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:
@@ -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')],
|
||||
},
|
||||
];
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
133
apps/api/src/orchestrator/bevy.ts
Normal file
133
apps/api/src/orchestrator/bevy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
46
apps/api/src/orchestrator/events.ts
Normal file
46
apps/api/src/orchestrator/events.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user