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:
3
apps/api/.gitignore
vendored
Normal file
3
apps/api/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Local SQLite database and runtime artifacts
|
||||
.data/
|
||||
dist/
|
||||
24
apps/api/package.json
Normal file
24
apps/api/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@void-nav/api",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Backend API for the VOID::NAV docs site (Implementation Board persistence).",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"hono": "^4.12.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^25.9.3",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
124
apps/api/src/ai.ts
Normal file
124
apps/api/src/ai.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* AI "beautify" helper.
|
||||
*
|
||||
* Turns a user's raw text into structured HTML documentation with embedded
|
||||
* Mermaid diagram blocks.
|
||||
*
|
||||
* Uses the **same model pi uses**: GLM-5.2 via the z.ai coding endpoint
|
||||
* (`https://api.z.ai/api/coding/paas/v4`), an OpenAI-compatible Chat
|
||||
* Completions API, with a 1,000,000-token context window and 131,072 max
|
||||
* output tokens. See `~/.pi/agent/models.json` (provider `zai-coding`) for the
|
||||
* canonical configuration this mirrors.
|
||||
*
|
||||
* The API key is resolved the same way pi resolves it: from
|
||||
* `~/.pi/agent/auth.json` under `zai.key`. It can be overridden with the
|
||||
* `ZAI_API_KEY` env var (useful for CI / containers). Other aspects are
|
||||
* overridable too:
|
||||
* - ZAI_BASE_URL (defaults to the z.ai coding v4 endpoint)
|
||||
* - ZAI_MODEL (defaults to `glm-5.2`)
|
||||
* - ZAI_MAX_TOKENS (defaults to 131072, matching pi's maxTokens for glm-5.2)
|
||||
*
|
||||
* When no key is available, `beautify` throws `AiNotConfiguredError` so the
|
||||
* route layer can return a clear 503 instead of a generic failure.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.z.ai/api/coding/paas/v4';
|
||||
const DEFAULT_MODEL = 'glm-5.2';
|
||||
const DEFAULT_MAX_TOKENS = 131_072;
|
||||
|
||||
/** GLM-5.2 supports a 1,000,000-token context window (pi: contextWindow: 1000000). */
|
||||
export const GLM_CONTEXT_WINDOW = 1_000_000;
|
||||
|
||||
export class AiNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
'AI beautify is not configured — add a key to ~/.pi/agent/auth.json (zai.key) or set ZAI_API_KEY',
|
||||
);
|
||||
this.name = 'AiNotConfiguredError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the z.ai API key the same way pi does: prefer the explicit env var,
|
||||
* then fall back to `~/.pi/agent/auth.json` → `zai.key`.
|
||||
*/
|
||||
function resolveApiKey(): string | undefined {
|
||||
if (process.env.ZAI_API_KEY) return process.env.ZAI_API_KEY;
|
||||
try {
|
||||
const authPath = path.join(homedir(), '.pi', 'agent', 'auth.json');
|
||||
const raw = readFileSync(authPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { zai?: { key?: string } };
|
||||
return parsed.zai?.key;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAiConfigured(): boolean {
|
||||
return Boolean(resolveApiKey());
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `You are a technical documentation writer for VOID::NAV, a single-player
|
||||
narrative-driven space exploration RPG. Convert the user's rough notes into polished,
|
||||
well-structured HTML documentation that fits a dark, monospace-accented sci-fi UI.
|
||||
|
||||
Rules:
|
||||
- Return a SINGLE HTML fragment (no <html>, <head>, <body>, or <script>).
|
||||
- Use semantic tags: <h1> (once, as the page title), <h2>, <h3>, <p>, <ul>, <ol>, <li>,
|
||||
<table>, <thead>, <tbody>, <tr>, <th>, <td>, <blockquote>, <code>, <pre>, <strong>, <em>.
|
||||
- Do NOT add classes or inline styles; the host app provides styling.
|
||||
- For any flow / architecture / sequence / relationship, include a Mermaid diagram
|
||||
inside a <div class="mermaid"> ... </div> block using valid Mermaid syntax
|
||||
(flowchart, sequenceDiagram, classDiagram, erDiagram, stateDiagram-v2, etc.).
|
||||
- Be concise and information-dense. Preserve all factual content from the user's input.
|
||||
- If the input is ambiguous, make reasonable, evocative choices consistent with a space RPG.
|
||||
- Output ONLY the HTML fragment, nothing else.`;
|
||||
|
||||
interface BeautifyResult {
|
||||
html: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export async function beautify(rawText: string): Promise<BeautifyResult> {
|
||||
const apiKey = resolveApiKey();
|
||||
if (!apiKey) throw new AiNotConfiguredError();
|
||||
|
||||
const baseUrl = (process.env.ZAI_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, '');
|
||||
const model = process.env.ZAI_MODEL ?? DEFAULT_MODEL;
|
||||
const maxTokens = Number(process.env.ZAI_MAX_TOKENS ?? DEFAULT_MAX_TOKENS);
|
||||
|
||||
const res = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
// Mirror pi's defaults for glm-5.2: high thinking, 1M context, 131072 max output.
|
||||
thinking: { type: 'enabled' },
|
||||
max_tokens: maxTokens,
|
||||
temperature: 0.5,
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: rawText },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`AI provider error ${res.status}: ${detail}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
};
|
||||
const html = data.choices?.[0]?.message?.content?.trim() ?? '';
|
||||
if (!html) throw new Error('AI provider returned an empty response');
|
||||
return { html, model };
|
||||
}
|
||||
32
apps/api/src/data/docPages.ts
Normal file
32
apps/api/src/data/docPages.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { DocPage } from '../types.js';
|
||||
|
||||
/**
|
||||
* Registry of documentation pages that kanban cards can reference.
|
||||
*
|
||||
* Mirrors the routes defined in `apps/docs/src/data/nav.ts` and
|
||||
* `apps/docs/src/App.tsx`. When a doc page is added/removed there, update this
|
||||
* list so the board's "add reference" picker and canonical links stay accurate.
|
||||
*/
|
||||
export const DOC_PAGES: DocPage[] = [
|
||||
{ path: '/docs/overview', title: 'Overview', icon: '◈', blurb: 'Vision, pillars, core loop, onboarding flow.' },
|
||||
{ path: '/docs/architecture', title: 'Architecture', icon: '⬡', blurb: 'System architecture, persistence model, audio.' },
|
||||
{ path: '/docs/tech-stack', title: 'Tech Stack', icon: '⟐', blurb: 'Technology decisions and rationale.' },
|
||||
{ path: '/docs/backend', title: 'Backend Model', icon: '⊞', blurb: 'Database tables, movement model, ER diagram.' },
|
||||
{ path: '/docs/agents', title: 'Agent Lifecycle', icon: '⏣', blurb: 'Scheduled agent system and galaxy story agents.' },
|
||||
{ path: '/docs/gameplay', title: 'Gameplay Loop', icon: '◉', blurb: 'Core loop, security, combat, missions, travel, events.' },
|
||||
{ path: '/docs/ships', title: 'Ships & Fitting', icon: '◇', blurb: 'Ship classes, fitting, acquisition, AI crew.' },
|
||||
{ path: '/docs/economy', title: 'Economy & Industry', icon: '⇄', blurb: 'Trade routes, refining, manufacturing, faucets/sinks.' },
|
||||
{ path: '/docs/social', title: 'Progression & World', icon: '✧', blurb: 'XP & skills, chat, bounty, kill feed, corporations.' },
|
||||
{ path: '/docs/ship-ai', title: 'Ship AI — Zora', icon: '◈', blurb: 'Zora companion AI, soul depth, module gating.' },
|
||||
{ path: '/docs/roadmap', title: 'Roadmap', icon: '⊞', blurb: '16 phases, 2 eras, integration gates.' },
|
||||
{ path: '/docs/kanban-board', title: 'Implementation Board', icon: '▦', blurb: 'This board — implementation status vs. design.' },
|
||||
{ path: '/docs/risks', title: 'Risks & Questions', icon: '◬', blurb: 'Open risks and open questions.' },
|
||||
{ path: '/docs/gap-analysis', title: 'Gap Analysis', icon: '□', blurb: 'Specs vs demos vs readiness.' },
|
||||
{ path: '/docs/vertical-slice-evaluation', title: 'Slice Evaluation', icon: '▤', blurb: 'Alignment matrix, phase readiness.' },
|
||||
{ path: '/docs/design-doc', title: 'Design Doc', icon: '▣', blurb: 'Original GDD reference.' },
|
||||
];
|
||||
|
||||
/** Look up a page by its route path. */
|
||||
export function findPage(path: string): DocPage | undefined {
|
||||
return DOC_PAGES.find((p) => p.path === path);
|
||||
}
|
||||
505
apps/api/src/data/kanbanCards.ts
Normal file
505
apps/api/src/data/kanbanCards.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
import type { Column, ReferenceType } from '../types.js';
|
||||
|
||||
/**
|
||||
* Canonical card definitions — the single source of truth for card *content*
|
||||
* (title, description, details, category, files, notes).
|
||||
*
|
||||
* On startup `seed()` upserts these into SQLite. The `status` (column) is only
|
||||
* applied when a card is first created; afterwards the user's moves are
|
||||
* preserved across reseeds. User-entered comments, tags, and references live
|
||||
* entirely in the database.
|
||||
*
|
||||
* `references` link each card to the design doc pages that specify it — turning
|
||||
* the board into a bridge between implementation status and design.
|
||||
*/
|
||||
export interface SeedReference {
|
||||
label: string;
|
||||
type: ReferenceType;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface SeedCard {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
details: string;
|
||||
category: string;
|
||||
files: string | null;
|
||||
notes: string | null;
|
||||
status: Column;
|
||||
sortOrder: number;
|
||||
references: SeedReference[];
|
||||
}
|
||||
|
||||
/** Helper for a doc-page reference. */
|
||||
function doc(path: string, label: string): SeedReference {
|
||||
return { label, type: 'doc', href: path };
|
||||
}
|
||||
|
||||
const N = (s: string | null) => s;
|
||||
|
||||
export const CARDS: SeedCard[] = [
|
||||
// ── DONE ──────────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'G1',
|
||||
title: 'Galaxy Generation & Viewer',
|
||||
description: 'Complete procedural spiral galaxy with core cluster, disk layers, and beam structures. Interactive 3D viewer with star selection, parameter controls, and system connections.',
|
||||
details: 'Fully functional: generates star systems with faction distribution, nearest-neighbor connections, and POI contents. Control panel allows real-time parameter adjustment.',
|
||||
category: 'Onboarding',
|
||||
files: 'apps/game/src/gameplay/galaxy/mod.rs, apps/game/src/gameplay/galaxy/contents.rs',
|
||||
notes: N(null),
|
||||
status: 'done',
|
||||
sortOrder: 10,
|
||||
references: [doc('/docs/overview', 'Overview'), doc('/docs/backend', 'Backend Model')],
|
||||
},
|
||||
{
|
||||
id: 'G2',
|
||||
title: 'Character Creation',
|
||||
description: 'Bannerlord-style character generation with name selection, 4 origin archetypes, 3 ship choices, and 5-step backstory questionnaire with stat bonuses.',
|
||||
details: 'Fully functional: complete UI with scrollable panels, stat calculation display, and character draft persistence for campaign.',
|
||||
category: 'Onboarding',
|
||||
files: 'apps/game/src/gameplay/character_creation/mod.rs',
|
||||
notes: N(null),
|
||||
status: 'done',
|
||||
sortOrder: 11,
|
||||
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/overview', 'Overview')],
|
||||
},
|
||||
{
|
||||
id: 'G3',
|
||||
title: 'Starting Base Selection',
|
||||
description: 'System selection from outer-galaxy candidates with 3D visualization, POI display, and interactive camera controls.',
|
||||
details: 'Fully functional: shows candidate systems from generated galaxy, with camera focus on selection and system contents preview.',
|
||||
category: 'Onboarding',
|
||||
files: 'apps/game/src/gameplay/starting_base/mod.rs',
|
||||
notes: N(null),
|
||||
status: 'done',
|
||||
sortOrder: 12,
|
||||
references: [doc('/docs/gameplay', 'Gameplay Loop')],
|
||||
},
|
||||
{
|
||||
id: 'S1',
|
||||
title: 'Game State Management',
|
||||
description: 'AppState with clean transitions: MainMenu, Galaxy, CharacterCreation, StartingBaseSelection, InGame, Options.',
|
||||
details: 'Fully functional: state system works properly with OnEnter/OnExit/Update schedules for each game phase.',
|
||||
category: 'Core',
|
||||
files: 'apps/game/src/state.rs',
|
||||
notes: N(null),
|
||||
status: 'done',
|
||||
sortOrder: 20,
|
||||
references: [doc('/docs/architecture', 'Architecture')],
|
||||
},
|
||||
{
|
||||
id: 'S2',
|
||||
title: 'Campaign Persistence',
|
||||
description: 'Galaxy draft, character draft, and starting base selection persisted through game flow.',
|
||||
details: 'Fully functional: Resources carry data through state transitions. New game properly clears previous state.',
|
||||
category: 'Core',
|
||||
files: 'apps/game/src/gameplay/campaign.rs',
|
||||
notes: N(null),
|
||||
status: 'done',
|
||||
sortOrder: 21,
|
||||
references: [doc('/docs/architecture', 'Architecture'), doc('/docs/backend', 'Backend Model')],
|
||||
},
|
||||
{
|
||||
id: 'C1',
|
||||
title: 'Camera System',
|
||||
description: 'Three camera modes: Orbit (for galaxy inspection), Follow (for flight), Cinematic (for docked views).',
|
||||
details: 'Fully functional: orbit camera with mouse controls, camera transitions between modes, proper target following.',
|
||||
category: 'Core',
|
||||
files: 'apps/game/src/camera.rs',
|
||||
notes: N(null),
|
||||
status: 'done',
|
||||
sortOrder: 22,
|
||||
references: [doc('/docs/gameplay', 'Gameplay Loop')],
|
||||
},
|
||||
{
|
||||
id: 'P1',
|
||||
title: 'POI Data Structures',
|
||||
description: 'Complete POI type system: 7 planet types, asteroid belts, stations, stargates, anomalies, gas clouds.',
|
||||
details: 'Fully functional: POI generation with orbital mechanics, faction-based biasing, and system context awareness.',
|
||||
category: 'Content',
|
||||
files: 'apps/game/src/gameplay/galaxy/poi.rs, apps/game/src/gameplay/galaxy/contents.rs',
|
||||
notes: N(null),
|
||||
status: 'done',
|
||||
sortOrder: 30,
|
||||
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/backend', 'Backend Model')],
|
||||
},
|
||||
|
||||
// ── IN PROGRESS ───────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'I1',
|
||||
title: 'In-System Scene Framework',
|
||||
description: 'Scene setup with star, POIs, and player ship. Docked/Flight state management exists.',
|
||||
details: 'Framework exists: scene spawns correctly, state transitions work. But UI is commented out and most gameplay features are stubs.',
|
||||
category: 'In-System',
|
||||
files: 'apps/game/src/gameplay/in_system/mod.rs, apps/game/src/gameplay/in_system/scene.rs',
|
||||
notes: 'UI systems removed/commented. No flight HUD visible to player.',
|
||||
status: 'in-progress',
|
||||
sortOrder: 110,
|
||||
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/architecture', 'Architecture')],
|
||||
},
|
||||
{
|
||||
id: 'I2',
|
||||
title: 'Target Selection',
|
||||
description: 'Click-to-select POIs (stations, asteroid belts) with selection tracking.',
|
||||
details: 'Framework exists: target selection works, but no gameplay uses the target yet. No distance display or spatial grouping.',
|
||||
category: 'In-System',
|
||||
files: 'apps/game/src/gameplay/in_system/target.rs',
|
||||
notes: 'Selection system works but no gameplay features depend on it yet.',
|
||||
status: 'in-progress',
|
||||
sortOrder: 111,
|
||||
references: [doc('/docs/gameplay', 'Gameplay Loop')],
|
||||
},
|
||||
{
|
||||
id: 'I3',
|
||||
title: 'Operations Framework',
|
||||
description: 'Timed operations system with undock (3s), travel (5s). Framework for docking (4s) and mining (8s).',
|
||||
details: 'Partial: undock and travel work. Docking and mining are skeleton operations marked TODO. No actual gameplay consequences.',
|
||||
category: 'In-System',
|
||||
files: 'apps/game/src/gameplay/in_system/operations.rs, apps/game/src/gameplay/in_system/flight.rs',
|
||||
notes: 'Only undock and approach are implemented. Docking and mining are TODO.',
|
||||
status: 'in-progress',
|
||||
sortOrder: 112,
|
||||
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/economy', 'Economy & Industry')],
|
||||
},
|
||||
{
|
||||
id: 'M1',
|
||||
title: 'Basic Movement',
|
||||
description: 'Click-to-move navigation with kinematic steering and velocity integration.',
|
||||
details: 'Partial: ship uses click-to-move + kinematic steering + velocity integration. Celestial-body orbital motion exists (movement/orbit.rs: Orbit component + update_orbits, registered in MovementPlugin). Ship flight itself is still kinematic — no physics thrust, no mass/inertia, no collision avoidance.',
|
||||
category: 'In-System',
|
||||
files: 'apps/game/src/gameplay/movement/{mod,kinematic,orbit,input,components}.rs',
|
||||
notes: 'Orbit component drives POIs/celestial bodies, not ship flight. AI ships reuse MoveTarget for navigation.',
|
||||
status: 'in-progress',
|
||||
sortOrder: 120,
|
||||
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/architecture', 'Architecture')],
|
||||
},
|
||||
{
|
||||
id: 'U1',
|
||||
title: 'Basic UI Shell',
|
||||
description: 'Main menu, galaxy control panel, info panels. Foundation for UI exists.',
|
||||
details: 'Partial: main menu and galaxy UI work. In-game flight UI is commented out. No station mode panel swap.',
|
||||
category: 'UI',
|
||||
files: 'apps/game/src/ui/',
|
||||
notes: 'Flight HUD and station UI are removed/commented in code.',
|
||||
status: 'in-progress',
|
||||
sortOrder: 130,
|
||||
references: [doc('/docs/overview', 'Overview')],
|
||||
},
|
||||
{
|
||||
id: 'I5',
|
||||
title: 'Contextual Action Panel',
|
||||
description: 'State-aware action buttons (Undock, Approach, Dock, Start Mining, Market, Fitting, Engage) shown bottom-right during InGame.',
|
||||
details: 'Built: ContextualActionPlugin registered in InSystemPlugin. Buttons are spawned/refreshed from game state (docked vs. flight, selected TargetKind) and fire ActionTriggeredEvent. Undock + Approach are wired to timed operations.',
|
||||
category: 'In-System',
|
||||
files: 'apps/game/src/gameplay/in_system/actions.rs',
|
||||
notes: 'Dock and StartMining handlers are TODO stubs; OpenMarket/OpenFitting trigger nothing yet (await those systems).',
|
||||
status: 'in-progress',
|
||||
sortOrder: 113,
|
||||
references: [doc('/docs/gameplay', 'Gameplay Loop')],
|
||||
},
|
||||
{
|
||||
id: 'N1',
|
||||
title: 'NPC Ships & AI',
|
||||
description: 'NPC spawning by system security & faction, state-machine behaviour (Idle/Patrol/Combat/Flee/Mining/Trading), perception, navigation, faction profiles.',
|
||||
details: 'Built & registered: AiPlugin runs on enter InGame + every Update. ~1.4k lines across ai/{behavior,states,navigation,perception,spawning,faction}. Perception emits events; behaviours assign MoveTargets; faction profiles drive aggression & engagement distance.',
|
||||
category: 'NPCs',
|
||||
files: 'apps/game/src/gameplay/ai/',
|
||||
notes: 'Framework complete but unbacked by gameplay: combat "attack" is a log line (no damage system), mining/trading have no economy, flee safe-distance check is TODO.',
|
||||
status: 'in-progress',
|
||||
sortOrder: 140,
|
||||
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/agents', 'Agent Lifecycle'), doc('/docs/social', 'Progression & World')],
|
||||
},
|
||||
{
|
||||
id: 'ST1.1',
|
||||
title: 'Event Logging System',
|
||||
description: 'Capture all player actions (mining, trading, combat, exploration, docking, missions) as structured GameEvents on an event bus.',
|
||||
details: 'Framework built: GameEvent enum (7 variants) + log_game_events system registered in NarrativePlugin. BUT no gameplay system emits GameEvents yet, so the bus stays empty.',
|
||||
category: 'Narrative',
|
||||
files: 'apps/game/src/gameplay/narrative/events.rs',
|
||||
notes: 'Next step: wire emitters into mining/docking/trade/combat once those systems exist.',
|
||||
status: 'in-progress',
|
||||
sortOrder: 150,
|
||||
references: [doc('/docs/agents', 'Agent Lifecycle'), doc('/docs/ship-ai', 'Ship AI — Zora')],
|
||||
},
|
||||
{
|
||||
id: 'ST1.3',
|
||||
title: 'Campaign History Tracking',
|
||||
description: 'Persistent timeline of events with automatic chapter detection, key-moment extraction, and aggregate statistics.',
|
||||
details: 'Framework built: CampaignHistory resource + detect_chapters system registered. Maintains chapters, key moments, and per-category statistics. Depends on ST1.1 emitters to actually populate.',
|
||||
category: 'Narrative',
|
||||
files: 'apps/game/src/gameplay/narrative/history.rs',
|
||||
notes: 'History is only as rich as the events fed into it — currently empty.',
|
||||
status: 'in-progress',
|
||||
sortOrder: 151,
|
||||
references: [doc('/docs/agents', 'Agent Lifecycle')],
|
||||
},
|
||||
|
||||
// ── TODO ──────────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'I4',
|
||||
title: 'Complete In-System Gameplay',
|
||||
description: 'Docking operation, mining operation with output, cargo collection, station interactions.',
|
||||
details: 'Missing: docking only undocks, mining has no resource output. No station services, no refueling, no repairs.',
|
||||
category: 'In-System',
|
||||
files: 'apps/game/src/gameplay/in_system/operations.rs',
|
||||
notes: 'Docks and mines marked as TODO in operations.rs.',
|
||||
status: 'todo',
|
||||
sortOrder: 210,
|
||||
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/economy', 'Economy & Industry')],
|
||||
},
|
||||
{
|
||||
id: 'U2',
|
||||
title: 'Flight HUD / In-Game UI',
|
||||
description: 'Target reticle, ship status display, operation progress bars, contextual action buttons.',
|
||||
details: 'Missing: all in-game UI is commented out. Player has no visual feedback during flight.',
|
||||
category: 'UI',
|
||||
files: 'apps/game/src/gameplay/in_system/',
|
||||
notes: 'UI setup and update systems are commented out in mod.rs.',
|
||||
status: 'todo',
|
||||
sortOrder: 220,
|
||||
references: [doc('/docs/gameplay', 'Gameplay Loop')],
|
||||
},
|
||||
{
|
||||
id: 'M2',
|
||||
title: 'Star Map Plugin',
|
||||
description: 'Galaxy-scale navigation showing all systems, hyperspace lanes, and current position.',
|
||||
details: 'Missing: plugin structure exists but implementation is empty. No camera, no rendering, no interaction.',
|
||||
category: 'Navigation',
|
||||
files: 'apps/game/src/gameplay/star_map/mod.rs',
|
||||
notes: 'File contains only TODO comments and empty plugin struct.',
|
||||
status: 'todo',
|
||||
sortOrder: 230,
|
||||
references: [doc('/docs/gameplay', 'Gameplay Loop')],
|
||||
},
|
||||
{
|
||||
id: 'P2',
|
||||
title: 'Physics & Collision',
|
||||
description: 'Mass-based interactions, collision detection between ships and objects, spatial queries.',
|
||||
details: 'Missing: only basic geometry primitives exist. No collision system, no physics simulation.',
|
||||
category: 'Core',
|
||||
files: 'apps/game/src/gameplay/physics/mod.rs',
|
||||
notes: 'Module exports marker components only. No actual physics systems.',
|
||||
status: 'todo',
|
||||
sortOrder: 23,
|
||||
references: [doc('/docs/architecture', 'Architecture'), doc('/docs/gameplay', 'Gameplay Loop')],
|
||||
},
|
||||
{
|
||||
id: 'R1',
|
||||
title: 'Resource System',
|
||||
description: 'Ore types, cargo inventory, resource collection during mining.',
|
||||
details: 'Missing: no resource types defined, no inventory system, no cargo hold.',
|
||||
category: 'Economy',
|
||||
files: 'N/A',
|
||||
notes: 'No resource or inventory modules exist in codebase.',
|
||||
status: 'todo',
|
||||
sortOrder: 240,
|
||||
references: [doc('/docs/economy', 'Economy & Industry'), doc('/docs/demos/refining', 'Refining demo')],
|
||||
},
|
||||
{
|
||||
id: 'E1',
|
||||
title: 'Economy System',
|
||||
description: 'Credits, market prices, trading, station markets.',
|
||||
details: 'Missing: no economy simulation, no pricing, no trade mechanics.',
|
||||
category: 'Economy',
|
||||
files: 'N/A',
|
||||
notes: 'No economy modules exist in codebase.',
|
||||
status: 'todo',
|
||||
sortOrder: 241,
|
||||
references: [doc('/docs/economy', 'Economy & Industry'), doc('/docs/demos/market', 'Market demo')],
|
||||
},
|
||||
{
|
||||
id: 'C2',
|
||||
title: 'Ship Components',
|
||||
description: 'Shields, armor, hull, capacitor, power grid, CPU.',
|
||||
details: 'Missing: ships have no stats beyond position. No component systems.',
|
||||
category: 'Ships',
|
||||
files: 'N/A',
|
||||
notes: 'Ship is just a mesh with position. No stats or systems.',
|
||||
status: 'todo',
|
||||
sortOrder: 250,
|
||||
references: [doc('/docs/ships', 'Ships & Fitting'), doc('/docs/gameplay', 'Gameplay Loop')],
|
||||
},
|
||||
{
|
||||
id: 'S4',
|
||||
title: 'Local Save / Disk Persistence',
|
||||
description: 'Continuous local persistence of campaign, character, galaxy, and story state (no manual save button).',
|
||||
details: 'Missing: Cargo.toml has only `bevy` — no serde/ron/fs. CampaignDraft + CampaignHistory are in-memory Resources lost on exit. Architecture doc mandates continuous auto-save as the source of truth.',
|
||||
category: 'Core',
|
||||
files: 'N/A — needs serde + a save module (e.g. gameplay/save.rs)',
|
||||
notes: 'Design refs: Architecture ("no save button, persists continuously"), Overview ("local persistence for V1"), AGENTS.md ("V1 is single-player with local persistence").',
|
||||
status: 'todo',
|
||||
sortOrder: 24,
|
||||
references: [doc('/docs/architecture', 'Architecture'), doc('/docs/backend', 'Backend Model'), doc('/docs/overview', 'Overview')],
|
||||
},
|
||||
{
|
||||
id: 'U3',
|
||||
title: 'Options / Settings Screen',
|
||||
description: 'Settings menu: graphics, audio, controls, accessibility. Reached from the main-menu Options button.',
|
||||
details: 'Missing: AppState::Options exists and the main-menu Options button transitions to it, but there are no OnEnter/OnExit/Update systems — selecting it dead-ends.',
|
||||
category: 'UI',
|
||||
files: 'apps/game/src/state.rs, apps/game/src/ui/main_menu.rs',
|
||||
notes: 'State variant + menu button exist; no screen implementation.',
|
||||
status: 'todo',
|
||||
sortOrder: 231,
|
||||
references: [doc('/docs/overview', 'Overview'), doc('/docs/architecture', 'Architecture')],
|
||||
},
|
||||
|
||||
// ── BACKLOG ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'C3',
|
||||
title: 'Combat System',
|
||||
description: 'Weapons, damage resolution, shield/armor/hull pools, combat AI, target locking.',
|
||||
details: 'Not started: no weapon systems, no damage calculations, no combat state. FTL-style auto-combat planned.',
|
||||
category: 'Combat',
|
||||
files: 'N/A',
|
||||
notes: 'No combat modules exist. Design specifies FTL-style power allocation combat.',
|
||||
status: 'backlog',
|
||||
sortOrder: 310,
|
||||
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/ships', 'Ships & Fitting'), doc('/docs/demos/combat', 'Combat demo')],
|
||||
},
|
||||
{
|
||||
id: 'S3',
|
||||
title: 'Multiple Ship Types',
|
||||
description: 'Frigate, hauler, scout, warship, battleship classes with different stats.',
|
||||
details: 'Not started: only generic starter ship mesh exists. No ship catalog or stat variations.',
|
||||
category: 'Ships',
|
||||
files: 'N/A',
|
||||
notes: 'Single cone+box+sphere ship. No classes or variations.',
|
||||
status: 'backlog',
|
||||
sortOrder: 320,
|
||||
references: [doc('/docs/ships', 'Ships & Fitting')],
|
||||
},
|
||||
{
|
||||
id: 'F1',
|
||||
title: 'Module Fitting',
|
||||
description: 'High/Med/Low slots, CPU/PG constraints, module catalog, fitting screen.',
|
||||
details: 'Not started: no fitting system. Design specifies full fitting with drag-and-drop.',
|
||||
category: 'Ships',
|
||||
files: 'N/A',
|
||||
notes: 'Reference: apps/docs/src/prototypes/existing-demos/FittingDemo.tsx',
|
||||
status: 'backlog',
|
||||
sortOrder: 321,
|
||||
references: [doc('/docs/ships', 'Ships & Fitting'), doc('/docs/demos/fitting', 'Fitting demo')],
|
||||
},
|
||||
{
|
||||
id: 'Q1',
|
||||
title: 'Quest & Mission System',
|
||||
description: 'NPC agents, mission templates (kill, courier, mining), active mission tracking.',
|
||||
details: 'Not started: no quest system. Design specifies 6 mission types with rewards.',
|
||||
category: 'Content',
|
||||
files: 'N/A',
|
||||
notes: 'Reference: design docs describe faction quests and mission templates.',
|
||||
status: 'backlog',
|
||||
sortOrder: 330,
|
||||
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/social', 'Progression & World'), doc('/docs/demos/bounty', 'Bounty demo')],
|
||||
},
|
||||
{
|
||||
id: 'A1',
|
||||
title: 'Faction System',
|
||||
description: 'Faction standing, loyalty points, reputation effects, faction-controlled space.',
|
||||
details: 'Not started: factions are colors only. No standing or reputation tracking.',
|
||||
category: 'Social',
|
||||
files: 'N/A',
|
||||
notes: 'Factions exist as labels only. No gameplay effects.',
|
||||
status: 'backlog',
|
||||
sortOrder: 340,
|
||||
references: [doc('/docs/social', 'Progression & World'), doc('/docs/gameplay', 'Gameplay Loop')],
|
||||
},
|
||||
{
|
||||
id: 'W1',
|
||||
title: 'Dynamic World Events',
|
||||
description: 'Random events, anomalies, faction conflicts, world state changes.',
|
||||
details: 'Not started: no event system. Design describes 6 event categories.',
|
||||
category: 'Content',
|
||||
files: 'N/A',
|
||||
notes: 'No world event modules exist.',
|
||||
status: 'backlog',
|
||||
sortOrder: 331,
|
||||
references: [doc('/docs/gameplay', 'Gameplay Loop'), doc('/docs/agents', 'Agent Lifecycle')],
|
||||
},
|
||||
{
|
||||
id: 'PR1',
|
||||
title: 'Skill & Progression',
|
||||
description: 'XP awards, skill catalog (Mining, Industry, Trade, Gunnery, Navigation), level 0-5.',
|
||||
details: 'Not started: no XP or skills. Design specifies 5 skills with tier bonuses.',
|
||||
category: 'Progression',
|
||||
files: 'N/A',
|
||||
notes: 'Reference: apps/docs/src/prototypes/existing-demos/ProgressionDemo.tsx',
|
||||
status: 'backlog',
|
||||
sortOrder: 350,
|
||||
references: [doc('/docs/social', 'Progression & World'), doc('/docs/demos/progression', 'Progression demo')],
|
||||
},
|
||||
{
|
||||
id: 'MF1',
|
||||
title: 'Manufacturing',
|
||||
description: 'Blueprints, production jobs, material requirements, output queues.',
|
||||
details: 'Not started: no manufacturing. Design: Ore→Mineral→Component→Module→Ship chain.',
|
||||
category: 'Industry',
|
||||
files: 'N/A',
|
||||
notes: 'Reference: apps/docs/src/prototypes/existing-demos/RefiningDemo.tsx',
|
||||
status: 'backlog',
|
||||
sortOrder: 360,
|
||||
references: [doc('/docs/economy', 'Economy & Industry'), doc('/docs/demos/refining', 'Refining demo')],
|
||||
},
|
||||
{
|
||||
id: 'Z1',
|
||||
title: 'Zora Ship AI',
|
||||
description: 'AI companion with personality, soul depth, tier progression (0-5).',
|
||||
details: 'Not started: no Zora implementation. Design specifies full AI companion system.',
|
||||
category: 'Ships',
|
||||
files: 'N/A',
|
||||
notes: 'Reference: apps/docs/src/prototypes/existing-demos/ZoraDemo.tsx',
|
||||
status: 'backlog',
|
||||
sortOrder: 370,
|
||||
references: [doc('/docs/ship-ai', 'Ship AI — Zora'), doc('/docs/demos/zora', 'Zora demo')],
|
||||
},
|
||||
{
|
||||
id: 'ST1.2',
|
||||
title: 'Narrative Synthesis Engine',
|
||||
description: 'LLM-based system that weaves events into coherent narrative text.',
|
||||
details: 'Stub only: synthesis.rs exists with a placeholder NarrativeSynthesis (available=false) and generate_narrative() returns a TODO string. No LLM/Claude wiring, no event aggregation; NarrativeConfig.use_llm_synthesis defaults off.',
|
||||
category: 'Narrative',
|
||||
files: 'apps/game/src/gameplay/narrative/synthesis.rs',
|
||||
notes: 'Core differentiator. Use Claude API for story generation.',
|
||||
status: 'backlog',
|
||||
sortOrder: 352,
|
||||
references: [doc('/docs/agents', 'Agent Lifecycle'), doc('/docs/ship-ai', 'Ship AI — Zora')],
|
||||
},
|
||||
{
|
||||
id: 'ST1.4',
|
||||
title: 'Story Log UI',
|
||||
description: 'In-game interface for reading campaign narrative with chapter navigation.',
|
||||
details: 'Stub only: ui.rs defines StoryLogUi/ChapterListItem components but setup/update/export fns are TODO placeholders that only log. Not registered in NarrativePlugin.',
|
||||
category: 'Narrative',
|
||||
files: 'apps/game/src/gameplay/narrative/ui.rs',
|
||||
notes: 'Integrates with existing UI system. Should support export/save.',
|
||||
status: 'backlog',
|
||||
sortOrder: 353,
|
||||
references: [doc('/docs/agents', 'Agent Lifecycle')],
|
||||
},
|
||||
{
|
||||
id: 'T1',
|
||||
title: 'Tutorial & Onboarding',
|
||||
description: 'Guided mission sequence, skip option, tutorial hints, objective tracking.',
|
||||
details: 'Not started: no tutorial system. New players have no guidance.',
|
||||
category: 'UX',
|
||||
files: 'N/A',
|
||||
notes: 'Design specifies 7-objective tutorial chain.',
|
||||
status: 'backlog',
|
||||
sortOrder: 380,
|
||||
references: [doc('/docs/overview', 'Overview'), doc('/docs/gameplay', 'Gameplay Loop')],
|
||||
},
|
||||
{
|
||||
id: 'MP1',
|
||||
title: 'Multiplayer / Backend',
|
||||
description: 'Server auth, multiplayer sessions, cloud persistence.',
|
||||
details: 'Removed: multiplayer services deleted. Game is now single-player only with local persistence.',
|
||||
category: 'Infrastructure',
|
||||
files: 'Removed (services/spacetimedb, services/auth)',
|
||||
notes: 'Multiplayer infrastructure has been removed to focus on single-player experience.',
|
||||
status: 'backlog',
|
||||
sortOrder: 390,
|
||||
references: [doc('/docs/architecture', 'Architecture'), doc('/docs/tech-stack', 'Tech Stack')],
|
||||
},
|
||||
];
|
||||
196
apps/api/src/db.ts
Normal file
196
apps/api/src/db.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Database as BetterSqlite3Database } from 'better-sqlite3';
|
||||
|
||||
/**
|
||||
* Local SQLite store for the kanban board.
|
||||
*
|
||||
* The database file lives at `<package>/.data/kanban.db` (gitignored). Using
|
||||
* SQLite (rather than a JSON file) gives us proper relational integrity for
|
||||
* comments/tags/references and concurrent-safe writes for free.
|
||||
*/
|
||||
const DATA_DIR = path.join(process.cwd(), '.data');
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const DB_PATH = path.join(DATA_DIR, 'kanban.db');
|
||||
|
||||
export const db: BetterSqlite3Database = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
/** Row shapes returned by better-sqlite3 (snake_case columns). */
|
||||
export interface CardRow {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
details: string;
|
||||
category: string;
|
||||
files: string | null;
|
||||
notes: string | null;
|
||||
status: string;
|
||||
sort_order: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ReferenceRow {
|
||||
label: string;
|
||||
type: string;
|
||||
href: string;
|
||||
canonical: number;
|
||||
}
|
||||
|
||||
export interface CommentRow {
|
||||
id: string;
|
||||
card_id: string;
|
||||
text: string;
|
||||
author: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/** Create tables if missing. Idempotent. */
|
||||
export function migrate(): void {
|
||||
// The agent_* tables are new and empty in dev; if an earlier schema created
|
||||
// them with non-nullable timestamp columns, recreate them so the nullable
|
||||
// columns below take effect without requiring a manual DB wipe.
|
||||
const cols = db.prepare('PRAGMA table_info(agent_runs)').all() as
|
||||
{ name: string; notnull: number }[];
|
||||
const finishedCol = cols.find((c) => c.name === 'finished_at');
|
||||
if (finishedCol && finishedCol.notnull === 1) {
|
||||
db.exec('DROP TABLE IF EXISTS agent_run_events');
|
||||
db.exec('DROP TABLE IF EXISTS agent_runs');
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS cards (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
details TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
files TEXT,
|
||||
notes TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
sort_order REAL NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS card_references (
|
||||
card_id TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
href TEXT NOT NULL,
|
||||
canonical INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (card_id, href)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
card_id TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
author TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
card_id TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (card_id, tag)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS custom_pages (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '◈',
|
||||
blurb TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
html TEXT NOT NULL DEFAULT '',
|
||||
beautified INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Agentic orchestrator runs. One run works a single kanban card inside an
|
||||
-- isolated git worktree, driving the board/docs via internal HTTP endpoints.
|
||||
CREATE TABLE IF NOT EXISTS agent_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
card_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
use_worktree INTEGER NOT NULL DEFAULT 1,
|
||||
branch TEXT,
|
||||
worktree_path TEXT,
|
||||
prompt TEXT NOT NULL DEFAULT '',
|
||||
summary TEXT,
|
||||
token TEXT NOT NULL,
|
||||
commit_sha TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
finished_at TEXT
|
||||
);
|
||||
|
||||
-- Slim, replayable event log streamed from each agent run.
|
||||
CREATE TABLE IF NOT EXISTS agent_run_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(run_id, seq)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_card ON comments(card_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_tags_card ON tags(card_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refs_card ON card_references(card_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_runs_card ON agent_runs(card_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_runs_status ON agent_runs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_events_run ON agent_run_events(run_id, seq);
|
||||
`);
|
||||
}
|
||||
|
||||
/** Row shape for the agent_runs table. */
|
||||
export interface AgentRunRow {
|
||||
id: string;
|
||||
card_id: string;
|
||||
status: string;
|
||||
use_worktree: number;
|
||||
branch: string | null;
|
||||
worktree_path: string | null;
|
||||
prompt: string;
|
||||
summary: string | null;
|
||||
token: string;
|
||||
commit_sha: string | null;
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
/** Row shape for the agent_run_events table. */
|
||||
export interface AgentRunEventRow {
|
||||
id: number;
|
||||
run_id: string;
|
||||
seq: number;
|
||||
type: string;
|
||||
data: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Row shape for the custom_pages table. */
|
||||
export interface CustomPageRow {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
blurb: string;
|
||||
content: string;
|
||||
html: string;
|
||||
beautified: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Ensure the schema exists the moment this module is imported. Route modules
|
||||
// prepare statements at top level, so tables must exist before they load.
|
||||
migrate();
|
||||
38
apps/api/src/index.ts
Normal file
38
apps/api/src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { Hono } from 'hono';
|
||||
import './db.js'; // importing db runs migrate()
|
||||
import { db } from './db.js';
|
||||
import { seed } from './seed.js';
|
||||
import { kanban } from './routes/kanban.js';
|
||||
import { pages } from './routes/pages.js';
|
||||
import { orchestrator } from './routes/orchestrator.js';
|
||||
import { internal } from './routes/internal.js';
|
||||
|
||||
// Seed canonical cards on boot.
|
||||
seed();
|
||||
|
||||
// Reconcile orchestrator runs orphaned by a previous process: anything still
|
||||
// marked running/queued has no live subprocess, so mark it stopped.
|
||||
{
|
||||
const now = new Date().toISOString();
|
||||
const result = db
|
||||
.prepare("UPDATE agent_runs SET status = 'stopped', finished_at = COALESCE(finished_at, ?), error = COALESCE(error, 'interrupted by server restart') WHERE status IN ('running', 'queued')")
|
||||
.run(now);
|
||||
if (result.changes > 0) {
|
||||
console.log(`[api] reconciled ${result.changes} interrupted agent run(s) → stopped`);
|
||||
}
|
||||
}
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/api/health', (c) => c.json({ ok: true }));
|
||||
app.route('/api/kanban', kanban);
|
||||
app.route('/api/pages', pages);
|
||||
app.route('/api/orchestrator', orchestrator);
|
||||
app.route('/api/internal', internal);
|
||||
|
||||
const port = Number(process.env.PORT ?? 3001);
|
||||
|
||||
serve({ fetch: app.fetch, port }, (info) => {
|
||||
console.log(`[api] VOID::NAV Kanban API on http://localhost:${info.port}`);
|
||||
});
|
||||
109
apps/api/src/markdown.ts
Normal file
109
apps/api/src/markdown.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Minimal, dependency-free Markdown → HTML converter used to render a custom
|
||||
* page's raw content when the user has not yet run "Beautify with AI".
|
||||
*
|
||||
* Intentionally tiny: headings, paragraphs, lists, bold/italic/code, hr, and
|
||||
* fenced code blocks. The AI-beautified output is already HTML, so this is only
|
||||
* for the live-edit preview / pre-beautify state.
|
||||
*/
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function inline(s: string): string {
|
||||
return s
|
||||
.replace(/`([^`]+)`/g, (_, c) => `<code>${escapeHtml(c)}</code>`)
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
}
|
||||
|
||||
export function renderMarkdown(src: string): string {
|
||||
const lines = src.replace(/\r\n/g, '\n').split('\n');
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
let inUl = false;
|
||||
let inOl = false;
|
||||
|
||||
const closeLists = () => {
|
||||
if (inUl) { out.push('</ul>'); inUl = false; }
|
||||
if (inOl) { out.push('</ol>'); inOl = false; }
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Fenced code block — ```` ```mermaid ```` becomes a renderable diagram div.
|
||||
if (line.trimStart().startsWith('```')) {
|
||||
closeLists();
|
||||
const info = line.trim().slice(3);
|
||||
const lang = info.split(/\s+/)[0] ?? '';
|
||||
const buf: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].trimStart().startsWith('```')) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
i++; // skip closing fence
|
||||
if (lang === 'mermaid') {
|
||||
out.push(`<div class="mermaid">${escapeHtml(buf.join('\n'))}</div>`);
|
||||
} else {
|
||||
out.push(`<pre><code data-lang="${escapeHtml(lang)}">${escapeHtml(buf.join('\n'))}</code></pre>`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (/^\s*([-*_])\1\1[-*_\s]*$/.test(line)) {
|
||||
closeLists();
|
||||
out.push('<hr/>');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heading
|
||||
const h = /^(#{1,6})\s+(.*)$/.exec(line);
|
||||
if (h) {
|
||||
closeLists();
|
||||
const level = h[1].length;
|
||||
out.push(`<h${level}>${inline(escapeHtml(h[2]))}</h${level}>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list item
|
||||
const ol = /^\s*\d+\.\s+(.*)$/.exec(line);
|
||||
if (ol) {
|
||||
if (!inOl) { closeLists(); out.push('<ol>'); inOl = true; }
|
||||
out.push(`<li>${inline(escapeHtml(ol[1]))}</li>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list item
|
||||
const ul = /^\s*[-*+]\s+(.*)$/.exec(line);
|
||||
if (ul) {
|
||||
if (!inUl) { closeLists(); out.push('<ul>'); inUl = true; }
|
||||
out.push(`<li>${inline(escapeHtml(ul[1]))}</li>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blank line
|
||||
if (line.trim() === '') {
|
||||
closeLists();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph
|
||||
closeLists();
|
||||
out.push(`<p>${inline(escapeHtml(line))}</p>`);
|
||||
i++;
|
||||
}
|
||||
closeLists();
|
||||
return out.join('\n');
|
||||
}
|
||||
56
apps/api/src/orchestrator/config.ts
Normal file
56
apps/api/src/orchestrator/config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Orchestrator configuration: paths, ports, and tokens.
|
||||
*
|
||||
* The orchestrator embeds pi as the agentic/model layer by spawning the `pi`
|
||||
* CLI in RPC mode (one subprocess per run) inside an isolated git worktree. All
|
||||
* kanban/documentation state still lives in this process's SQLite store; the
|
||||
* agent drives it by calling this server's own token-gated HTTP endpoints.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
|
||||
/** Resolve the git repository root from the current process cwd. */
|
||||
function resolveRepoRoot(): string {
|
||||
try {
|
||||
return execSync('git rev-parse --show-toplevel', {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
} catch {
|
||||
return process.cwd();
|
||||
}
|
||||
}
|
||||
|
||||
/** Absolute path to the monorepo root (used for git worktrees). */
|
||||
export const REPO_ROOT = resolveRepoRoot();
|
||||
|
||||
/** Port this API server listens on (mirrors `index.ts`). */
|
||||
export const PORT = Number(process.env.PORT ?? 3001);
|
||||
|
||||
/** Base URL the agent subprocess uses to reach this server over loopback. */
|
||||
export const API_BASE = process.env.AGENT_API_BASE ?? `http://127.0.0.1:${PORT}`;
|
||||
|
||||
/** Directory holding agent worktrees (gitignored). */
|
||||
export const WORKTREE_ROOT = path.join(REPO_ROOT, '.worktrees');
|
||||
|
||||
/** Branch prefix for agent-created branches. */
|
||||
export const BRANCH_PREFIX = 'agent';
|
||||
|
||||
/** Path to the `pi` binary (override with PI_BIN for non-standard installs). */
|
||||
export const PI_BIN = process.env.PI_BIN ?? 'pi';
|
||||
|
||||
/** Default tool allowlist handed to pi (explore + edit + run commands). */
|
||||
export const PI_TOOLS = process.env.PI_TOOLS ?? 'read,bash,edit,write,ls,find,grep';
|
||||
|
||||
/**
|
||||
* Optional model override for orchestrator runs (e.g. `anthropic/claude-sonnet-4-5`).
|
||||
* When unset, pi uses the user's configured default model.
|
||||
*/
|
||||
export const PI_MODEL = process.env.PI_ORCHESTRATOR_MODEL ?? '';
|
||||
|
||||
/** Issue a fresh secret token authorising a single run's internal API calls. */
|
||||
export function newToken(): string {
|
||||
return randomBytes(12).toString('hex');
|
||||
}
|
||||
155
apps/api/src/orchestrator/prompt.ts
Normal file
155
apps/api/src/orchestrator/prompt.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Builds the initial prompt handed to each orchestrator agent run.
|
||||
*
|
||||
* The agent runs inside an isolated git worktree with pi's built-in coding
|
||||
* tools (read/bash/edit/write/…). It reaches this server over loopback to read
|
||||
* context (the kanban board + documentation) and to record its work (card
|
||||
* moves, comments, references, and documentation updates). The documentation is
|
||||
* the central store of truth: the agent reads it to learn how things should
|
||||
* work and updates it when it makes design decisions or completes work.
|
||||
*/
|
||||
|
||||
import type { Card } from '../types.js';
|
||||
import { API_BASE } from './config.js';
|
||||
|
||||
export interface PromptContext {
|
||||
/** Secret token authorising this run's internal API calls. */
|
||||
token: string;
|
||||
/** This run's id (used in some callbacks). */
|
||||
runId: string;
|
||||
}
|
||||
|
||||
/** A compact, LLM-readable snapshot of the card being worked. */
|
||||
function describeCard(card: Card): string {
|
||||
const refs = card.references.length
|
||||
? card.references.map((r) => `- [${r.type}] ${r.label} → ${r.href}`).join('\n')
|
||||
: '(none)';
|
||||
const tags = card.tags.length ? card.tags.join(', ') : '(none)';
|
||||
const comments = card.comments.length
|
||||
? card.comments
|
||||
.map((c) => `- ${c.author}: ${c.text}`)
|
||||
.join('\n')
|
||||
: '(none)';
|
||||
return [
|
||||
`card_id: ${card.id}`,
|
||||
`title: ${card.title}`,
|
||||
`category: ${card.category}`,
|
||||
`status: ${card.status}`,
|
||||
`description: ${card.description}`,
|
||||
`details: ${card.details}`,
|
||||
`files: ${card.files ?? '(unspecified)'}`,
|
||||
`notes: ${card.notes ?? '(none)'}`,
|
||||
`references:\n${refs}`,
|
||||
`tags: ${tags}`,
|
||||
`existing comments:\n${comments}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** The full agent contract: how to read state and record work via HTTP. */
|
||||
function apiContract(ctx: PromptContext): string {
|
||||
const auth = `-H "x-agent-token: ${ctx.token}"`;
|
||||
return `
|
||||
## API contract (this server, over loopback)
|
||||
|
||||
All endpoints are relative to \`${API_BASE}\`. GET/read endpoints are public; the
|
||||
**mutation** endpoints require the header \`x-agent-token: ${ctx.token}\`. Use \`curl\`.
|
||||
|
||||
### Read context (the store of truth)
|
||||
- \`GET /api/kanban/board\` — every card, its status, references, comments.
|
||||
- \`GET /api/kanban/pages\` — registry of static design doc pages (path/title/blurb).
|
||||
- \`GET /api/pages\` — all custom (dynamic) documentation pages.
|
||||
- \`GET /api/pages/:slug\` — one custom page (its \`content\` + rendered \`html\`).
|
||||
|
||||
### Record your work (token required)
|
||||
- Post a progress/decision note onto the card:
|
||||
\`\`\`bash
|
||||
curl -sX POST ${API_BASE}/api/internal/cards/<card_id>/comments ${auth} \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-d '{"text":"..."}'
|
||||
\`\`\`
|
||||
- Move the card between \`backlog\`|\`todo\`|\`in-progress\` as its status changes.
|
||||
**NEVER move a card to \`done\` — that returns \`403\`; only a human may complete a
|
||||
card.** When you finish, leave the card \`in-progress\` and post a summary
|
||||
comment so the operator can review and complete it:
|
||||
\`\`\`bash
|
||||
curl -sX PATCH ${API_BASE}/api/internal/cards/<card_id> ${auth} \\
|
||||
-H 'Content-Type: application/json' -d '{"status":"in-progress"}'
|
||||
\`\`\`
|
||||
- Link the card to a doc page (existing or one you just created):
|
||||
\`\`\`bash
|
||||
curl -sX POST ${API_BASE}/api/internal/cards/<card_id>/references ${auth} \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-d '{"label":"Decision Log","type":"custom","href":"/docs/custom/decision-log"}'
|
||||
\`\`\`
|
||||
- Create or update a custom documentation page (upsert by slug). Use this to log
|
||||
design decisions, architecture, or completed-work summaries — the docs are the
|
||||
store of truth, so persist important decisions here, not just in chat:
|
||||
\`\`\`bash
|
||||
curl -sX POST ${API_BASE}/api/internal/pages ${auth} \\
|
||||
-H 'Content-Type: application/json' -d '{
|
||||
"title":"Combat Resolution — Decision Log",
|
||||
"icon":"⚔",
|
||||
"blurb":"How damage and hit resolution were implemented.",
|
||||
"content":"# Combat resolution\\n\\n...markdown or HTML..."
|
||||
}'
|
||||
\`\`\`
|
||||
If a page with the same slug (derived from the title) already exists it is updated.
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the initial user prompt for a run.
|
||||
*
|
||||
* @param card the card to implement
|
||||
* @param ctx run credentials
|
||||
* @param userPrompt optional extra instructions from the human (steer the run)
|
||||
*/
|
||||
export function buildPrompt(card: Card, ctx: PromptContext, userPrompt?: string): string {
|
||||
return `You are the autonomous engineering agent for VOID::NAV, a single-player
|
||||
narrative space-exploration RPG. You are working one item from the implementation
|
||||
board. You operate inside an isolated git worktree that is ALREADY checked out on
|
||||
a dedicated branch — commit your work there.
|
||||
|
||||
## Repository layout
|
||||
- \`apps/game\` — the playable client (Rust + Bevy 0.16). READ \`apps/game/AGENTS.md\`
|
||||
and the root \`AGENTS.md\` for conventions. CRITICAL: never suppress compiler
|
||||
warnings/errors (no \`#[allow(...)]\`, no \`_unused\` prefixes) — fix the cause.
|
||||
- \`apps/docs\` — the living design docs (Vite + React). The documentation is the
|
||||
central store of truth for HOW the game should work.
|
||||
- \`apps/api\` — this backend (Hono + SQLite): kanban board + documentation store.
|
||||
|
||||
## Documentation is the store of truth
|
||||
- READ the docs first to understand intent. Update them when you make a design
|
||||
decision or finish work — persist decisions as custom pages (see API contract)
|
||||
and link the card to them. Do not leave important decisions only in chat.
|
||||
- Prefer small, focused doc pages with clear titles.
|
||||
|
||||
## Workflow
|
||||
1. Read the card below and any referenced design docs.
|
||||
2. Explore the relevant code (use \`read\`/\`grep\`/\`find\`/\`ls\`).
|
||||
3. Plan briefly, then implement on this branch. Keep changes scoped to the card.
|
||||
4. Verify: in \`apps/game\` run \`cargo check\` (and \`cargo clippy\` if relevant); in
|
||||
the TS apps run \`pnpm --filter @void-nav/<pkg> check\` from the repo root.
|
||||
5. Commit your work with a clear message.
|
||||
6. Record outcomes: post a concise summary comment on the card, create/update the
|
||||
relevant documentation page(s), and link them to the card. Leave the card in
|
||||
\`in-progress\` when your work is ready for review — you may NEVER mark a card
|
||||
\`done\`; only a human completes cards. (Moving it to \`done\` is rejected by the
|
||||
API.) Move it to \`todo\`/\`backlog\` only if you determine it is not yet ready.
|
||||
7. Stop when your work for this card is finished or genuinely blocked. Leave the
|
||||
card in \`in-progress\` either way (with a comment explaining any blocker); a
|
||||
human reviews and marks it \`done\`.
|
||||
|
||||
Be autonomous and thorough, but keep each run focused on THIS card.
|
||||
${apiContract(ctx)}
|
||||
|
||||
${userPrompt?.trim() ? `## Additional instructions from the operator\n${userPrompt.trim()}\n` : ''}
|
||||
## Your card
|
||||
|
||||
\`\`\`
|
||||
${describeCard(card)}
|
||||
\`\`\`
|
||||
|
||||
Begin now. Start by reading the relevant docs and code, then implement.
|
||||
`;
|
||||
}
|
||||
395
apps/api/src/orchestrator/runner.ts
Normal file
395
apps/api/src/orchestrator/runner.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Runs a single orchestrator agent by spawning the `pi` CLI in RPC mode and
|
||||
* translating its JSONL event stream into a slim, persisted + streamable log.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - spawn `pi --mode rpc` in the run's worktree (or the repo root)
|
||||
* - send the initial prompt, plus later steer / follow-up / abort commands
|
||||
* - parse stdout JSONL (commands-responses vs. agent events)
|
||||
* - emit a compact event stream (`SlimEvent`) to subscribers AND persist it
|
||||
* - finalize the run status (completed / failed / stopped) on exit
|
||||
*
|
||||
* The agent uses pi's built-in coding tools directly; kanban/docs mutations
|
||||
* happen via the server's own HTTP endpoints (the agent curls them), so no
|
||||
* SQLite writes cross the process boundary.
|
||||
*/
|
||||
|
||||
import { type ChildProcess, spawn } from 'node:child_process';
|
||||
import { db } from '../db.js';
|
||||
import type { RunStatus } from '../types.js';
|
||||
import { PI_BIN, PI_MODEL, PI_TOOLS } from './config.js';
|
||||
|
||||
/** Compact, UI-friendly event derived from pi's richer event stream. */
|
||||
export type SlimEvent =
|
||||
| { type: 'status'; status: RunStatus }
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'tool_start'; tool: string; preview: string }
|
||||
| { type: 'tool_end'; tool: string; ok: boolean; preview: string }
|
||||
| { type: 'log'; level: 'info' | 'warn' | 'error'; text: string }
|
||||
| { type: 'done'; summary: string }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
/** A slim event after it has been persisted: it now carries its sequence
|
||||
* number and timestamp so SSE consumers can dedup and order reliably. */
|
||||
export type PersistedEvent = SlimEvent & { seq: number; createdAt: string };
|
||||
|
||||
type Listener = (event: PersistedEvent) => void;
|
||||
|
||||
export interface RunnerOptions {
|
||||
runId: string;
|
||||
/** Working directory for the agent (a worktree path, or the repo root). */
|
||||
cwd: string;
|
||||
/** Initial prompt. */
|
||||
prompt: string;
|
||||
/** Called once when the run settles (status is final). */
|
||||
onSettled: (status: RunStatus, summary: string | null) => void;
|
||||
}
|
||||
|
||||
export class Runner {
|
||||
readonly runId: string;
|
||||
private readonly cwd: string;
|
||||
private readonly prompt: string;
|
||||
private readonly onSettled: (status: RunStatus, summary: string | null) => void;
|
||||
|
||||
private proc: ChildProcess | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
private buffer = ''; // stdin line buffer
|
||||
private textBuffer = ''; // accumulating assistant text for the current message
|
||||
private lastAssistantText = '';
|
||||
private stopping = false;
|
||||
private settled = false;
|
||||
/** Pending steer/follow-up messages pi hasn't processed yet. The run only
|
||||
* settles when the agent finishes a turn with an empty queue. */
|
||||
private pending = 0;
|
||||
/** Watchdog that force-settles a run if the agent stalls with a queued msg. */
|
||||
private watchdog: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(opts: RunnerOptions) {
|
||||
this.runId = opts.runId;
|
||||
this.cwd = opts.cwd;
|
||||
this.prompt = opts.prompt;
|
||||
this.onSettled = opts.onSettled;
|
||||
}
|
||||
|
||||
/** Subscribe to live slim events. Returns an unsubscribe function. */
|
||||
subscribe(fn: Listener): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
/** Begin the run: spawn pi and send the prompt. */
|
||||
start(): void {
|
||||
const args = ['--mode', 'rpc', '--no-session', '--approve', '-n', `kanban:${this.runId.slice(0, 8)}`, '--tools', PI_TOOLS];
|
||||
if (PI_MODEL) args.push('--model', PI_MODEL);
|
||||
|
||||
this.proc = spawn(PI_BIN, args, {
|
||||
cwd: this.cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
// Keep the subprocess quiet and deterministic.
|
||||
PI_SKIP_VERSION_CHECK: '1',
|
||||
NO_COLOR: '1',
|
||||
CLICOLOR: '0',
|
||||
},
|
||||
});
|
||||
|
||||
this.proc.stdout?.setEncoding('utf8');
|
||||
this.proc.stderr?.setEncoding('utf8');
|
||||
|
||||
this.proc.stdout?.on('data', (chunk: string) => this.onStdout(chunk));
|
||||
this.proc.stderr?.on('data', (chunk: string) => this.onStderr(chunk));
|
||||
this.proc.on('error', (err) => this.fail(`failed to spawn pi: ${err.message}`));
|
||||
this.proc.on('exit', (code, signal) => this.onExit(code, signal));
|
||||
|
||||
this.send({ type: 'prompt', message: this.prompt });
|
||||
this.emit({ type: 'log', level: 'info', text: `Started pi (cwd ${this.cwd})` });
|
||||
this.emit({ type: 'status', status: 'running' });
|
||||
}
|
||||
|
||||
/** Queue a steering or follow-up message mid-run. */
|
||||
message(text: string, mode: 'steer' | 'followUp'): void {
|
||||
const cmd =
|
||||
mode === 'followUp' ? { type: 'follow_up', message: text } : { type: 'steer', message: text };
|
||||
this.send(cmd);
|
||||
// Optimistically mark one more turn pending so an imminent agent_end for the
|
||||
// current turn doesn't settle the run before the queued message is served.
|
||||
this.pending += 1;
|
||||
this.clearWatchdog();
|
||||
this.emit({ type: 'log', level: 'info', text: `${mode === 'followUp' ? 'Follow-up' : 'Steer'} queued: ${text}` });
|
||||
}
|
||||
|
||||
/** Abort the run. Status becomes 'stopped' once the process exits. */
|
||||
stop(): void {
|
||||
if (!this.proc || this.stopping) return;
|
||||
this.stopping = true;
|
||||
this.emit({ type: 'log', level: 'warn', text: 'Stopping run…' });
|
||||
try {
|
||||
this.send({ type: 'abort' });
|
||||
} catch {
|
||||
/* stdin may already be closed */
|
||||
}
|
||||
// Hard kill backstop if pi doesn't exit promptly after abort.
|
||||
setTimeout(() => {
|
||||
if (this.proc && !this.settled) {
|
||||
try {
|
||||
this.proc.kill('SIGTERM');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, 15_000);
|
||||
}
|
||||
|
||||
// --- stdin / stdout handling --------------------------------------------
|
||||
|
||||
private send(cmd: Record<string, unknown>): void {
|
||||
this.proc?.stdin?.write(`${JSON.stringify(cmd)}\n`);
|
||||
}
|
||||
|
||||
private onStdout(chunk: string): void {
|
||||
this.buffer += chunk;
|
||||
let idx: number;
|
||||
while ((idx = this.buffer.indexOf('\n')) >= 0) {
|
||||
const line = this.buffer.slice(0, idx).replace(/\r$/, '');
|
||||
this.buffer = this.buffer.slice(idx + 1);
|
||||
if (line.trim()) this.handleLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
private onStderr(chunk: string): void {
|
||||
const text = chunk.trim();
|
||||
if (text) this.emit({ type: 'log', level: 'warn', text: `[pi stderr] ${text.slice(0, 500)}` });
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
if (!line.startsWith('{')) return; // ignore banners / non-JSON
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
switch (msg.type) {
|
||||
case 'response':
|
||||
// Command ack; surface failures.
|
||||
if (msg.success === false) {
|
||||
this.emit({ type: 'log', level: 'error', text: `command ${String(msg.command ?? '?')} rejected` });
|
||||
}
|
||||
break;
|
||||
case 'agent_start':
|
||||
// A new turn is starting (initial prompt or a queued steer/follow-up).
|
||||
this.clearWatchdog();
|
||||
break;
|
||||
case 'agent_end':
|
||||
this.lastAssistantText = extractAssistantText(msg.messages) || this.lastAssistantText;
|
||||
this.maybeComplete();
|
||||
break;
|
||||
case 'message_update': {
|
||||
const ev = msg.assistantMessageEvent as { type?: string; delta?: string } | undefined;
|
||||
if (ev?.type === 'text_delta' && typeof ev.delta === 'string') this.textBuffer += ev.delta;
|
||||
break;
|
||||
}
|
||||
case 'message_end': {
|
||||
const text = extractAssistantText(msg.message) || this.textBuffer.trim();
|
||||
if (text) {
|
||||
this.lastAssistantText = text;
|
||||
this.emit({ type: 'text', text });
|
||||
}
|
||||
this.textBuffer = '';
|
||||
break;
|
||||
}
|
||||
case 'tool_execution_start':
|
||||
this.emit({
|
||||
type: 'tool_start',
|
||||
tool: String(msg.toolName ?? 'tool'),
|
||||
preview: previewArgs(msg.args),
|
||||
});
|
||||
break;
|
||||
case 'tool_execution_end':
|
||||
this.emit({
|
||||
type: 'tool_end',
|
||||
tool: String(msg.toolName ?? 'tool'),
|
||||
ok: msg.isError !== true,
|
||||
preview: previewResult(msg.result),
|
||||
});
|
||||
break;
|
||||
case 'queue_update': {
|
||||
// Reconcile our pending count with pi's authoritative queue state.
|
||||
const q = msg as { steering?: unknown[]; followUp?: unknown[] };
|
||||
this.pending = (q.steering?.length ?? 0) + (q.followUp?.length ?? 0);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Other event types (compaction, retry, …) are intentionally dropped.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private onExit(code: number | null, signal: NodeJS.Signals | null): void {
|
||||
// Backstop: if pi exits before we settled (crash, abort, or unexpected
|
||||
// close), finalize now. Normal completion is driven by `maybeComplete()`.
|
||||
if (this.settled) return;
|
||||
this.clearWatchdog();
|
||||
const crashed = code !== null && code !== 0;
|
||||
let status: RunStatus;
|
||||
if (this.stopping) status = 'stopped';
|
||||
else if (crashed || signal) status = 'failed';
|
||||
else status = 'completed';
|
||||
|
||||
this.settle(status, this.lastAssistantText || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the run is finished after an `agent_end`. RPC-mode pi does
|
||||
* not exit on its own after answering; it waits for more commands. So we treat
|
||||
* the run as complete when the agent finishes a turn with nothing queued, and
|
||||
* close pi's stdin so it exits cleanly. A queued steer/follow-up keeps the run
|
||||
* alive for another turn (guarded by a watchdog in case pi stalls).
|
||||
*/
|
||||
private maybeComplete(): void {
|
||||
if (this.settled) return;
|
||||
// An abort was requested: honor it as 'stopped' rather than 'completed'
|
||||
// (pi still emits agent_end after aborting the current turn).
|
||||
if (this.stopping) {
|
||||
this.clearWatchdog();
|
||||
this.settle('stopped', this.lastAssistantText || null);
|
||||
return;
|
||||
}
|
||||
if (this.pending > 0) {
|
||||
this.armWatchdog();
|
||||
return;
|
||||
}
|
||||
this.clearWatchdog();
|
||||
this.settle('completed', this.lastAssistantText || null);
|
||||
}
|
||||
|
||||
private armWatchdog(): void {
|
||||
this.clearWatchdog();
|
||||
this.watchdog = setTimeout(() => {
|
||||
if (!this.settled) {
|
||||
this.emit({ type: 'log', level: 'warn', text: 'Agent stalled with a queued message; finalizing.' });
|
||||
this.settle('completed', this.lastAssistantText || null);
|
||||
}
|
||||
}, 90_000);
|
||||
}
|
||||
|
||||
private clearWatchdog(): void {
|
||||
if (this.watchdog) {
|
||||
clearTimeout(this.watchdog);
|
||||
this.watchdog = null;
|
||||
}
|
||||
}
|
||||
|
||||
private fail(message: string): void {
|
||||
if (this.settled) return;
|
||||
this.emit({ type: 'error', message });
|
||||
this.settle('failed', this.lastAssistantText || null);
|
||||
}
|
||||
|
||||
private settle(status: RunStatus, summary: string | null): void {
|
||||
if (this.settled) return;
|
||||
this.settled = true;
|
||||
this.clearWatchdog();
|
||||
this.emit({ type: 'status', status });
|
||||
if (status === 'completed') this.emit({ type: 'done', summary: summary ?? '' });
|
||||
if (status === 'failed' && summary) this.emit({ type: 'text', text: summary });
|
||||
try {
|
||||
this.proc?.stdin?.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.onSettled(status, summary);
|
||||
}
|
||||
|
||||
// --- persistence + fan-out ----------------------------------------------
|
||||
|
||||
private emit(event: SlimEvent): void {
|
||||
const persisted = persistEvent(this.runId, event);
|
||||
const enriched: PersistedEvent = { ...event, seq: persisted.seq, createdAt: persisted.createdAt };
|
||||
for (const fn of this.listeners) {
|
||||
try {
|
||||
fn(enriched);
|
||||
} catch {
|
||||
/* a bad listener must not break the run */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
const insertEvent = db.prepare(
|
||||
`INSERT INTO agent_run_events (run_id, seq, type, data, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
);
|
||||
|
||||
/** Persist a slim event with a per-run monotonic sequence number. */
|
||||
function persistEvent(runId: string, event: SlimEvent): { seq: number; createdAt: string } {
|
||||
const seq = (
|
||||
db
|
||||
.prepare('SELECT COALESCE(MAX(seq), 0) + 1 AS n FROM agent_run_events WHERE run_id = ?')
|
||||
.get(runId) as { n: number }
|
||||
).n;
|
||||
const createdAt = new Date().toISOString();
|
||||
const { type, ...data } = event;
|
||||
insertEvent.run(runId, seq, type, JSON.stringify(data), createdAt);
|
||||
return { seq, createdAt };
|
||||
}
|
||||
|
||||
/** Extract concatenated assistant text from a message-like object or from the
|
||||
* `messages` array of an `agent_end` event (last assistant message wins). */
|
||||
function extractAssistantText(source: unknown): string {
|
||||
if (Array.isArray(source)) {
|
||||
for (let i = source.length - 1; i >= 0; i--) {
|
||||
const m = source[i] as { role?: string };
|
||||
if (m && m.role === 'assistant') {
|
||||
const text = textFromMessage(m);
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return textFromMessage(source);
|
||||
}
|
||||
|
||||
function textFromMessage(message: unknown): string {
|
||||
if (!message || typeof message !== 'object') return '';
|
||||
const content = (message as { content?: unknown }).content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.filter((b): b is { type: string; text: string } => typeof b === 'object' && b !== null && (b as { type?: string }).type === 'text' && typeof (b as { text?: unknown }).text === 'string')
|
||||
.map((b) => b.text)
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Short, readable preview of a tool call's arguments. */
|
||||
function previewArgs(args: unknown): string {
|
||||
if (!args || typeof args !== 'object') return '';
|
||||
const a = args as Record<string, unknown>;
|
||||
if (typeof a.command === 'string') return truncate(a.command);
|
||||
const path = a.path ?? a.file_path;
|
||||
if (typeof path === 'string') return truncate(String(path));
|
||||
return truncate(JSON.stringify(a));
|
||||
}
|
||||
|
||||
/** Short, readable preview of a tool result. */
|
||||
function previewResult(result: unknown): string {
|
||||
if (!result || typeof result !== 'object') return '';
|
||||
const content = (result as { content?: unknown }).content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
const text = content
|
||||
.map((b) =>
|
||||
b && typeof b === 'object' && (b as { type?: string }).type === 'text'
|
||||
? String((b as { text?: unknown }).text ?? '')
|
||||
: '',
|
||||
)
|
||||
.join('\n')
|
||||
.trim();
|
||||
return truncate(text);
|
||||
}
|
||||
|
||||
function truncate(s: string, max = 280): string {
|
||||
const oneLine = s.replace(/\s+/g, ' ').trim();
|
||||
return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine;
|
||||
}
|
||||
244
apps/api/src/orchestrator/runs.ts
Normal file
244
apps/api/src/orchestrator/runs.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Run manager: the single source of truth for orchestrator runs.
|
||||
*
|
||||
* Each run works one kanban card inside an (optional) isolated git worktree.
|
||||
* The manager persists run state to SQLite, keeps active `Runner` instances in
|
||||
* memory (so routes can steer/stop/stream them), and finalizes the run record
|
||||
* when a runner settles — capturing the head commit and a work summary.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db, type AgentRunRow } from '../db.js';
|
||||
import type { AgentRun, AgentRunEvent, Card, RunStatus } from '../types.js';
|
||||
import { newToken, REPO_ROOT } from './config.js';
|
||||
import { buildPrompt } from './prompt.js';
|
||||
import { Runner, type PersistedEvent } from './runner.js';
|
||||
import { createWorktree, dirtySummary, headSha, removeWorktree, type Worktree } from './worktrees.js';
|
||||
|
||||
export interface StartRunInput {
|
||||
cardId: string;
|
||||
/** Extra operator instructions appended to the agent's prompt. */
|
||||
prompt?: string;
|
||||
/** Default true: run inside an isolated git worktree. */
|
||||
useWorktree?: boolean;
|
||||
/** If true, delete the worktree + branch when the run settles. */
|
||||
cleanupOnFinish?: boolean;
|
||||
}
|
||||
|
||||
class RunManager {
|
||||
/** Active runners keyed by run id. */
|
||||
private active = new Map<string, { runner: Runner; worktree: Worktree | null; cleanup: boolean }>();
|
||||
|
||||
/** Create and start a run for a card. Returns the persisted run. */
|
||||
start(card: Card, input: StartRunInput): AgentRun {
|
||||
const id = randomUUID();
|
||||
const token = newToken();
|
||||
const useWorktree = input.useWorktree ?? true;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Provision the worktree (or fall back to the repo root).
|
||||
let worktree: Worktree | null = null;
|
||||
let branch: string | null = null;
|
||||
let worktreePath: string | null = null;
|
||||
if (useWorktree) {
|
||||
worktree = createWorktree(id, card.id);
|
||||
branch = worktree.branch;
|
||||
worktreePath = worktree.path;
|
||||
}
|
||||
|
||||
const prompt = buildPrompt(card, { token, runId: id }, input.prompt);
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO agent_runs
|
||||
(id, card_id, status, use_worktree, branch, worktree_path, prompt, token, created_at, started_at)
|
||||
VALUES (?, ?, 'running', ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(id, card.id, useWorktree ? 1 : 0, branch, worktreePath, prompt, token, now, now);
|
||||
|
||||
const cwd = worktreePath ?? REPO_ROOT;
|
||||
const runner = new Runner({
|
||||
runId: id,
|
||||
cwd,
|
||||
prompt,
|
||||
onSettled: (status, summary) => this.settle(id, status, summary),
|
||||
});
|
||||
this.active.set(id, { runner, worktree, cleanup: input.cleanupOnFinish ?? false });
|
||||
runner.start();
|
||||
} catch (err) {
|
||||
// Persisting or starting failed — reclaim the worktree so we never leak one.
|
||||
if (worktree) removeWorktree(id, worktree.branch);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return this.get(id)!;
|
||||
}
|
||||
|
||||
/** Whether a run is currently active (steerable / stoppable). */
|
||||
isActive(id: string): boolean {
|
||||
return this.active.has(id);
|
||||
}
|
||||
|
||||
/** Send a steer/follow-up message to an active run. */
|
||||
message(id: string, text: string, mode: 'steer' | 'followUp'): void {
|
||||
const entry = this.active.get(id);
|
||||
if (!entry) throw new RunNotFoundError(id);
|
||||
entry.runner.message(text, mode);
|
||||
}
|
||||
|
||||
/** Abort an active run. */
|
||||
stop(id: string): void {
|
||||
const entry = this.active.get(id);
|
||||
if (!entry) throw new RunNotFoundError(id);
|
||||
entry.runner.stop();
|
||||
}
|
||||
|
||||
/** Subscribe to a run's live slim-event stream (best-effort; may be inactive). */
|
||||
subscribe(id: string, fn: (event: PersistedEvent) => void): () => void {
|
||||
const entry = this.active.get(id);
|
||||
if (entry) return entry.runner.subscribe(fn);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
/** Delete a run record (and its worktree if still present). */
|
||||
remove(id: string): void {
|
||||
const entry = this.active.get(id);
|
||||
if (entry) throw new Error('cannot delete an active run; stop it first');
|
||||
const row = this.get(id);
|
||||
if (row?.worktreePath && row.branch) removeWorktree(id, row.branch);
|
||||
db.prepare('DELETE FROM agent_run_events WHERE run_id = ?').run(id);
|
||||
db.prepare('DELETE FROM agent_runs WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
// --- reads ---------------------------------------------------------------
|
||||
|
||||
get(id: string): AgentRun | undefined {
|
||||
const row = db.prepare('SELECT * FROM agent_runs WHERE id = ?').get(id) as AgentRunRow | undefined;
|
||||
return row ? hydrateRun(row) : undefined;
|
||||
}
|
||||
|
||||
/** Resolve a run by its agent token (used to authorize internal calls). */
|
||||
getByToken(token: string): AgentRun | undefined {
|
||||
const row = db
|
||||
.prepare('SELECT * FROM agent_runs WHERE token = ? ORDER BY created_at DESC LIMIT 1')
|
||||
.get(token) as AgentRunRow | undefined;
|
||||
return row ? hydrateRun(row) : undefined;
|
||||
}
|
||||
|
||||
/** Recent runs, newest first. */
|
||||
list(limit = 50): AgentRun[] {
|
||||
const rows = db
|
||||
.prepare('SELECT * FROM agent_runs ORDER BY created_at DESC LIMIT ?')
|
||||
.all(limit) as AgentRunRow[];
|
||||
return rows.map(hydrateRun);
|
||||
}
|
||||
|
||||
/** Runs for a given card (newest first). */
|
||||
listForCard(cardId: string): AgentRun[] {
|
||||
const rows = db
|
||||
.prepare('SELECT * FROM agent_runs WHERE card_id = ? ORDER BY created_at DESC')
|
||||
.all(cardId) as AgentRunRow[];
|
||||
return rows.map(hydrateRun);
|
||||
}
|
||||
|
||||
/** Replayable event history for a run, optionally after a sequence number. */
|
||||
events(id: string, sinceSeq = 0): AgentRunEvent[] {
|
||||
const rows = db
|
||||
.prepare('SELECT * FROM agent_run_events WHERE run_id = ? AND seq > ? ORDER BY seq ASC')
|
||||
.all(id, sinceSeq) as { id: number; run_id: string; seq: number; type: string; data: string; created_at: string }[];
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
runId: r.run_id,
|
||||
seq: r.seq,
|
||||
type: r.type,
|
||||
data: safeParse(r.data),
|
||||
createdAt: r.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- internal: finalize a run -------------------------------------------
|
||||
|
||||
private settle(id: string, status: RunStatus, summary: string | null): void {
|
||||
const entry = this.active.get(id);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
let commitSha: string | null = null;
|
||||
let extraError: string | null = null;
|
||||
if (entry?.worktree) {
|
||||
commitSha = headSha(entry.worktree.path);
|
||||
const dirty = dirtySummary(entry.worktree.path);
|
||||
if (dirty) {
|
||||
// Uncommitted changes left behind — surface as an error note.
|
||||
extraError = `worktree has uncommitted changes:\n${dirty}`;
|
||||
// Persist it as a run event so it shows in the feed.
|
||||
db.prepare(
|
||||
`INSERT INTO agent_run_events (run_id, seq, type, data, created_at)
|
||||
VALUES (?, ?, 'log', ?, ?)`,
|
||||
).run(
|
||||
id,
|
||||
nextSeq(id),
|
||||
JSON.stringify({ level: 'warn', text: extraError }),
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE agent_runs
|
||||
SET status = ?, summary = ?, commit_sha = ?, error = COALESCE(?, error), finished_at = ?
|
||||
WHERE id = ?`,
|
||||
).run(status, summary, commitSha, extraError, now, id);
|
||||
|
||||
this.active.delete(id);
|
||||
|
||||
// Optional cleanup of the worktree once the run is done.
|
||||
if (entry?.cleanup && entry.worktree) {
|
||||
removeWorktree(id, entry.worktree.branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RunNotFoundError extends Error {
|
||||
constructor(id: string) {
|
||||
super(`agent run not found: ${id}`);
|
||||
this.name = 'RunNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
function hydrateRun(row: AgentRunRow): AgentRun {
|
||||
return {
|
||||
id: row.id,
|
||||
cardId: row.card_id,
|
||||
status: row.status as RunStatus,
|
||||
useWorktree: row.use_worktree === 1,
|
||||
branch: row.branch,
|
||||
worktreePath: row.worktree_path,
|
||||
prompt: row.prompt,
|
||||
summary: row.summary,
|
||||
commitSha: row.commit_sha,
|
||||
error: row.error,
|
||||
createdAt: row.created_at,
|
||||
startedAt: row.started_at,
|
||||
finishedAt: row.finished_at,
|
||||
};
|
||||
}
|
||||
|
||||
function nextSeq(runId: string): number {
|
||||
return (
|
||||
db
|
||||
.prepare('SELECT COALESCE(MAX(seq), 0) + 1 AS n FROM agent_run_events WHERE run_id = ?')
|
||||
.get(runId) as { n: number }
|
||||
).n;
|
||||
}
|
||||
|
||||
function safeParse(s: string): Record<string, unknown> {
|
||||
try {
|
||||
return JSON.parse(s) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Process-wide singleton. */
|
||||
export const runManager = new RunManager();
|
||||
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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
202
apps/api/src/routes/internal.ts
Normal file
202
apps/api/src/routes/internal.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Internal, agent-facing routes.
|
||||
*
|
||||
* These are the endpoints the orchestrator agent calls (via `curl` from its
|
||||
* `pi` subprocess) to record its work: posting card comments, moving cards,
|
||||
* adding references, and upserting documentation pages. They are the only way
|
||||
* the kanban/docs SQLite store is mutated by an agent, keeping all writes in
|
||||
* this process.
|
||||
*
|
||||
* Every mutation requires an `x-agent-token` header matching a run's token.
|
||||
* Tokens are single-use-per-run secrets issued at run creation and baked into
|
||||
* the agent's prompt, so they never need to be shared with the UI.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../db.js';
|
||||
import type { Column } from '../types.js';
|
||||
import { isColumn } from '../types.js';
|
||||
import { renderMarkdown } from '../markdown.js';
|
||||
import { runManager } from '../orchestrator/runs.js';
|
||||
|
||||
export const internal = new Hono<{
|
||||
Variables: { runId: string; cardId: string };
|
||||
}>();
|
||||
|
||||
/** Middleware: require a valid agent token and attach the owning run's card id. */
|
||||
internal.use('*', async (c, next) => {
|
||||
const token = c.req.header('x-agent-token');
|
||||
if (!token) return c.json({ error: 'x-agent-token header required' }, 401);
|
||||
const run = runManager.getByToken(token);
|
||||
if (!run) return c.json({ error: 'invalid or expired agent token' }, 401);
|
||||
c.set('runId', run.id);
|
||||
c.set('cardId', run.cardId);
|
||||
await next();
|
||||
});
|
||||
|
||||
/** Post a comment on the run's card as the agent. */
|
||||
internal.post('/cards/:id/comments', async (c) => {
|
||||
const cardId = c.req.param('id');
|
||||
if (!(cardExists(cardId))) return c.json({ error: 'card not found' }, 404);
|
||||
|
||||
const body = await c.req.json().catch(() => ({}) as { text?: unknown });
|
||||
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
||||
if (!text) return c.json({ error: "'text' is required" }, 400);
|
||||
|
||||
const runId = c.get('runId');
|
||||
const comment = {
|
||||
id: randomUUID(),
|
||||
cardId,
|
||||
text,
|
||||
author: 'Agent',
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
db.prepare(
|
||||
'INSERT INTO comments (id, card_id, text, author, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(comment.id, comment.cardId, comment.text, comment.author, comment.createdAt);
|
||||
|
||||
if (runId) {
|
||||
logEvent(runId, 'log', { level: 'info', text: `Comment on ${cardId}: ${text.slice(0, 160)}` });
|
||||
}
|
||||
return c.json(comment, 201);
|
||||
});
|
||||
|
||||
/** Move the run's card to a new column. */
|
||||
internal.patch('/cards/:id', async (c) => {
|
||||
const cardId = c.req.param('id');
|
||||
if (!cardExists(cardId)) return c.json({ error: 'card not found' }, 404);
|
||||
|
||||
const body = await c.req.json().catch(() => ({}) as { status?: unknown });
|
||||
if (!isColumn(body.status)) return c.json({ error: "body must include a valid 'status'" }, 400);
|
||||
// Only a human may mark a card done — an agent must never complete cards.
|
||||
if (body.status === 'done') {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
"agents cannot set a card to 'done'. Leave it 'in-progress' with a summary comment; a human reviews and completes the card.",
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
db.prepare('UPDATE cards SET status = ?, updated_at = ? WHERE id = ?').run(
|
||||
body.status as Column,
|
||||
now,
|
||||
cardId,
|
||||
);
|
||||
|
||||
const runId = c.get('runId');
|
||||
if (runId) logEvent(runId, 'log', { level: 'info', text: `Moved ${cardId} → ${body.status}` });
|
||||
return c.json({ cardId, status: body.status });
|
||||
});
|
||||
|
||||
/** Add a user reference (doc/custom/external) to a card. */
|
||||
internal.post('/cards/:id/references', async (c) => {
|
||||
const cardId = c.req.param('id');
|
||||
if (!cardExists(cardId)) return c.json({ error: 'card not found' }, 404);
|
||||
|
||||
const body = await c.req.json().catch(() => ({}) as Record<string, unknown>);
|
||||
const label = typeof body.label === 'string' ? body.label.trim() : '';
|
||||
const href = typeof body.href === 'string' ? body.href.trim() : '';
|
||||
const type = typeof body.type === 'string' ? body.type : 'doc';
|
||||
if (!label || !href) return c.json({ error: "'label' and 'href' are required" }, 400);
|
||||
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO card_references (card_id, label, type, href, canonical) VALUES (?, ?, ?, ?, 0)',
|
||||
).run(cardId, label, type, href);
|
||||
|
||||
const runId = c.get('runId');
|
||||
if (runId) logEvent(runId, 'log', { level: 'info', text: `Linked ${cardId} → ${href}` });
|
||||
return c.json({ cardId, label, type, href, removable: true }, 201);
|
||||
});
|
||||
|
||||
interface UpsertPageBody {
|
||||
title?: unknown;
|
||||
icon?: unknown;
|
||||
blurb?: unknown;
|
||||
content?: unknown;
|
||||
slug?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a custom documentation page (upsert by slug).
|
||||
*
|
||||
* This is how the agent persists design decisions and completed-work summaries
|
||||
* into the documentation — the central store of truth. Content containing HTML
|
||||
* is stored verbatim as beautified HTML; otherwise it is rendered from markdown.
|
||||
*/
|
||||
internal.post('/pages', async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}) as UpsertPageBody);
|
||||
const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Untitled';
|
||||
const icon = typeof body.icon === 'string' && body.icon.trim() ? body.icon.trim().slice(0, 4) : '◈';
|
||||
const blurb = typeof body.blurb === 'string' ? body.blurb.trim() : '';
|
||||
const content = typeof body.content === 'string' ? body.content : '';
|
||||
if (!content.trim()) return c.json({ error: "'content' is required" }, 400);
|
||||
|
||||
const slug = (typeof body.slug === 'string' && body.slug.trim() ? body.slug.trim() : slugify(title)).slice(0, 64);
|
||||
const looksHtml = /<[a-z][\s\S]*>/i.test(content);
|
||||
const html = looksHtml ? content : renderMarkdown(content);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const existing = db.prepare('SELECT id FROM custom_pages WHERE slug = ?').get(slug) as { id: string } | undefined;
|
||||
let id: string;
|
||||
if (existing) {
|
||||
id = existing.id;
|
||||
db.prepare(
|
||||
`UPDATE custom_pages SET title = ?, icon = ?, blurb = ?, content = ?, html = ?, beautified = ?, updated_at = ? WHERE id = ?`,
|
||||
).run(title, icon, blurb, content, html, looksHtml ? 1 : 0, now, id);
|
||||
} else {
|
||||
id = randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO custom_pages (id, slug, title, icon, blurb, content, html, beautified, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(id, slug, title, icon, blurb, content, html, looksHtml ? 1 : 0, now, now);
|
||||
}
|
||||
|
||||
const runId = c.get('runId');
|
||||
if (runId) logEvent(runId, 'log', { level: 'info', text: `Documented "${title}" → /docs/custom/${slug}` });
|
||||
|
||||
const row = db.prepare('SELECT * FROM custom_pages WHERE id = ?').get(id);
|
||||
return c.json(hydratePage(row), existing ? 200 : 201);
|
||||
});
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
function cardExists(id: string): boolean {
|
||||
return Boolean(db.prepare('SELECT 1 FROM cards WHERE id = ?').get(id));
|
||||
}
|
||||
|
||||
function slugify(input: string): string {
|
||||
return (
|
||||
input
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64) || `page-${randomUUID().slice(0, 8)}`
|
||||
);
|
||||
}
|
||||
|
||||
function hydratePage(row: unknown): Record<string, unknown> {
|
||||
const r = row as {
|
||||
id: string; slug: string; title: string; icon: string; blurb: string;
|
||||
content: string; html: string; beautified: number; created_at: string; updated_at: string;
|
||||
};
|
||||
return {
|
||||
id: r.id, slug: r.slug, title: r.title, icon: r.icon, blurb: r.blurb,
|
||||
content: r.content, html: r.html, beautified: r.beautified === 1,
|
||||
createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Append a slim event to a run's persisted history (mirrors the Runner). */
|
||||
function logEvent(runId: string, type: string, data: Record<string, unknown>): void {
|
||||
const seq = (
|
||||
db.prepare('SELECT COALESCE(MAX(seq), 0) + 1 AS n FROM agent_run_events WHERE run_id = ?').get(runId) as { n: number }
|
||||
).n;
|
||||
db.prepare(
|
||||
'INSERT INTO agent_run_events (run_id, seq, type, data, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(runId, seq, type, JSON.stringify(data), new Date().toISOString());
|
||||
}
|
||||
167
apps/api/src/routes/kanban.ts
Normal file
167
apps/api/src/routes/kanban.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Hono } from 'hono';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db, type CardRow, type CommentRow, type ReferenceRow } from '../db.js';
|
||||
import { isColumn, type Board, type Card, type Column, type Comment, type Reference } from '../types.js';
|
||||
import { DOC_PAGES } from '../data/docPages.js';
|
||||
import { resetBoard } from '../seed.js';
|
||||
|
||||
export const kanban = new Hono();
|
||||
|
||||
// --- hydration helpers -----------------------------------------------------
|
||||
|
||||
const refsForCard = db.prepare(
|
||||
'SELECT label, type, href, canonical FROM card_references WHERE card_id = ?',
|
||||
);
|
||||
const tagsForCard = db.prepare('SELECT tag FROM tags WHERE card_id = ?');
|
||||
const commentsForCard = db.prepare(
|
||||
'SELECT id, card_id, text, author, created_at FROM comments WHERE card_id = ? ORDER BY created_at ASC',
|
||||
);
|
||||
|
||||
export function hydrateCard(row: CardRow): Card {
|
||||
const references: Reference[] = (refsForCard.all(row.id) as ReferenceRow[]).map((r) => ({
|
||||
label: r.label,
|
||||
type: r.type as Reference['type'],
|
||||
href: r.href,
|
||||
removable: r.canonical === 0,
|
||||
}));
|
||||
const tags: string[] = (tagsForCard.all(row.id) as { tag: string }[]).map((t) => t.tag);
|
||||
const comments: Comment[] = (commentsForCard.all(row.id) as CommentRow[]).map((c) => ({
|
||||
id: c.id,
|
||||
cardId: c.card_id,
|
||||
text: c.text,
|
||||
author: c.author,
|
||||
createdAt: c.created_at,
|
||||
}));
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
details: row.details,
|
||||
category: row.category,
|
||||
files: row.files,
|
||||
notes: row.notes,
|
||||
status: row.status as Column,
|
||||
sortOrder: row.sort_order,
|
||||
comments,
|
||||
tags,
|
||||
references,
|
||||
};
|
||||
}
|
||||
|
||||
// --- routes ----------------------------------------------------------------
|
||||
|
||||
/** Full board, cards ordered by sort_order. */
|
||||
kanban.get('/board', (c) => {
|
||||
const rows = db.prepare('SELECT * FROM cards ORDER BY sort_order ASC').all() as CardRow[];
|
||||
const cards = rows.map(hydrateCard);
|
||||
const lastRow = db.prepare('SELECT MAX(updated_at) AS u FROM cards').get() as { u: string | null } | undefined;
|
||||
const body: Board = { cards, lastUpdated: lastRow?.u ?? new Date().toISOString() };
|
||||
return c.json(body);
|
||||
});
|
||||
|
||||
/** Doc-page registry — drives the "add reference" picker on the frontend. */
|
||||
kanban.get('/pages', (c) => c.json(DOC_PAGES));
|
||||
|
||||
/** Move a card to a different column. */
|
||||
kanban.patch('/cards/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
if (!isColumn(body.status)) {
|
||||
return c.json({ error: "body must include a valid 'status'" }, 400);
|
||||
}
|
||||
const result = db
|
||||
.prepare('UPDATE cards SET status = ?, updated_at = ? WHERE id = ?')
|
||||
.run(body.status, new Date().toISOString(), id);
|
||||
if (result.changes === 0) return c.json({ error: 'card not found' }, 404);
|
||||
const row = db.prepare('SELECT * FROM cards WHERE id = ?').get(id) as CardRow | undefined;
|
||||
return c.json(hydrateCard(row!));
|
||||
});
|
||||
|
||||
/** Add a comment. */
|
||||
kanban.post('/cards/:id/comments', async (c) => {
|
||||
const cardId = c.req.param('id');
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
||||
const author = typeof body.author === 'string' && body.author.trim() ? body.author.trim() : 'Anonymous';
|
||||
if (!text) return c.json({ error: "'text' is required" }, 400);
|
||||
|
||||
if (!(db.prepare('SELECT 1 FROM cards WHERE id = ?').get(cardId))) {
|
||||
return c.json({ error: 'card not found' }, 404);
|
||||
}
|
||||
const comment: Comment = {
|
||||
id: randomUUID(),
|
||||
cardId,
|
||||
text,
|
||||
author,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
db.prepare(
|
||||
'INSERT INTO comments (id, card_id, text, author, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(comment.id, comment.cardId, comment.text, comment.author, comment.createdAt);
|
||||
return c.json(comment, 201);
|
||||
});
|
||||
|
||||
/** Delete a comment. */
|
||||
kanban.delete('/comments/:commentId', (c) => {
|
||||
const result = db.prepare('DELETE FROM comments WHERE id = ?').run(c.req.param('commentId'));
|
||||
if (result.changes === 0) return c.json({ error: 'comment not found' }, 404);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
/** Add a tag. */
|
||||
kanban.post('/cards/:id/tags', async (c) => {
|
||||
const cardId = c.req.param('id');
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const tag = typeof body.tag === 'string' ? body.tag.trim() : '';
|
||||
if (!tag) return c.json({ error: "'tag' is required" }, 400);
|
||||
if (!(db.prepare('SELECT 1 FROM cards WHERE id = ?').get(cardId))) {
|
||||
return c.json({ error: 'card not found' }, 404);
|
||||
}
|
||||
db.prepare('INSERT OR IGNORE INTO tags (card_id, tag) VALUES (?, ?)').run(cardId, tag);
|
||||
return c.json({ cardId, tag }, 201);
|
||||
});
|
||||
|
||||
/** Remove a tag. */
|
||||
kanban.delete('/cards/:id/tags/:tag', (c) => {
|
||||
const result = db
|
||||
.prepare('DELETE FROM tags WHERE card_id = ? AND tag = ?')
|
||||
.run(c.req.param('id'), decodeURIComponent(c.req.param('tag')));
|
||||
if (result.changes === 0) return c.json({ error: 'tag not found' }, 404);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
/** Add a user reference (canonical references are seeded, not added here). */
|
||||
kanban.post('/cards/:id/references', async (c) => {
|
||||
const cardId = c.req.param('id');
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const label = typeof body.label === 'string' ? body.label.trim() : '';
|
||||
const href = typeof body.href === 'string' ? body.href.trim() : '';
|
||||
const type = typeof body.type === 'string' ? body.type : 'doc';
|
||||
if (!label || !href) return c.json({ error: "'label' and 'href' are required" }, 400);
|
||||
if (!(db.prepare('SELECT 1 FROM cards WHERE id = ?').get(cardId))) {
|
||||
return c.json({ error: 'card not found' }, 404);
|
||||
}
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO card_references (card_id, label, type, href, canonical) VALUES (?, ?, ?, ?, 0)',
|
||||
).run(cardId, label, type, href);
|
||||
return c.json({ cardId, label, type, href, removable: true }, 201);
|
||||
});
|
||||
|
||||
/** Remove a user reference (canonical references cannot be removed). */
|
||||
kanban.delete('/cards/:id/references', (c) => {
|
||||
const href = c.req.query('href');
|
||||
if (!href) return c.json({ error: "'href' query param is required" }, 400);
|
||||
const result = db
|
||||
.prepare('DELETE FROM card_references WHERE card_id = ? AND href = ? AND canonical = 0')
|
||||
.run(c.req.param('id'), href);
|
||||
if (result.changes === 0) {
|
||||
return c.json({ error: 'reference not found or is canonical' }, 404);
|
||||
}
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
/** Reset board to canonical state (destructive: clears user data). */
|
||||
kanban.post('/reset', (c) => {
|
||||
resetBoard();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
148
apps/api/src/routes/orchestrator.ts
Normal file
148
apps/api/src/routes/orchestrator.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Orchestrator HTTP routes.
|
||||
*
|
||||
* Exposes run lifecycle (start/list/get/delete), interactive control
|
||||
* (steer/stop), and a Server-Sent-Events stream of a run's slim event log. The
|
||||
* agent itself runs in a `pi` subprocess and drives the board via the separate
|
||||
* `internal` router; these routes are for the human-facing UI.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { streamSSE } from 'hono/streaming';
|
||||
import { db } from '../db.js';
|
||||
import type { Card } from '../types.js';
|
||||
import { hydrateCard } from './kanban.js';
|
||||
import { RunNotFoundError, runManager } from '../orchestrator/runs.js';
|
||||
|
||||
export const orchestrator = new Hono();
|
||||
|
||||
/** Hydrate a full card (with references/tags/comments) for prompt building. */
|
||||
function loadCard(cardId: string): Card | undefined {
|
||||
const row = db.prepare('SELECT * FROM cards WHERE id = ?').get(cardId);
|
||||
if (!row) return undefined;
|
||||
return hydrateCard(row as Parameters<typeof hydrateCard>[0]);
|
||||
}
|
||||
|
||||
/** List recent runs (optionally filtered to a card). */
|
||||
orchestrator.get('/runs', (c) => {
|
||||
const cardId = c.req.query('cardId');
|
||||
const runs = cardId ? runManager.listForCard(cardId) : runManager.list();
|
||||
return c.json({ runs });
|
||||
});
|
||||
|
||||
/** Get a single run, including its persisted event history. */
|
||||
orchestrator.get('/runs/:id', (c) => {
|
||||
const run = runManager.get(c.req.param('id'));
|
||||
if (!run) return c.json({ error: 'run not found' }, 404);
|
||||
const since = Number(c.req.query('since') ?? '0');
|
||||
return c.json({ run, events: runManager.events(run.id, Number.isFinite(since) ? since : 0) });
|
||||
});
|
||||
|
||||
/** Start a new run for a card. */
|
||||
orchestrator.post('/runs', async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}) as Record<string, unknown>);
|
||||
const cardId = typeof body.cardId === 'string' ? body.cardId : '';
|
||||
if (!cardId) return c.json({ error: "'cardId' is required" }, 400);
|
||||
|
||||
const card = loadCard(cardId);
|
||||
if (!card) return c.json({ error: 'card not found' }, 404);
|
||||
|
||||
const prompt = typeof body.prompt === 'string' ? body.prompt : undefined;
|
||||
const useWorktree = body.useWorktree !== false; // default true
|
||||
const cleanupOnFinish = body.cleanupOnFinish === true;
|
||||
|
||||
try {
|
||||
const run = runManager.start(card, { cardId, prompt, useWorktree, cleanupOnFinish });
|
||||
return c.json({ run }, 201);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'failed to start run';
|
||||
return c.json({ error: message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/** Steer or follow-up an active run. */
|
||||
orchestrator.post('/runs/:id/message', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json().catch(() => ({}) as Record<string, unknown>);
|
||||
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
||||
const mode = body.mode === 'followUp' ? 'followUp' : 'steer';
|
||||
if (!text) return c.json({ error: "'text' is required" }, 400);
|
||||
try {
|
||||
runManager.message(id, text, mode);
|
||||
return c.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof RunNotFoundError) return c.json({ error: 'run is not active' }, 409);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
/** Stop an active run. */
|
||||
orchestrator.post('/runs/:id/stop', (c) => {
|
||||
const id = c.req.param('id');
|
||||
try {
|
||||
runManager.stop(id);
|
||||
return c.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof RunNotFoundError) return c.json({ error: 'run is not active' }, 409);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
/** Delete a (settled) run and reclaim its worktree. */
|
||||
orchestrator.delete('/runs/:id', (c) => {
|
||||
const id = c.req.param('id');
|
||||
try {
|
||||
runManager.remove(id);
|
||||
return c.body(null, 204);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'failed to delete run';
|
||||
const status = message.includes('active') ? 409 : 404;
|
||||
return c.json({ error: message }, status);
|
||||
}
|
||||
});
|
||||
|
||||
/** Live event stream for a run (Server-Sent Events). */
|
||||
orchestrator.get('/runs/:id/stream', (c) => {
|
||||
const id = c.req.param('id');
|
||||
const run = runManager.get(id);
|
||||
if (!run) return c.json({ error: 'run not found' }, 404);
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
// Replay any persisted history the client hasn't seen yet.
|
||||
let lastSeq = Number(c.req.query('since') ?? '0');
|
||||
if (!Number.isFinite(lastSeq)) lastSeq = 0;
|
||||
|
||||
const flushHistory = () => {
|
||||
for (const ev of runManager.events(id, lastSeq)) {
|
||||
lastSeq = ev.seq;
|
||||
void stream.writeSSE({ event: 'event', data: JSON.stringify({ seq: ev.seq, type: ev.type, data: ev.data, createdAt: ev.createdAt }) });
|
||||
}
|
||||
};
|
||||
flushHistory();
|
||||
|
||||
// If the run is still active, subscribe to live events. Each carries its
|
||||
// persisted `seq`, so advancing `lastSeq` here keeps the periodic history
|
||||
// flush below from ever re-emitting the same event.
|
||||
const unsubscribe = runManager.subscribe(id, (ev) => {
|
||||
if (ev.seq <= lastSeq) return;
|
||||
lastSeq = ev.seq;
|
||||
const { seq, createdAt, type, ...data } = ev;
|
||||
void stream.writeSSE({ event: 'event', data: JSON.stringify({ seq, type, data, createdAt }) });
|
||||
});
|
||||
|
||||
// Periodic flush of persisted history catches events written straight to the
|
||||
// DB (agent `curl` mutations, settle-time warnings) that bypass the live
|
||||
// subscriber. Since `lastSeq` tracks everything delivered, this is a no-op
|
||||
// except for those DB-direct events.
|
||||
const timer = setInterval(flushHistory, 1500);
|
||||
|
||||
// Keep the stream open until the client disconnects. `onAbort` fires on
|
||||
// disconnect; resolving the promise lets `streamSSE` finish cleanly.
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
|
||||
clearInterval(timer);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
170
apps/api/src/routes/pages.ts
Normal file
170
apps/api/src/routes/pages.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Hono } from 'hono';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db, type CustomPageRow } from '../db.js';
|
||||
import type { CustomPage } from '../types.js';
|
||||
import { renderMarkdown } from '../markdown.js';
|
||||
import { beautify, AiNotConfiguredError, isAiConfigured } from '../ai.js';
|
||||
|
||||
export const pages = new Hono();
|
||||
|
||||
/** Convert a snake_case DB row into the camelCase API shape. */
|
||||
function hydrate(row: CustomPageRow): CustomPage {
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
icon: row.icon,
|
||||
blurb: row.blurb,
|
||||
content: row.content,
|
||||
html: row.html,
|
||||
beautified: row.beautified === 1,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Make a string safe for use as a URL slug. */
|
||||
function slugify(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64) || `page-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/** Read all custom pages ordered by creation time. */
|
||||
pages.get('/', (c) => {
|
||||
const rows = db
|
||||
.prepare('SELECT * FROM custom_pages ORDER BY created_at ASC')
|
||||
.all() as CustomPageRow[];
|
||||
return c.json(rows.map(hydrate));
|
||||
});
|
||||
|
||||
/** Read a single page by slug. */
|
||||
pages.get('/:slug', (c) => {
|
||||
const row = db
|
||||
.prepare('SELECT * FROM custom_pages WHERE slug = ?')
|
||||
.get(c.req.param('slug')) as CustomPageRow | undefined;
|
||||
if (!row) return c.json({ error: 'page not found' }, 404);
|
||||
return c.json(hydrate(row));
|
||||
});
|
||||
|
||||
interface CreateBody {
|
||||
title?: unknown;
|
||||
icon?: unknown;
|
||||
blurb?: unknown;
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
/** Create a new custom page. */
|
||||
pages.post('/', async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}) as CreateBody);
|
||||
const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Untitled Page';
|
||||
const icon = typeof body.icon === 'string' && body.icon.trim() ? body.icon.trim().slice(0, 4) : '◈';
|
||||
const blurb = typeof body.blurb === 'string' ? body.blurb.trim() : '';
|
||||
const content = typeof body.content === 'string' ? body.content : '';
|
||||
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Guarantee a unique slug.
|
||||
let slug = slugify(title);
|
||||
const exists = db.prepare('SELECT 1 FROM custom_pages WHERE slug = ?');
|
||||
if (exists.get(slug)) {
|
||||
slug = `${slug}-${randomUUID().slice(0, 6)}`;
|
||||
}
|
||||
|
||||
const html = content ? renderMarkdown(content) : '';
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO custom_pages (id, slug, title, icon, blurb, content, html, beautified, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
||||
).run(id, slug, title, icon, blurb, content, html, now, now);
|
||||
|
||||
const row = db.prepare('SELECT * FROM custom_pages WHERE id = ?').get(id) as CustomPageRow;
|
||||
return c.json(hydrate(row), 201);
|
||||
});
|
||||
|
||||
interface UpdateBody {
|
||||
title?: unknown;
|
||||
icon?: unknown;
|
||||
blurb?: unknown;
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
/** Update a page's editable fields. A beautified page already stores its HTML
|
||||
* in `content`, so edits are kept verbatim; an un-beautified page's markdown
|
||||
* `content` is re-rendered to HTML whenever it changes. */
|
||||
pages.patch('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const row = db.prepare('SELECT * FROM custom_pages WHERE id = ?').get(id) as CustomPageRow | undefined;
|
||||
if (!row) return c.json({ error: 'page not found' }, 404);
|
||||
|
||||
const body = await c.req.json().catch(() => ({}) as UpdateBody);
|
||||
const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim() : row.title;
|
||||
const icon = typeof body.icon === 'string' && body.icon.trim() ? body.icon.trim().slice(0, 4) : row.icon;
|
||||
const blurb = typeof body.blurb === 'string' ? body.blurb.trim() : row.blurb;
|
||||
const content = typeof body.content === 'string' ? body.content : row.content;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// A beautified page's `content` is already the AI-authored HTML, so it is
|
||||
// kept verbatim (never re-rendered as markdown). An un-beautified page's
|
||||
// `content` is raw markdown and is re-rendered whenever it changes.
|
||||
const wasBeautified = row.beautified === 1;
|
||||
const contentChanged = body.content !== undefined;
|
||||
const beautified = contentChanged && !wasBeautified ? 0 : row.beautified;
|
||||
const html = beautified === 1 ? content : renderMarkdown(content);
|
||||
|
||||
db.prepare(
|
||||
`UPDATE custom_pages
|
||||
SET title = ?, icon = ?, blurb = ?, content = ?, html = ?, beautified = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
).run(title, icon, blurb, content, html, beautified, now, id);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM custom_pages WHERE id = ?').get(id) as CustomPageRow;
|
||||
return c.json(hydrate(updated));
|
||||
});
|
||||
|
||||
/** Delete a custom page. */
|
||||
pages.delete('/:id', (c) => {
|
||||
const result = db.prepare('DELETE FROM custom_pages WHERE id = ?').run(c.req.param('id'));
|
||||
if (result.changes === 0) return c.json({ error: 'page not found' }, 404);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
/** Beautify a page's content with the AI model, replacing the raw markdown
|
||||
* source with the generated HTML (which also becomes the displayed `html`). */
|
||||
pages.post('/:id/beautify', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const row = db.prepare('SELECT * FROM custom_pages WHERE id = ?').get(id) as CustomPageRow | undefined;
|
||||
if (!row) return c.json({ error: 'page not found' }, 404);
|
||||
if (!row.content.trim()) return c.json({ error: 'cannot beautify an empty page' }, 400);
|
||||
if (!isAiConfigured()) {
|
||||
return c.json(
|
||||
{ error: 'AI beautify is not configured. Set OPENAI_API_KEY on the API server.' },
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await beautify(row.content);
|
||||
} catch (err) {
|
||||
if (err instanceof AiNotConfiguredError) {
|
||||
return c.json({ error: err.message }, 503);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'AI beautify failed';
|
||||
return c.json({ error: message }, 502);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
// The beautified HTML replaces the raw markdown so the page's `content` is
|
||||
// the beautified version (the old markdown is discarded).
|
||||
db.prepare(
|
||||
'UPDATE custom_pages SET content = ?, html = ?, beautified = 1, updated_at = ? WHERE id = ?',
|
||||
).run(result.html, result.html, now, id);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM custom_pages WHERE id = ?').get(id) as CustomPageRow;
|
||||
return c.json(hydrate(updated));
|
||||
});
|
||||
77
apps/api/src/seed.ts
Normal file
77
apps/api/src/seed.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { db } from './db.js';
|
||||
import { CARDS } from './data/kanbanCards.js';
|
||||
|
||||
/**
|
||||
* Upsert canonical cards and their references into the database.
|
||||
*
|
||||
* - Card *content* (title, description, …) is always refreshed from the seed,
|
||||
* so editing card text in `kanbanCards.ts` flows through on next startup.
|
||||
* - Card *status* (column) is intentionally NOT overwritten on conflict, so a
|
||||
* user's board moves survive reseeds.
|
||||
* - Canonical references use `INSERT OR IGNORE`, so they self-heal if missing
|
||||
* but never duplicate. They are flagged `canonical = 1` and are not removable
|
||||
* from the UI.
|
||||
*/
|
||||
export function seed(): void {
|
||||
const upsertCard = db.prepare(`
|
||||
INSERT INTO cards (id, title, description, details, category, files, notes, status, sort_order, updated_at)
|
||||
VALUES (@id, @title, @description, @details, @category, @files, @notes, @status, @sort_order, @updated_at)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
details = excluded.details,
|
||||
category = excluded.category,
|
||||
files = excluded.files,
|
||||
notes = excluded.notes,
|
||||
sort_order = excluded.sort_order,
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
|
||||
const upsertRef = db.prepare(`
|
||||
INSERT OR IGNORE INTO card_references (card_id, label, type, href, canonical)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
`);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
for (const c of CARDS) {
|
||||
upsertCard.run({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
details: c.details,
|
||||
category: c.category,
|
||||
files: c.files,
|
||||
notes: c.notes,
|
||||
status: c.status,
|
||||
sort_order: c.sortOrder,
|
||||
updated_at: now,
|
||||
});
|
||||
for (const r of c.references) {
|
||||
upsertRef.run(c.id, r.label, r.type, r.href);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tx();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the board: drop all user data (comments, tags, user references) and
|
||||
* restore every card's status to its canonical column. Called by the
|
||||
* `POST /api/kanban/reset` endpoint.
|
||||
*/
|
||||
export function resetBoard(): void {
|
||||
const canonical = new Map(CARDS.map((c) => [c.id, c.status]));
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare('DELETE FROM comments').run();
|
||||
db.prepare('DELETE FROM tags').run();
|
||||
db.prepare('DELETE FROM card_references WHERE canonical = 0').run();
|
||||
const set = db.prepare('UPDATE cards SET status = ? WHERE id = ?');
|
||||
for (const [id, status] of canonical) {
|
||||
set.run(status, id);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
}
|
||||
112
apps/api/src/types.ts
Normal file
112
apps/api/src/types.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Shared domain types for the Kanban API.
|
||||
*
|
||||
* These describe the shape of data exchanged with the docs frontend. The
|
||||
* frontend re-declares a compatible set in `kanbanApi.ts` (it cannot import
|
||||
* across packages without a build step); keep them in sync.
|
||||
*/
|
||||
|
||||
/** Kanban columns, in left-to-right board order. */
|
||||
export type Column = 'done' | 'in-progress' | 'todo' | 'backlog';
|
||||
|
||||
export const COLUMNS: Column[] = ['done', 'in-progress', 'todo', 'backlog'];
|
||||
|
||||
export function isColumn(value: unknown): value is Column {
|
||||
return typeof value === 'string' && (COLUMNS as string[]).includes(value);
|
||||
}
|
||||
|
||||
export type ReferenceType = 'doc' | 'code' | 'demo' | 'external' | 'custom';
|
||||
|
||||
/** A cross-reference shown on a card — usually a link to a design doc page. */
|
||||
export interface Reference {
|
||||
label: string;
|
||||
type: ReferenceType;
|
||||
href: string;
|
||||
/** Canonical references come from the seed and cannot be removed by users. */
|
||||
removable: boolean;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
cardId: string;
|
||||
text: string;
|
||||
author: string;
|
||||
/** Epoch milliseconds. */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface Card {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
details: string;
|
||||
category: string;
|
||||
files: string | null;
|
||||
notes: string | null;
|
||||
status: Column;
|
||||
sortOrder: number;
|
||||
comments: Comment[];
|
||||
tags: string[];
|
||||
references: Reference[];
|
||||
}
|
||||
|
||||
export interface Board {
|
||||
cards: Card[];
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
export interface DocPage {
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
blurb: string;
|
||||
}
|
||||
|
||||
/** A user-created documentation page. Before beautification, `content` is raw
|
||||
* markdown-ish source and `html` is its rendered form; after beautification,
|
||||
* `content` is overwritten with the AI-authored HTML (which also equals
|
||||
* `html`). `beautified` records which state the page is in. */
|
||||
export interface CustomPage {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
blurb: string;
|
||||
content: string;
|
||||
html: string;
|
||||
beautified: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// --- Agentic orchestrator --------------------------------------------------
|
||||
|
||||
/** Lifecycle state of an agent run. */
|
||||
export type RunStatus = 'queued' | 'running' | 'completed' | 'failed' | 'stopped';
|
||||
|
||||
/** A single agent run working one kanban card inside a git worktree. */
|
||||
export interface AgentRun {
|
||||
id: string;
|
||||
cardId: string;
|
||||
status: RunStatus;
|
||||
useWorktree: boolean;
|
||||
branch: string | null;
|
||||
worktreePath: string | null;
|
||||
prompt: string;
|
||||
summary: string | null;
|
||||
commitSha: string | null;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
}
|
||||
|
||||
/** A slim, replayable event emitted during a run (persisted + streamed). */
|
||||
export interface AgentRunEvent {
|
||||
id: number;
|
||||
runId: string;
|
||||
seq: number;
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
19
apps/api/tsconfig.json
Normal file
19
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2023"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user