astrocode-workflow 0.1.58 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +243 -11
  2. package/dist/agents/prompts.d.ts +1 -0
  3. package/dist/agents/prompts.js +159 -0
  4. package/dist/agents/registry.js +11 -1
  5. package/dist/config/loader.js +34 -0
  6. package/dist/config/schema.d.ts +7 -1
  7. package/dist/config/schema.js +2 -0
  8. package/dist/hooks/continuation-enforcer.d.ts +9 -1
  9. package/dist/hooks/continuation-enforcer.js +2 -1
  10. package/dist/hooks/inject-provider.d.ts +9 -1
  11. package/dist/hooks/inject-provider.js +2 -1
  12. package/dist/hooks/tool-output-truncator.d.ts +9 -1
  13. package/dist/hooks/tool-output-truncator.js +2 -1
  14. package/dist/index.js +228 -45
  15. package/dist/state/adapters/index.d.ts +4 -2
  16. package/dist/state/adapters/index.js +23 -27
  17. package/dist/state/db.d.ts +6 -8
  18. package/dist/state/db.js +106 -45
  19. package/dist/tools/index.d.ts +13 -3
  20. package/dist/tools/index.js +14 -31
  21. package/dist/tools/init.d.ts +10 -1
  22. package/dist/tools/init.js +73 -18
  23. package/dist/tools/injects.js +90 -26
  24. package/dist/tools/spec.d.ts +0 -1
  25. package/dist/tools/spec.js +4 -1
  26. package/dist/tools/status.d.ts +1 -1
  27. package/dist/tools/status.js +70 -52
  28. package/dist/tools/workflow.js +2 -2
  29. package/dist/ui/inject.d.ts +16 -2
  30. package/dist/ui/inject.js +104 -33
  31. package/dist/workflow/directives.d.ts +2 -0
  32. package/dist/workflow/directives.js +34 -19
  33. package/dist/workflow/state-machine.d.ts +46 -3
  34. package/dist/workflow/state-machine.js +249 -92
  35. package/package.json +1 -1
  36. package/src/agents/prompts.ts +160 -0
  37. package/src/agents/registry.ts +16 -1
  38. package/src/config/loader.ts +39 -4
  39. package/src/config/schema.ts +3 -0
  40. package/src/hooks/continuation-enforcer.ts +9 -2
  41. package/src/hooks/inject-provider.ts +9 -2
  42. package/src/hooks/tool-output-truncator.ts +9 -2
  43. package/src/index.ts +260 -56
  44. package/src/state/adapters/index.ts +21 -26
  45. package/src/state/db.ts +114 -58
  46. package/src/tools/index.ts +29 -31
  47. package/src/tools/init.ts +91 -22
  48. package/src/tools/injects.ts +147 -53
  49. package/src/tools/spec.ts +6 -2
  50. package/src/tools/status.ts +71 -55
  51. package/src/tools/workflow.ts +3 -3
  52. package/src/ui/inject.ts +115 -41
  53. package/src/workflow/directives.ts +103 -75
  54. package/src/workflow/state-machine.ts +327 -109
@@ -1,12 +1,86 @@
1
+ // src/workflow/state-machine.ts
1
2
  import type { AstrocodeConfig } from "../config/schema";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
3
  import type { SqliteDb } from "../state/db";
4
+ import { withTx } from "../state/db";
5
5
  import type { RunRow, StageKey, StageRunRow, StoryRow } from "../state/types";
6
6
  import { nowISO } from "../shared/time";
7
7
  import { newEventId, newRunId, newStageRunId } from "../state/ids";
8
8
  import { warn } from "../shared/log";
