feat(api): add bevy playtest, worktree review, and drop seeding

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

View File

@@ -1,505 +0,0 @@
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')],
},
];

View File

@@ -2,15 +2,11 @@ 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.
{

View File

@@ -0,0 +1,133 @@
/**
* Bevy test launcher.
*
* Spawns `cargo run` inside a run's worktree game directory so the operator can
* playtest an agent's branch straight from the board. Build/runtime output is
* batched and appended to the run's event log as `bevy` events (phase: start |
* output | end), so it streams through the same SSE feed as the agent's work.
*
* The process is run in its own group so it can be torn down cleanly (cargo
* plus the spawned game window) when the operator stops the test.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { appendRunEvent } from './events.js';
/** Game package path within a worktree. */
const GAME_REL = 'apps/game';
/** `cargo` (or override) used to build + run the Bevy client. */
const BEVY_CMD = process.env.BEVY_CMD ?? 'cargo';
/** How often buffered output is flushed as a single event. */
const FLUSH_MS = 250;
/** Flush early once this much output has accumulated. */
const MAX_BUFFER_CHARS = 4_000;
/** Soft cap on the number of output events per run, to protect the log. */
const MAX_OUTPUT_EVENTS = 2_000;
/** A live `cargo run` for one run/worktree. Not tied to the agent runner — a
* Bevy test typically starts after the agent has settled. */
export class BevyProcess {
readonly runId: string;
private proc: ChildProcess | null = null;
private buffer = '';
private timer: ReturnType<typeof setInterval> | null = null;
private outputEvents = 0;
private cappedNotice = false;
get running(): boolean {
return this.proc !== null && !this.proc.killed;
}
constructor(runId: string) {
this.runId = runId;
}
/** Spawn cargo run in the worktree's game dir. No-op if already running;
* emits an `end` event if the worktree has no game dir. */
start(wtPath: string): void {
if (this.proc) return;
const gameDir = path.join(wtPath, GAME_REL);
if (!fs.existsSync(gameDir)) {
appendRunEvent(this.runId, 'bevy', {
phase: 'end',
exitCode: -1,
text: `No ${GAME_REL} in this worktree — cannot run Bevy.`,
});
return;
}
appendRunEvent(this.runId, 'bevy', { phase: 'start', text: `cargo run (in ${GAME_REL})` });
this.proc = spawn(BEVY_CMD, ['run'], {
cwd: gameDir,
detached: true, // own process group → clean teardown of cargo + game window
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env },
});
this.proc.stdout?.setEncoding('utf8');
this.proc.stderr?.setEncoding('utf8');
this.proc.stdout?.on('data', (d: string) => this.consume(d));
this.proc.stderr?.on('data', (d: string) => this.consume(d));
this.proc.on('exit', (code, signal) => this.onExit(code, signal));
this.timer = setInterval(() => this.flush(), FLUSH_MS);
}
/** Stop the test: signal the whole process group. The `exit` handler emits
* the final `end` event. */
stop(): void {
if (!this.proc || !this.proc.pid) return;
try {
process.kill(-this.proc.pid, 'SIGTERM');
} catch {
try {
this.proc.kill('SIGTERM');
} catch {
/* already gone */
}
}
}
private consume(chunk: string): void {
this.buffer += chunk;
if (this.buffer.length >= MAX_BUFFER_CHARS) this.flush();
}
private flush(): void {
const text = this.buffer;
this.buffer = '';
if (!text) return;
if (this.outputEvents >= MAX_OUTPUT_EVENTS) {
if (!this.cappedNotice) {
this.cappedNotice = true;
appendRunEvent(this.runId, 'bevy', {
phase: 'output',
text: '…(output truncated to protect the log; the game is still running)',
});
}
return;
}
this.outputEvents++;
appendRunEvent(this.runId, 'bevy', { phase: 'output', text });
}
private onExit(code: number | null, signal: NodeJS.Signals | null): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.flush();
const reason = signal ? `signal ${signal}` : `exit code ${code}`;
appendRunEvent(this.runId, 'bevy', {
phase: 'end',
exitCode: code ?? -1,
text: `Bevy process ended (${reason}).`,
});
this.proc = null;
}
}

View File

@@ -0,0 +1,46 @@
/**
* Shared run-event persistence.
*
* Every contributor to a run's event log — the pi agent runner, the
* agent-driven internal mutations, and the Bevy test launcher — appends through
* this single helper so sequence numbering and the JSON shape stay consistent.
*/
import { db } from '../db.js';
const insertEvent = db.prepare(
`INSERT INTO agent_run_events (run_id, seq, type, data, created_at)
VALUES (?, ?, ?, ?, ?)`,
);
/** Next monotonic sequence number for a run's event log. */
export 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;
}
export interface AppendedEvent {
seq: number;
createdAt: string;
}
/**
* Append a slim event to a run's persisted history.
*
* `type` is the discriminator the UI renders on; `data` carries its payload.
* Returns the assigned sequence number and timestamp so in-process subscribers
* (e.g. the SSE stream) can dedup against history they've already flushed.
*/
export function appendRunEvent(
runId: string,
type: string,
data: Record<string, unknown>,
): AppendedEvent {
const seq = nextSeq(runId);
const createdAt = new Date().toISOString();
insertEvent.run(runId, seq, type, JSON.stringify(data), createdAt);
return { seq, createdAt };
}

