feat(api): add backend with kanban, docs store, and orchestrator

Introduce the @void-nav/api Hono + SQLite backend that powers the docs site:
a persisted implementation board (kanban), a custom documentation-pages store
with AI beautify, and an agentic orchestrator that runs `pi` agents per card.

The orchestrator spawns `pi --mode rpc` inside an isolated git worktree per run,
streams slim events over SSE, and lets the agent drive the board/docs via
token-gated internal endpoints (all SQLite writes stay in-process). Interrupted
runs are reconciled to "stopped" on boot.

Workspace wiring: root `dev:api`/`dev:web` scripts with `concurrently`, the
docs Vite `/api` proxy, and `.worktrees/` gitignore.
This commit is contained in:
2026-06-16 15:43:58 -04:00
parent 57633addfe
commit efdc34637e
24 changed files with 3825 additions and 981 deletions

View File

@@ -0,0 +1,90 @@
/**
* Git worktree management for isolated agent runs.
*
* Each run optionally gets its own worktree on a dedicated branch off the
* current `HEAD`. This keeps `main` clean, isolates heavy per-task build
* artifacts (e.g. the Rust `target/` dir), and lets multiple runs proceed in
* parallel without clobbering each other's working tree.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { BRANCH_PREFIX, REPO_ROOT, WORKTREE_ROOT } from './config.js';
export interface Worktree {
path: string;
branch: string;
}
/** Run a git command, returning trimmed stdout. */
function git(args: string[], opts?: { cwd?: string }): string {
return execSync(['git', ...args].join(' '), {
encoding: 'utf8',
cwd: opts?.cwd ?? REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
/** Filesystem path for a run's worktree. */
export function worktreePathFor(runId: string): string {
return path.join(WORKTREE_ROOT, runId);
}
/** Stable branch name derived from the card id + run id. */
export function branchNameFor(cardId: string, runId: string): string {
const safe = cardId.replace(/[^a-z0-9-]+/gi, '-').toLowerCase().replace(/^-+|-+$/g, '').slice(0, 24);
return `${BRANCH_PREFIX}/${safe || 'card'}-${runId.slice(0, 8)}`;
}
/**
* Create a new worktree on a fresh branch off `HEAD`.
*
* Throws if the branch already exists or the worktree path is taken; callers
* are expected to use a unique run id.
*/
export function createWorktree(runId: string, cardId: string): Worktree {
fs.mkdirSync(WORKTREE_ROOT, { recursive: true });
const wtPath = worktreePathFor(runId);
const branch = branchNameFor(cardId, runId);
git(['worktree', 'add', '-b', branch, JSON.stringify(wtPath)]);
return { path: wtPath, branch };
}
/** Short SHA at the head of a worktree (best-effort). */
export function headSha(wtPath: string): string | null {
try {
return git(['rev-parse', '--short', 'HEAD'], { cwd: wtPath });
} catch {
return null;
}
}
/** Best-effort uncommitted change summary in a worktree (`git status --short`). */
export function dirtySummary(wtPath: string): string {
try {
const out = git(['status', '--short'], { cwd: wtPath });
if (!out) return '';
const files = out.split('\n');
return `${files.length} changed file(s):\n${files.slice(0, 12).join('\n')}${files.length > 12 ? '\n…' : ''}`;
} catch {
return '';
}
}
/** Remove a worktree and delete its branch (best-effort, never throws). */
export function removeWorktree(runId: string, branch: string | null): void {
const wtPath = worktreePathFor(runId);
try {
git(['worktree', 'remove', '--force', JSON.stringify(wtPath)]);
} catch {
/* already gone */
}
if (branch) {
try {
git(['branch', '-D', branch]);
} catch {
/* branch may be checked out elsewhere or already deleted */
}
}
}