Files
Space-Game/apps/api/src/orchestrator/worktrees.ts
francy51 e4f0abed20 feat(api): add bevy playtest, worktree review, and drop seeding
- bevy.ts: spawn `cargo run` in a run's worktree to playtest its branch,
  batching build/runtime output as `bevy` events (capped at 2000/run)
- events.ts: shared appendRunEvent/nextSeq so the runner, the agent-driven
  internal mutations, and the bevy launcher all persist through one path
- runs.ts: track per-run bevy processes; stop them before worktree teardown
- worktrees.ts: review/merge ops (isWorktreePresent, mainDirtySummary,
  commitsAheadOfHead, diffPatch/stat, mergeBranch)
- orchestrator routes: diff, merge, bevy start/stop, bevy-status endpoints
- remove seeding: drop seed.ts + data/kanbanCards.ts, the /reset endpoint,
  and boot-time seeding; cards are plain persisted DB records now
2026-06-16 18:17:27 -04:00

218 lines
7.0 KiB
TypeScript

/**
* 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 */
}
}
}
// --- review & merge (human-triggered) --------------------------------------
/** Whether a worktree directory still exists on disk. */
export function isWorktreePresent(wtPath: string): boolean {
return fs.existsSync(wtPath);
}
/** Current checked-out branch at the main worktree (REPO_ROOT). */
export function currentBranch(): string {
return git(['rev-parse', '--abbrev-ref', 'HEAD']);
}
/** Empty when the main worktree is clean, else a short summary of changes. */
export function mainDirtySummary(): string {
const out = git(['status', '--porcelain']);
if (!out) return '';
const files = out.split('\n');
return `${files.length} uncommitted change(s) in the main worktree:\n${files.slice(0, 12).join('\n')}${files.length > 12 ? '\n…' : ''}`;
}
/** True if `branch` is already contained in HEAD (nothing to merge). */
export function isMergedIntoHead(branch: string): boolean {
try {
git(['merge-base', '--is-ancestor', branch, 'HEAD']);
return true;
} catch {
return false;
}
}
export interface CommitLog {
sha: string;
subject: string;
}
/** Commits on `branch` that are not yet on HEAD (what a merge would bring in). */
export function commitsAheadOfHead(branch: string): CommitLog[] {
let out: string;
try {
out = git(['log', '--no-decorate', '--pretty=format:%h%x09%s', `HEAD..${branch}`]);
} catch {
return [];
}
if (!out) return [];
return out.split('\n').map((line) => {
const idx = line.indexOf('\t');
return idx >= 0
? { sha: line.slice(0, idx), subject: line.slice(idx + 1) }
: { sha: line, subject: '' };
});
}
/** Compact `--stat` summary of what `branch` changed since it forked from HEAD. */
export function diffStat(branch: string): string {
try {
return git(['diff', '--stat', `HEAD...${branch}`]);
} catch {
return '';
}
}
/** Full diff of what `branch` changed since it forked from HEAD, size-capped. */
export function diffPatch(branch: string, maxBytes = 60_000): { patch: string; truncated: boolean } {
let raw = '';
try {
raw = execSync(`git diff HEAD...${branch}`, {
encoding: 'utf8',
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'ignore'],
maxBuffer: 10 * 1024 * 1024,
});
} catch {
return { patch: '', truncated: false };
}
if (raw.length <= maxBytes) return { patch: raw, truncated: false };
return { patch: raw.slice(0, maxBytes), truncated: true };
}
export interface MergeResult {
ok: boolean;
/** Branch was already an ancestor of HEAD — merge was a no-op. */
alreadyMerged: boolean;
/** Branch the merge targeted (the main worktree's checked-out branch). */
target: string;
branch: string;
output: string;
}
/**
* Merge an agent branch into the main worktree's checked-out branch.
*
* Refuses (returns `ok:false`) on conflict, aborting the merge so the main tree
* is left clean for a manual resolution. Callers should check `mainDirtySummary`
* first — git will refuse to merge a dirty tree and this returns that as a
* failure too.
*/
export function mergeBranch(branch: string): MergeResult {
const target = currentBranch();
if (isMergedIntoHead(branch)) {
return { ok: true, alreadyMerged: true, target, branch, output: `${branch} is already in ${target}.` };
}
try {
const out = execSync(
`git merge --no-ff --no-edit -m ${JSON.stringify(`Merge agent branch ${branch} into ${target}`)} ${branch}`,
{ encoding: 'utf8', cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'] },
);
return { ok: true, alreadyMerged: false, target, branch, output: out.trim() || `Merged ${branch} into ${target}.` };
} catch (err) {
// Abort any in-progress merge so the main worktree stays clean.
try {
execSync('git merge --abort', { cwd: REPO_ROOT, stdio: 'ignore' });
} catch {
/* not mid-merge */
}
const stderr = (err as { stderr?: Buffer }).stderr?.toString().trim() ?? '';
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
alreadyMerged: false,
target,
branch,
output: stderr || `Merge of ${branch} failed (aborted; ${target} left clean):
${message}`,
};
}
}