diff --git a/apps/api/src/data/kanbanCards.ts b/apps/api/src/data/kanbanCards.ts deleted file mode 100644 index d135635..0000000 --- a/apps/api/src/data/kanbanCards.ts +++ /dev/null @@ -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')], - }, -]; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a999243..650bd8d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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. { diff --git a/apps/api/src/orchestrator/bevy.ts b/apps/api/src/orchestrator/bevy.ts new file mode 100644 index 0000000..a76a9a4 --- /dev/null +++ b/apps/api/src/orchestrator/bevy.ts @@ -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 | 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; + } +} diff --git a/apps/api/src/orchestrator/events.ts b/apps/api/src/orchestrator/events.ts new file mode 100644 index 0000000..235670f --- /dev/null +++ b/apps/api/src/orchestrator/events.ts @@ -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, +): AppendedEvent { + const seq = nextSeq(runId); + const createdAt = new Date().toISOString(); + insertEvent.run(runId, seq, type, JSON.stringify(data), createdAt); + return { seq, createdAt }; +} diff --git a/apps/api/src/orchestrator/runner.ts b/apps/api/src/orchestrator/runner.ts index b06ce22..d4cdd2b 100644 --- a/apps/api/src/orchestrator/runner.ts +++ b/apps/api/src/orchestrator/runner.ts @@ -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 { diff --git a/apps/api/src/orchestrator/runs.ts b/apps/api/src/orchestrator/runs.ts index 2da138b..07ce781 100644 --- a/apps/api/src/orchestrator/runs.ts +++ b/apps/api/src/orchestrator/runs.ts @@ -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(); + /** Active Bevy test processes keyed by run id (independent of agent runs). */ + private bevy = new Map(); /** 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 { try { return JSON.parse(s) as Record; diff --git a/apps/api/src/orchestrator/worktrees.ts b/apps/api/src/orchestrator/worktrees.ts index 4dd3767..35ed11a 100644 --- a/apps/api/src/orchestrator/worktrees.ts +++ b/apps/api/src/orchestrator/worktrees.ts @@ -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}`, + }; + } +} diff --git a/apps/api/src/routes/internal.ts b/apps/api/src/routes/internal.ts index 5204848..14ae735 100644 --- a/apps/api/src/routes/internal.ts +++ b/apps/api/src/routes/internal.ts @@ -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 { }; } -/** 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): 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); } diff --git a/apps/api/src/routes/kanban.ts b/apps/api/src/routes/kanban.ts index c834c70..c3122de 100644 --- a/apps/api/src/routes/kanban.ts +++ b/apps/api/src/routes/kanban.ts @@ -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 }); -}); diff --git a/apps/api/src/routes/orchestrator.ts b/apps/api/src/routes/orchestrator.ts index bc197a4..3cd62dc 100644 --- a/apps/api/src/routes/orchestrator.ts +++ b/apps/api/src/routes/orchestrator.ts @@ -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'); diff --git a/apps/api/src/seed.ts b/apps/api/src/seed.ts deleted file mode 100644 index 11f7350..0000000 --- a/apps/api/src/seed.ts +++ /dev/null @@ -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(); -} diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts index f718d22..c88620d 100644 --- a/apps/api/src/types.ts +++ b/apps/api/src/types.ts @@ -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; }