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:
90
apps/api/src/orchestrator/worktrees.ts
Normal file
90
apps/api/src/orchestrator/worktrees.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user