9
+ import { sha256Hex } from "../shared/hash";
10
+ import { SCHEMA_VERSION } from "../state/schema";
11
+ import type { ToastOptions } from "../ui/toasts";
12
+ import { injectChatPrompt } from "../ui/inject";
13
+
14
+ export const EVENT_TYPES = {
15
+ RUN_STARTED: "run.started",
16
+ RUN_COMPLETED: "run.completed",
17
+ RUN_FAILED: "run.failed",
18
+ RUN_ABORTED: "run.aborted",
19
+ RUN_GENESIS_PLANNING_ATTACHED: "run.genesis_planning_attached",
20
+ STAGE_STARTED: "stage.started",
21
+ WORKFLOW_PROCEED: "workflow.proceed",
22
+ } as const;
23
+
24
+ /**
25
+ * UI HOOKS
26
+ * --------
27
+ * This workflow module is DB-first. UI emission is optional and happens AFTER the DB tx commits.
28
+ *
29
+ * Contract:
30
+ * - If you pass ui.ctx + ui.sessionId, we will inject a visible chat message deterministically.
31
+ * - If you pass ui.toast, we will also toast (throttling is handled by the toast manager).
32
+ */
33
+ export type WorkflowUi = {
34
+ ctx: any;
35
+ sessionId: string;
36
+ agentName?: string; // label for injected chat messages
37
+ toast?: (t: ToastOptions) => Promise<void>;
38
+ };
39
+
40
+ async function emitUi(ui: WorkflowUi | undefined, text: string, toast?: ToastOptions): Promise<void> {
41
+ if (!ui) return;
42
+
43
+ // Prefer toast (if provided) AND also inject chat (for audit trail / visibility).
44
+ // If you want toast-only, pass a toast function and omit ctx/sessionId.
45
+ if (toast && ui.toast) {
46
+ try {
47
+ await ui.toast(toast);
48
+ } catch {
49
+ // non-fatal
50
+ }
51
+ }
9
52
 
53
+ try {
54
+ await injectChatPrompt({
55
+ ctx: ui.ctx,
56
+ sessionId: ui.sessionId,
57
+ text,
58
+ agent: ui.agentName ?? "Astro",
59
+ });
60
+ } catch {
61
+ // non-fatal (workflow correctness is DB-based)
62
+ }
63
+ }
64
+
65
+ function tableExists(db: SqliteDb, tableName: string): boolean {
66
+ try {
67
+ const row = db
68
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
69
+ .get(tableName) as { name?: string } | undefined;
70
+ return row?.name === tableName;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * PLANNING-FIRST REDESIGN
78
+ * ----------------------
79
+ * - Never mutate story title/body.
80
+ * - Planning-first becomes a run-scoped inject stored in `injects` (scope = run:<run_id>).
81
+ * - Trigger is deterministic via config.workflow.genesis_planning:
82
+ * - "off" | "first_story_only" | "always"
83
+ */
10
84
  export type NextAction =
11
85
  | { kind: "idle"; reason: "no_approved_stories" }
12
86
  | { kind: "start_run"; story_key: string }
@@ -53,9 +127,7 @@ export function decideNextAction(db: SqliteDb, config: AstrocodeConfig): NextAct
53
127
  const stageRuns = getStageRuns(db, activeRun.run_id);
54
128
  const current = getCurrentStageRun(stageRuns);
55
129
 
56
- if (!current) {
57
- return { kind: "complete_run", run_id: activeRun.run_id };
58
- }
130
+ if (!current) return { kind: "complete_run", run_id: activeRun.run_id };
59
131
 
60
132
  if (current.status === "pending") {
61
133
  return { kind: "delegate_stage", run_id: activeRun.run_id, stage_key: current.stage_key, stage_run_id: current.stage_run_id };
@@ -69,149 +141,295 @@ export function decideNextAction(db: SqliteDb, config: AstrocodeConfig): NextAct
69
141
  return { kind: "failed", run_id: activeRun.run_id, stage_key: current.stage_key, error_text: current.error_text ?? "stage failed" };
70
142
  }
71
143
 
72
- // Should never happen: other statuses are handled above
73
- warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.stage_key });
144
+ warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.status });
74
145
  return { kind: "await_stage_completion", run_id: activeRun.run_id, stage_key: current.stage_key, stage_run_id: current.stage_run_id };
75
146
  }
76
147
 