View File

@@ -15,8 +15,8 @@
*/
import { type ChildProcess, spawn } from 'node:child_process';
import { db } from '../db.js';
import type { RunStatus } from '../types.js';
import { appendRunEvent } from './events.js';
import { PI_BIN, PI_MODEL, PI_TOOLS } from './config.js';
/** Compact, UI-friendly event derived from pi's richer event stream. */
@@ -304,8 +304,9 @@ export class Runner {
// --- persistence + fan-out ----------------------------------------------
private emit(event: SlimEvent): void {
const persisted = persistEvent(this.runId, event);
const enriched: PersistedEvent = { ...event, seq: persisted.seq, createdAt: persisted.createdAt };
const { type, ...data } = event;
const { seq, createdAt } = appendRunEvent(this.runId, type, data);
const enriched: PersistedEvent = { ...event, seq, createdAt };
for (const fn of this.listeners) {
try {
fn(enriched);
@@ -318,24 +319,6 @@ export class Runner {
// --- 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 {

View File

@@ -10,10 +10,12 @@
import { randomUUID } from 'node:crypto';
import { db, type AgentRunRow } from '../db.js';
import type { AgentRun, AgentRunEvent, Card, RunStatus } from '../types.js';
import { appendRunEvent } from './events.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';
import { BevyProcess } from './bevy.js';
import { createWorktree, dirtySummary, headSha, isWorktreePresent, removeWorktree, type Worktree } from './worktrees.js';
export interface StartRunInput {
cardId: string;
@@ -26,8 +28,10 @@ export interface StartRunInput {
}
class RunManager {
/** Active runners keyed by run id. */
/** Active agent runners keyed by run id. */
private active = new Map<string, { runner: Runner; worktree: Worktree | null; cleanup: boolean }>();
/** Active Bevy test processes keyed by run id (independent of agent runs). */
private bevy = new Map<string, BevyProcess>();
/** Create and start a run for a card. Returns the persisted run. */
start(card: Card, input: StartRunInput): AgentRun {
@@ -103,12 +107,43 @@ class RunManager {
remove(id: string): void {
const entry = this.active.get(id);
if (entry) throw new Error('cannot delete an active run; stop it first');
// Stop any live Bevy test before tearing down its worktree.
this.bevy.get(id)?.stop();
this.bevy.delete(id);
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);
}
// --- Bevy playtesting ----------------------------------------------------
/** Whether a Bevy test is currently running for a run. */
bevyRunning(id: string): boolean {
return this.bevy.get(id)?.running ?? false;
}
/** Spawn `cargo run` in a run's worktree to playtest its branch. */
startBevy(id: string): void {
const run = this.get(id);
if (!run) throw new RunNotFoundError(id);
if (!run.worktreePath || !isWorktreePresent(run.worktreePath)) {
throw new Error('run has no worktree to run Bevy in');
}
let bp = this.bevy.get(id);
if (bp?.running) throw new Error('Bevy is already running for this run');
if (!bp) {
bp = new BevyProcess(id);
this.bevy.set(id, bp);
}
bp.start(run.worktreePath);
}
/** Stop a run's Bevy test (best-effort). */
stopBevy(id: string): void {
this.bevy.get(id)?.stop();
}
// --- reads ---------------------------------------------------------------
get(id: string): AgentRun | undefined {
@@ -167,18 +202,9 @@ class RunManager {
commitSha = headSha(entry.worktree.path);
const dirty = dirtySummary(entry.worktree.path);
if (dirty) {
// Uncommitted changes left behind — surface as an error note.
// Uncommitted changes left behind — surface as an error note + event.
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,
);
appendRunEvent(id, 'log', { level: 'warn', text: extraError });
}
}
@@ -206,8 +232,7 @@ export class RunNotFoundError extends Error {
// --- helpers ---------------------------------------------------------------
function hydrateRun(row: AgentRunRow): AgentRun {
return {
function hydrateRun(row: AgentRunRow): AgentRun { return {
id: row.id,
cardId: row.card_id,
status: row.status as RunStatus,
@@ -224,14 +249,6 @@ function hydrateRun(row: AgentRunRow): AgentRun {
};
}
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>;

View File

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

View File

@@ -19,6 +19,7 @@ import type { Column } from '../types.js';
import { isColumn } from '../types.js';
import { renderMarkdown } from '../markdown.js';
import { runManager } from '../orchestrator/runs.js';
import { appendRunEvent } from '../orchestrator/events.js';
export const internal = new Hono<{
Variables: { runId: string; cardId: string };
@@ -191,12 +192,7 @@ function hydratePage(row: unknown): Record<string, unknown> {
};
}
/** Append a slim event to a run's persisted history (mirrors the Runner). */
/** Append a slim event to a run's persisted history. */
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());
appendRunEvent(runId, type, data);
}

View File

@@ -3,7 +3,6 @@ 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();
@@ -130,7 +129,7 @@ kanban.delete('/cards/:id/tags/:tag', (c) => {
return c.body(null, 204);
});
/** Add a user reference (canonical references are seeded, not added here). */
/** Add a user reference (canonical references are protected, not added here). */
kanban.post('/cards/:id/references', async (c) => {
const cardId = c.req.param('id');
const body = await c.req.json().catch(() => ({}));
@@ -159,9 +158,3 @@ kanban.delete('/cards/:id/references', (c) => {
}
return c.body(null, 204);
});
/** Reset board to canonical state (destructive: clears user data). */
kanban.post('/reset', (c) => {
resetBoard();
return c.json({ ok: true });
});

View File

@@ -13,6 +13,14 @@ import { db } from '../db.js';
import type { Card } from '../types.js';
import { hydrateCard } from './kanban.js';
import { RunNotFoundError, runManager } from '../orchestrator/runs.js';
import {
commitsAheadOfHead,
diffPatch,
diffStat,
mainDirtySummary,
isWorktreePresent,
mergeBranch,
} from '../orchestrator/worktrees.js';
export const orchestrator = new Hono();
@@ -101,6 +109,65 @@ orchestrator.delete('/runs/:id', (c) => {
}
});
// --- worktree review & playtesting ----------------------------------------
/** Diff of a run's branch vs main (commits, --stat, and the capped patch). */
orchestrator.get('/runs/:id/diff', (c) => {
const run = runManager.get(c.req.param('id'));
if (!run) return c.json({ error: 'run not found' }, 404);
if (!run.branch || !run.worktreePath || !isWorktreePresent(run.worktreePath)) {
return c.json({ error: 'run has no worktree to diff' }, 409);
}
const { patch, truncated } = diffPatch(run.branch);
return c.json({
branch: run.branch,
commits: commitsAheadOfHead(run.branch),
stat: diffStat(run.branch),
patch,
truncated,
});
});
/** Merge a run's branch into the main worktree's checked-out branch. */
orchestrator.post('/runs/:id/merge', (c) => {
const run = runManager.get(c.req.param('id'));
if (!run) return c.json({ error: 'run not found' }, 404);
if (!run.branch) return c.json({ error: 'run has no branch to merge' }, 409);
// Refuse if the main worktree is dirty — merging would be unsafe.
const dirty = mainDirtySummary();
if (dirty) return c.json({ error: `Cannot merge: ${dirty}` }, 409);
// The merge outcome (success or conflict-aborted) is in the body; only hard
// preconditions (above) throw HTTP errors so the UI can read `ok` directly.
return c.json(mergeBranch(run.branch));
});
/** Whether a Bevy playtest is running for a run. */
orchestrator.get('/runs/:id/bevy', (c) => {
const run = runManager.get(c.req.param('id'));
if (!run) return c.json({ error: 'run not found' }, 404);
return c.json({ running: runManager.bevyRunning(run.id) });
});
/** Launch a Bevy playtest (`cargo run`) in a run's worktree. */
orchestrator.post('/runs/:id/bevy', (c) => {
const id = c.req.param('id');
try {
runManager.startBevy(id);
return c.json({ ok: true });
} catch (err) {
if (err instanceof RunNotFoundError) return c.json({ error: 'run not found' }, 404);
const message = err instanceof Error ? err.message : 'failed to start Bevy';
const status = message.includes('no worktree') || message.includes('already running') ? 409 : 500;
return c.json({ error: message }, status);
}
});
/** Stop a run's Bevy playtest. */
orchestrator.post('/runs/:id/bevy/stop', (c) => {
runManager.stopBevy(c.req.param('id'));
return c.json({ ok: true });
});
/** Live event stream for a run (Server-Sent Events). */
orchestrator.get('/runs/:id/stream', (c) => {
const id = c.req.param('id');

View File

@@ -1,77 +0,0 @@
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();
}

View File

@@ -22,7 +22,7 @@ export interface Reference {
label: string;
type: ReferenceType;
href: string;
/** Canonical references come from the seed and cannot be removed by users. */
/** Canonical references are protected and cannot be removed by users. */
removable: boolean;
}