Two board-level additions: Merge all reviewable agent branches into the main branch sequentially: - worktrees.mergeBranches() merges candidates in order, advancing HEAD each time; refuses if the main tree is dirty, and on the first conflict aborts that merge (main left clean) and stops — prior merges stay, untried branches reported as unattempted. - runManager.mergeAll() picks candidates (settled runs that own their worktree, branch present, not yet in HEAD; deduped, oldest-first) — so refinement runs (which inherit a worktree) are correctly excluded. - POST /runs/merge-all; ⬇ Merge all button in the board header opens a results modal (per-branch ✓/⇢/⚠, conflict banner, inline git output). Floating dock to view/open running agents (front-end only): - RunningAgentsBar pins bottom-right (below the card modal), lists every running run with live elapsed times, and opens that card's modal on click; auto-hides when nothing runs, collapsible otherwise. EOF && echo "" && git log --oneline -4
173 lines
4.9 KiB
TypeScript
173 lines
4.9 KiB
TypeScript
/**
|
|
* Typed client for the @void-nav/api orchestrator backend.
|
|
*
|
|
* The orchestrator turns the implementation board into an agentic system: each
|
|
* run hands a kanban card to a `pi` subprocess (running inside an isolated git
|
|
* worktree) and streams its progress back to the UI. The agent drives the board
|
|
* and the documentation through the server's internal endpoints, so the board
|
|
* stays fully interactive and the docs remain the central store of truth.
|
|
*
|
|
* Keep these types in sync with `apps/api/src/types.ts`.
|
|
*/
|
|
|
|
export type RunStatus = 'queued' | 'running' | 'completed' | 'failed' | 'stopped';
|
|
|
|
export interface AgentRun {
|
|
id: string;
|
|
cardId: string;
|
|
status: RunStatus;
|
|
useWorktree: boolean;
|
|
/** Whether this run created (and therefore owns/cleans up) its worktree. */
|
|
ownsWorktree: boolean;
|
|
branch: string | null;
|
|
worktreePath: string | null;
|
|
/** pi session JSONL path persisted for this run (resumable by refinements). */
|
|
sessionFile: string | null;
|
|
prompt: string;
|
|
summary: string | null;
|
|
commitSha: string | null;
|
|
error: string | null;
|
|
createdAt: string;
|
|
startedAt: string | null;
|
|
finishedAt: string | null;
|
|
}
|
|
|
|
export type RunEventType =
|
|
| 'status'
|
|
| 'text'
|
|
| 'tool_start'
|
|
| 'tool_end'
|
|
| 'log'
|
|
| 'bevy'
|
|
| 'done'
|
|
| 'error';
|
|
|
|
export interface RunEvent {
|
|
seq?: number;
|
|
type: RunEventType;
|
|
data: Record<string, unknown>;
|
|
createdAt?: string;
|
|
}
|
|
|
|
const BASE = '/api/orchestrator';
|
|
|
|
class ApiError extends Error {
|
|
constructor(message: string, readonly status: number) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
}
|
|
}
|
|
|
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
...init,
|
|
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
|
});
|
|
if (!res.ok) {
|
|
let detail = `${res.status} ${res.statusText}`;
|
|
try {
|
|
const body = await res.json();
|
|
if (body?.error) detail = body.error;
|
|
} catch {
|
|
/* keep status text */
|
|
}
|
|
throw new ApiError(detail, res.status);
|
|
}
|
|
if (res.status === 204) return undefined as T;
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
export interface StartRunInput {
|
|
cardId: string;
|
|
prompt?: string;
|
|
useWorktree?: boolean;
|
|
cleanupOnFinish?: boolean;
|
|
/**
|
|
* If set, start a refinement run that continues this prior run's branch with
|
|
* `prompt` as the operator's refinement feedback.
|
|
*/
|
|
refineRunId?: string;
|
|
}
|
|
|
|
/** A commit on a run's branch that is not yet on main. */
|
|
export interface DiffCommit {
|
|
sha: string;
|
|
subject: string;
|
|
}
|
|
|
|
/** A run's branch diff vs main. */
|
|
export interface DiffResult {
|
|
branch: string;
|
|
commits: DiffCommit[];
|
|
stat: string;
|
|
patch: string;
|
|
truncated: boolean;
|
|
}
|
|
|
|
/** Outcome of merging a run's branch into the main worktree. */
|
|
export interface MergeResult {
|
|
ok: boolean;
|
|
alreadyMerged: boolean;
|
|
target: string;
|
|
branch: string;
|
|
output: string;
|
|
}
|
|
|
|
/** One branch's outcome in a batch (merge-all) merge. */
|
|
export interface BatchMergeItem extends MergeResult {
|
|
/** The run this branch belonged to. */
|
|
runId: string;
|
|
}
|
|
|
|
/** Result of merging several branches sequentially into the main branch. */
|
|
export interface BatchMergeResult {
|
|
target: string;
|
|
/** One entry per candidate branch, in the order processed. */
|
|
items: BatchMergeItem[];
|
|
/** The branch that conflicted and stopped the batch, if any. */
|
|
haltedOn: string | null;
|
|
/** True if the main worktree was dirty and nothing was attempted. */
|
|
aborted: boolean;
|
|
/** Human-readable reason when aborted or halted. */
|
|
reason: string | null;
|
|
}
|
|
|
|
export const orchestratorApi = {
|
|
listRuns: (cardId?: string) =>
|
|
req<{ runs: AgentRun[] }>(`/runs${cardId ? `?cardId=${encodeURIComponent(cardId)}` : ''}`),
|
|
|
|
getRun: (id: string, since = 0) =>
|
|
req<{ run: AgentRun; events: RunEvent[] }>(`/runs/${id}?since=${since}`),
|
|
|
|
startRun: (input: StartRunInput) =>
|
|
req<{ run: AgentRun }>(`/runs`, { method: 'POST', body: JSON.stringify(input) }),
|
|
|
|
messageRun: (id: string, text: string, mode: 'steer' | 'followUp') =>
|
|
req<{ ok: boolean }>(`/runs/${id}/message`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ text, mode }),
|
|
}),
|
|
|
|
stopRun: (id: string) =>
|
|
req<{ ok: boolean }>(`/runs/${id}/stop`, { method: 'POST' }),
|
|
|
|
deleteRun: (id: string) => req<void>(`/runs/${id}`, { method: 'DELETE' }),
|
|
|
|
// --- worktree review & playtesting ---
|
|
|
|
getDiff: (id: string) => req<DiffResult>(`/runs/${id}/diff`),
|
|
|
|
mergeRun: (id: string) =>
|
|
req<MergeResult>(`/runs/${id}/merge`, { method: 'POST' }),
|
|
|
|
/** Merge all reviewable agent branches into the main branch sequentially. */
|
|
mergeAll: () => req<BatchMergeResult>('/runs/merge-all', { method: 'POST' }),
|
|
|
|
bevyStatus: (id: string) => req<{ running: boolean }>(`/runs/${id}/bevy`),
|
|
|
|
startBevy: (id: string) => req<{ ok: boolean }>(`/runs/${id}/bevy`, { method: 'POST' }),
|
|
|
|
stopBevy: (id: string) =>
|
|
req<{ ok: boolean }>(`/runs/${id}/bevy/stop`, { method: 'POST' }),
|
|
};
|