77
- function isInitialStory(db: SqliteDb, storyKey: string): boolean {
78
- // Check if this story has no parent relations (is top-level)
79
- const relations = db.prepare("SELECT COUNT(*) as count FROM story_relations WHERE child_story_key=?").get(storyKey) as { count: number };
80
- return relations.count === 0;
148
+ function getPipelineFromConfig(config: AstrocodeConfig): StageKey[] {
149
+ const pipeline = (config as any)?.workflow?.pipeline as StageKey[] | undefined;
150
+ if (!Array.isArray(pipeline) || pipeline.length === 0) {
151
+ throw new Error("Invalid config: workflow.pipeline must be a non-empty array of stage keys.");
152
+ }
153
+ return pipeline;
81
154
  }
82
155
 
83
- export function createRunForStory(db: SqliteDb, config: AstrocodeConfig, storyKey: string): { run_id: string } {
84
- const story = getStory(db, storyKey);
85
- if (!story) throw new Error(`Story not found: ${storyKey}`);
86
- if (story.state !== "approved") throw new Error(`Story must be approved to run: ${storyKey} (state=${story.state})`);
156
+ type GenesisPlanningMode = "off" | "first_story_only" | "always";
87
157
 
88
- const run_id = newRunId();
89
- const now = nowISO();
90
- const pipeline = config.workflow.pipeline;
158
+ function getGenesisPlanningMode(config: AstrocodeConfig): GenesisPlanningMode {
159
+ const raw = (config as any)?.workflow?.genesis_planning;
160
+ if (raw === "off" || raw === "first_story_only" || raw === "always") return raw;
161
+ warn(`Invalid genesis_planning config: ${String(raw)}. Using default "first_story_only".`);
162
+ return "first_story_only";
163
+ }
91
164
 
92
- // Convert to genesis planning story if needed
93
- const isGenesisCandidate = storyKey === 'S-0001' || isInitialStory(db, storyKey) ||
94
- (story.body_md && story.body_md.length > 100 &&
95
- (story.title.toLowerCase().includes('implement') || story.body_md.toLowerCase().includes('implement')));
165
+ function shouldAttachPlanningDirective(config: AstrocodeConfig, story: StoryRow): boolean {
166
+ const mode = getGenesisPlanningMode(config);
167
+ if (mode === "off") return false;
168
+ if (mode === "always") return true;
169
+ return story.story_key === "S-0001";
170
+ }
96
171
 
97
- // Skip conversion if there are already many stories (spec already decomposed)
98
- const existingStoriesCount = db.prepare("SELECT COUNT(*) as count FROM stories").get() as { count: number };
99
- const alreadyDecomposed = existingStoriesCount.count > 10; // Arbitrary threshold
172
+ function attachRunPlanningDirective(db: SqliteDb, runId: string, story: StoryRow, pipeline: StageKey[]) {
173
+ if (!tableExists(db, "injects")) return;
100
174
 
101
- if (isGenesisCandidate && !alreadyDecomposed) {
102
- const planningTitle = `Plan and decompose: ${story.title}`;
103
- const planningBody = `Analyze the requirements and break down "${story.title}" into 50-200 detailed, granular implementation stories. Each story should be focused on a specific, implementable task with clear acceptance criteria.\n\nOriginal request: ${story.body_md || ''}`;
104
- db.prepare("UPDATE stories SET title=?, body_md=? WHERE story_key=?").run(planningTitle, planningBody, storyKey);
175
+ const now = nowISO();
176
+ const injectId = `inj_${runId}_genesis_plan`;
177
+
178
+ const body = [
179
+ `# Genesis planning directive`,
180
+ ``,
181
+ `This run is configured to perform a planning/decomposition pass before implementation.`,
182
+ `Do not edit the origin story title/body. Create additional stories instead.`,
183
+ ``,
184
+ `## Required output`,
185
+ `- Produce 50–200 granular implementation stories with clear acceptance criteria.`,
186
+ `- Each story: single focused change, explicit done conditions, dependencies listed.`,
187
+ ``,
188
+ `## Context`,
189
+ `- Origin story: ${story.story_key} — ${story.title ?? ""}`,
190
+ `- Pipeline: ${pipeline.join(" → ")}`,
191
+ ``,
192
+ ].join("\n");
193
+
194
+ const hash = sha256Hex(body);
195
+
196
+ try {
197
+ db.prepare(
198
+ `
199
+ INSERT OR IGNORE INTO injects (
200
+ inject_id, type, title, body_md, tags_json, scope, source, priority,
201
+ expires_at, sha256, created_at, updated_at
202
+ ) VALUES (
203
+ ?, 'note', ?, ?, '["genesis","planning","decompose"]', ?, 'tool', 100,
204
+ NULL, ?, ?, ?
205
+ )
206
+ `
207
+ ).run(injectId, "Genesis planning: decompose into stories", body, `run:${runId}`, hash, now, now);
208
+
209
+ db.prepare(
210
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
211
+ ).run(newEventId(), runId, EVENT_TYPES.RUN_GENESIS_PLANNING_ATTACHED, JSON.stringify({ story_key: story.story_key, inject_id: injectId }), now);
212
+ } catch (e) {
213
+ warn("Failed to attach genesis planning inject", { run_id: runId, story_key: story.story_key, err: e });
105
214
  }
215
+ }
106
216
 
107
- // Lock story
108
- db.prepare(
109
- "UPDATE stories SET state='in_progress', in_progress=1, locked_by_run_id=?, locked_at=?, updated_at=? WHERE story_key=?"
110
- ).run(run_id, now, now, storyKey);
217
+ export function createRunForStory(db: SqliteDb, config: AstrocodeConfig, storyKey: string): { run_id: string } {
218
+ return withTx(db, () => {
219
+ const story = getStory(db, storyKey);
220
+ if (!story) throw new Error(`Story not found: ${storyKey}`);
221
+ if (story.state !== "approved") throw new Error(`Story must be approved to run: ${storyKey} (state=${story.state})`);
222
+
223
+ const run_id = newRunId();
224
+ const now = nowISO();
225
+ const pipeline = getPipelineFromConfig(config);
226
+
227
+ db.prepare(
228
+ "UPDATE stories SET state='in_progress', in_progress=1, locked_by_run_id=?, locked_at=?, updated_at=? WHERE story_key=?"
229
+ ).run(run_id, now, now, storyKey);
230
+
231
+ db.prepare(
232
+ "INSERT INTO runs (run_id, story_key, status, pipeline_stages_json, current_stage_key, created_at, started_at, updated_at) VALUES (?, ?, 'running', ?, ?, ?, ?, ?)"
233
+ ).run(run_id, storyKey, JSON.stringify(pipeline), pipeline[0] ?? null, now, now, now);
234
+
235
+ const insertStage = db.prepare(
236
+ "INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)"
237
+ );
238
+ pipeline.forEach((stageKey, idx) => {
239
+ insertStage.run(newStageRunId(), run_id, stageKey, idx, now, now);
240
+ });
241
+
242
+ db.prepare(
243
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
244
+ ).run(newEventId(), run_id, EVENT_TYPES.RUN_STARTED, JSON.stringify({ story_key: storyKey, pipeline }), now);
245
+
246
+ if (shouldAttachPlanningDirective(config, story)) {
247
+ attachRunPlanningDirective(db, run_id, story, pipeline);
248
+ }
249
+
250
+ db.prepare(`
251
+ INSERT INTO repo_state (id, schema_version, created_at, updated_at, last_run_id, last_story_key, last_event_at)
252
+ VALUES (1, ?, ?, ?, ?, ?, ?)
253
+ ON CONFLICT(id) DO UPDATE SET
254
+ last_run_id=excluded.last_run_id,
255
+ last_story_key=excluded.last_story_key,
256
+ last_event_at=excluded.last_event_at,
257
+ updated_at=excluded.updated_at
258
+ `).run(SCHEMA_VERSION, now, now, run_id, storyKey, now);
259
+
260
+ return { run_id };
261
+ });
262
+ }
111
263
 
112
- db.prepare(
113
- "INSERT INTO runs (run_id, story_key, status, pipeline_stages_json, current_stage_key, created_at, started_at, updated_at) VALUES (?, ?, 'running', ?, ?, ?, ?, ?)"
114
- ).run(run_id, storyKey, JSON.stringify(pipeline), pipeline[0] ?? null, now, now, now);
264
+ /**
265
+ * STAGE MOVEMENT (START) now async so UI injection is deterministic.
266
+ */
267
+ export async function startStage(
268
+ db: SqliteDb,
269
+ runId: string,
270
+ stageKey: StageKey,
271
+ meta?: { subagent_type?: string; subagent_session_id?: string; ui?: WorkflowUi }
272
+ ): Promise<void> {
273
+ // Do DB work inside tx, capture what we need for UI outside.
274
+ const payload = withTx(db, () => {
275
+ const now = nowISO();
276
+
277
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
278
+ if (!run) throw new Error(`Run not found: ${runId}`);
279
+ if (run.status !== "running") throw new Error(`Run is not running: ${runId} (status=${run.status})`);
280
+
281
+ const stage = db.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?").get(runId, stageKey) as StageRunRow | undefined;
282
+ if (!stage) throw new Error(`Stage run not found: ${runId}/${stageKey}`);
283
+ if (stage.status !== "pending") throw new Error(`Stage is not pending: ${stageKey} (status=${stage.status})`);
284
+
285
+ db.prepare(
286
+ "UPDATE stage_runs SET status='running', started_at=?, updated_at=?, subagent_type=COALESCE(?, subagent_type), subagent_session_id=COALESCE(?, subagent_session_id) WHERE stage_run_id=?"
287
+ ).run(now, now, meta?.subagent_type ?? null, meta?.subagent_session_id ?? null, stage.stage_run_id);
288
+
289
+ db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
290
+
291
+ db.prepare(
292
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)"
293
+ ).run(newEventId(), runId, stageKey, EVENT_TYPES.STAGE_STARTED, JSON.stringify({ subagent_type: meta?.subagent_type ?? null }), now);
294
+
295
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
296
+
297
+ const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key) as any;
298
+
299
+ return {
300
+ now,
301
+ story_key: run.story_key,
302
+ story_title: story?.title ?? "",
303
+ };
304
+ });
115
305
 
116
- // Stage runs
117
- const insertStage = db.prepare(
118
- "INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, updated_at) VALUES (?, ?, ?, ?, 'pending', ?)"
306
+ // Deterministic UI emission AFTER commit (never inside tx).
307
+ await emitUi(
308
+ meta?.ui,
309
+ [
310
+ `🟦 Stage started`,
311
+ `- Run: \`${runId}\``,
312
+ `- Stage: \`${stageKey}\``,
313
+ `- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
314
+ ].join("\n"),
315
+ {
316
+ title: "Stage started",
317
+ message: `${stageKey} (${payload.story_key})`,
318
+ variant: "info",
319
+ durationMs: 2500,
320
+ }
119
321
  );
322
+ }
120
323
 
121
- pipeline.forEach((stageKey, idx) => {
122
- insertStage.run(newStageRunId(), run_id, stageKey, idx, now);
123
- });
324
+ /**
325
+ * STAGE CLOSED (RUN COMPLETED) now async so UI injection is deterministic.
326
+ */
327
+ export async function completeRun(db: SqliteDb, runId: string, ui?: WorkflowUi): Promise<void> {
328
+ const payload = withTx(db, () => {
329
+ const now = nowISO();
124
330
 
125
- // Event
126
- db.prepare(
127
- "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, 'run.started', ?, ?)"
128
- ).run(newEventId(), run_id, JSON.stringify({ story_key: storyKey, pipeline }), now);
331
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
332
+ if (!run) throw new Error(`Run not found: ${runId}`);
333
+ if (run.status !== "running") throw new Error(`Run not running: ${runId} (status=${run.status})`);
129
334
 
130
- db.prepare("UPDATE repo_state SET last_run_id=?, last_story_key=?, last_event_at=?, updated_at=? WHERE id=1").run(run_id, storyKey, now, now);
335
+ const stageRuns = getStageRuns(db, runId);
336
+ const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
337
+ if (incomplete) throw new Error(`Cannot complete run: stage ${incomplete.stage_key} is ${incomplete.status}`);
131
338
 
132
- return { run_id };
133
- }
339
+ db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
134
340
 
135
- export function startStage(db: SqliteDb, runId: string, stageKey: StageKey, meta?: { subagent_type?: string; subagent_session_id?: string }) {
136
- const now = nowISO();
341
+ db.prepare(
342
+ "UPDATE stories SET state='done', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?"
343
+ ).run(now, run.story_key);
137
344
 
138
- // Ensure run is running
139
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
140
- if (!run) throw new Error(`Run not found: ${runId}`);
141
- if (run.status !== "running") throw new Error(`Run is not running: ${runId} (status=${run.status})`);
345
+ db.prepare(
346
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
347
+ ).run(newEventId(), runId, EVENT_TYPES.RUN_COMPLETED, JSON.stringify({ story_key: run.story_key }), now);
142
348
 
143
- const stage = db
144
- .prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?")
145
- .get(runId, stageKey) as StageRunRow | undefined;
146
- if (!stage) throw new Error(`Stage run not found: ${runId}/${stageKey}`);
147
- if (stage.status !== "pending") throw new Error(`Stage is not pending: ${stageKey} (status=${stage.status})`);
349
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
148
350
 
149
- db.prepare(
150
- "UPDATE stage_runs SET status='running', started_at=?, updated_at=?, subagent_type=COALESCE(?, subagent_type), subagent_session_id=COALESCE(?, subagent_session_id) WHERE stage_run_id=?"
151
- ).run(now, now, meta?.subagent_type ?? null, meta?.subagent_session_id ?? null, stage.stage_run_id);
351
+ const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key) as any;
152
352
 
153
- db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
353
+ return { now, story_key: run.story_key, story_title: story?.title ?? "" };
354
+ });
154
355
 
155
- db.prepare(
156
- "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, 'stage.started', ?, ?)"
157
- ).run(newEventId(), runId, stageKey, JSON.stringify({ subagent_type: meta?.subagent_type ?? null }), now);
356
+ await emitUi(
357
+ ui,
358
+ [
359
+ `✅ Run completed`,
360
+ `- Run: \`${runId}\``,
361
+ `- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
362
+ ].join("\n"),
363
+ {
364
+ title: "Run completed",
365
+ message: `${payload.story_key} — done`,
366
+ variant: "success",
367
+ durationMs: 3000,
368
+ }
369
+ );
158
370
  }
159
371
 
160
- export function completeRun(db: SqliteDb, runId: string) {
161
- const now = nowISO();
162
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
163
- if (!run) throw new Error(`Run not found: ${runId}`);
164
- if (run.status !== "running") throw new Error(`Run not running: ${runId} (status=${run.status})`);
372
+ /**
373
+ * STAGE CLOSED (RUN FAILED) — now async so UI injection is deterministic.
374
+ */
375
+ export async function failRun(db: SqliteDb, runId: string, stageKey: StageKey, errorText: string, ui?: WorkflowUi): Promise<void> {
376
+ const payload = withTx(db, () => {
377
+ const now = nowISO();
165
378
 
166
- // Ensure all stages completed/skipped
167
- const stageRuns = getStageRuns(db, runId);
168
- const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
169
- if (incomplete) throw new Error(`Cannot complete run: stage ${incomplete.stage_key} is ${incomplete.status}`);
379
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
380
+ if (!run) throw new Error(`Run not found: ${runId}`);
170
381
 
171
- db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
172
- db.prepare(
173
- "UPDATE stories SET state='done', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?"
174
- ).run(now, run.story_key);
382
+ db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
175
383
 
176
- db.prepare(
177
- "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, 'run.completed', ?, ?)"
178
- ).run(newEventId(), runId, JSON.stringify({ story_key: run.story_key }), now);
384
+ db.prepare(
385
+ "UPDATE stories SET state='blocked', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?"
386
+ ).run(now, run.story_key);
179
387
 
180
- db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
181
- }
388
+ db.prepare(
389
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)"
390
+ ).run(newEventId(), runId, stageKey, EVENT_TYPES.RUN_FAILED, JSON.stringify({ error_text: errorText }), now);
182
391
 
183
- export function failRun(db: SqliteDb, runId: string, stageKey: StageKey, errorText: string) {
184
- const now = nowISO();
185
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
186
- if (!run) throw new Error(`Run not found: ${runId}`);
392
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
187
393
 
188
- db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
189
- db.prepare(
190
- "UPDATE stories SET state='blocked', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?"
191
- ).run(now, run.story_key);
394
+ const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key) as any;
192
395
 
193
- db.prepare(
194
- "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, 'run.failed', ?, ?)"
195
- ).run(newEventId(), runId, stageKey, JSON.stringify({ error_text: errorText }), now);
396
+ return { now, story_key: run.story_key, story_title: story?.title ?? "" };
397
+ });
196
398
 
197
- db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
399
+ await emitUi(
400
+ ui,
401
+ [
402
+ `⛔ Run failed`,
403
+ `- Run: \`${runId}\``,
404
+ `- Stage: \`${stageKey}\``,
405
+ `- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
406
+ `- Error: ${errorText}`,
407
+ ].join("\n"),
408
+ {
409
+ title: "Run failed",
410
+ message: `${stageKey}: ${errorText}`,
411
+ variant: "error",
412
+ durationMs: 4500,
413
+ }
414
+ );
198
415
  }
199
416
 
200
417
  export function abortRun(db: SqliteDb, runId: string, reason: string) {
201
- const now = nowISO();
202
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
203
- if (!run) throw new Error(`Run not found: ${runId}`);
418
+ return withTx(db, () => {
419
+ const now = nowISO();
420
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
421
+ if (!run) throw new Error(`Run not found: ${runId}`);
204
422
 
205
- db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
423
+ db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
206
424
 
207
- // Unlock story back to approved so it can be re-run.
208
- db.prepare(
209
- "UPDATE stories SET state='approved', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?"
210
- ).run(now, run.story_key);
425
+ db.prepare(
426
+ "UPDATE stories SET state='approved', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?"
427
+ ).run(now, run.story_key);
211
428
 
212
- db.prepare(
213
- "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, 'run.aborted', ?, ?)"
214
- ).run(newEventId(), runId, JSON.stringify({ reason }), now);
429
+ db.prepare(
430
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
431
+ ).run(newEventId(), runId, EVENT_TYPES.RUN_ABORTED, JSON.stringify({ reason }), now);
215
432
 
216
- db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
433
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
434
+ });
217
435
  }