astrocode-workflow 0.2.0 → 0.2.2

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.
@@ -1,15 +1,12 @@
1
1
  // src/workflow/state-machine.ts
2
2
  import type { AstrocodeConfig } from "../config/schema";
3
3
  import type { SqliteDb } from "../state/db";
4
- import { withTx } from "../state/db";
5
4
  import type { RunRow, StageKey, StageRunRow, StoryRow } from "../state/types";
6
5
  import { nowISO } from "../shared/time";
7
6
  import { newEventId, newRunId, newStageRunId } from "../state/ids";
8
7
  import { warn } from "../shared/log";
9
8
  import { sha256Hex } from "../shared/hash";
10
9
  import { SCHEMA_VERSION } from "../state/schema";
11
- import type { ToastOptions } from "../ui/toasts";
12
- import { injectChatPrompt } from "../ui/inject";
13
10
 
14
11
  export const EVENT_TYPES = {
15
12
  RUN_STARTED: "run.started",
@@ -21,47 +18,6 @@ export const EVENT_TYPES = {
21
18
  WORKFLOW_PROCEED: "workflow.proceed",
22
19
  } as const;
23
20
 
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
- }
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
21
  function tableExists(db: SqliteDb, tableName: string): boolean {
66
22
  try {
67
23
  const row = db
@@ -73,14 +29,31 @@ function tableExists(db: SqliteDb, tableName: string): boolean {
73
29
  }
74
30
  }
75
31
 
32
+ export type UiEmitEvent =
33
+ | { kind: "stage_started"; run_id: string; stage_key: StageKey; agent_name?: string }
34
+ | { kind: "run_completed"; run_id: string; story_key: string }
35
+ | { kind: "run_failed"; run_id: string; story_key: string; stage_key: StageKey; error_text: string };
36
+
37
+ export type UiEmit = (e: UiEmitEvent) => void;
38
+
76
39
  /**
77
40
  * PLANNING-FIRST REDESIGN
78
41
  * ----------------------
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"
42
+ * Never mutate story title/body.
43
+ *
44
+ * Deterministic trigger:
45
+ * - config.workflow.genesis_planning:
46
+ * - "off" => never attach directive
47
+ * - "first_story_only"=> only when story_key === "S-0001"
48
+ * - "always" => attach for every run
49
+ *
50
+ * Contract: DB is already initialized before workflow is used:
51
+ * - schema tables exist
52
+ * - repo_state singleton row (id=1) exists
53
+ *
54
+ * IMPORTANT: Do NOT call withTx() in here. The caller owns transaction boundaries.
83
55
  */
56
+
84
57
  export type NextAction =
85
58
  | { kind: "idle"; reason: "no_approved_stories" }
86
59
  | { kind: "start_run"; story_key: string }
@@ -141,7 +114,7 @@ export function decideNextAction(db: SqliteDb, config: AstrocodeConfig): NextAct
141
114
  return { kind: "failed", run_id: activeRun.run_id, stage_key: current.stage_key, error_text: current.error_text ?? "stage failed" };
142
115
  }
143
116
 
144
- warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.status });
117
+ warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.stage_key });
145
118
  return { kind: "await_stage_completion", run_id: activeRun.run_id, stage_key: current.stage_key, stage_run_id: current.stage_run_id };
146
119
  }
147
120
 
@@ -215,221 +188,144 @@ function attachRunPlanningDirective(db: SqliteDb, runId: string, story: StoryRow
215
188
  }
216
189
 
217
190
  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})`);
191
+ const story = getStory(db, storyKey);
192
+ if (!story) throw new Error(`Story not found: ${storyKey}`);
193
+ if (story.state !== "approved") throw new Error(`Story must be approved to run: ${storyKey} (state=${story.state})`);
222
194
 
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);
195
+ const run_id = newRunId();
196
+ const now = nowISO();
197
+ const pipeline = getPipelineFromConfig(config);
230
198
 
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);
199
+ db.prepare(
200
+ "UPDATE stories SET state='in_progress', in_progress=1, locked_by_run_id=?, locked_at=?, updated_at=? WHERE story_key=?"
201
+ ).run(run_id, now, now, storyKey);
234
202
 
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
- });
203
+ db.prepare(
204
+ "INSERT INTO runs (run_id, story_key, status, pipeline_stages_json, current_stage_key, created_at, started_at, updated_at) VALUES (?, ?, 'running', ?, ?, ?, ?, ?)"
205
+ ).run(run_id, storyKey, JSON.stringify(pipeline), pipeline[0] ?? null, now, now, now);
241
206
 
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 };
207
+ const insertStage = db.prepare(
208
+ "INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)"
209
+ );
210
+ pipeline.forEach((stageKey, idx) => {
211
+ insertStage.run(newStageRunId(), run_id, stageKey, idx, now, now);
261
212
  });
213
+
214
+ db.prepare(
215
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
216
+ ).run(newEventId(), run_id, EVENT_TYPES.RUN_STARTED, JSON.stringify({ story_key: storyKey, pipeline }), now);
217
+
218
+ if (shouldAttachPlanningDirective(config, story)) {
219
+ attachRunPlanningDirective(db, run_id, story, pipeline);
220
+ }
221
+
222
+ db.prepare(`
223
+ INSERT INTO repo_state (id, schema_version, created_at, updated_at, last_run_id, last_story_key, last_event_at)
224
+ VALUES (1, ?, ?, ?, ?, ?, ?)
225
+ ON CONFLICT(id) DO UPDATE SET
226
+ last_run_id=excluded.last_run_id,
227
+ last_story_key=excluded.last_story_key,
228
+ last_event_at=excluded.last_event_at,
229
+ updated_at=excluded.updated_at
230
+ `).run(SCHEMA_VERSION, now, now, now, run_id, storyKey, now);
231
+
232
+ return { run_id };
262
233
  }
263
234
 
264
- /**
265
- * STAGE MOVEMENT (START) — now async so UI injection is deterministic.
266
- */
267
- export async function startStage(
235
+ export function startStage(
268
236
  db: SqliteDb,
269
237
  runId: string,
270
238
  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})`);
239
+ meta?: { subagent_type?: string; subagent_session_id?: string },
240
+ emit?: UiEmit
241
+ ) {
242
+ const now = nowISO();
284
243
 
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);
244
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
245
+ if (!run) throw new Error(`Run not found: ${runId}`);
246
+ if (run.status !== "running") throw new Error(`Run is not running: ${runId} (status=${run.status})`);
288
247
 
289
- db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
248
+ const stage = db.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?").get(runId, stageKey) as StageRunRow | undefined;
249
+ if (!stage) throw new Error(`Stage run not found: ${runId}/${stageKey}`);
250
+ if (stage.status !== "pending") throw new Error(`Stage is not pending: ${stageKey} (status=${stage.status})`);
290
251
 
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);
252
+ db.prepare(
253
+ "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=?"
254
+ ).run(now, now, meta?.subagent_type ?? null, meta?.subagent_session_id ?? null, stage.stage_run_id);
294
255
 
295
- db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
256
+ db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
296
257
 
297
- const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key) as any;
258
+ db.prepare(
259
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)"
260
+ ).run(newEventId(), runId, stageKey, EVENT_TYPES.STAGE_STARTED, JSON.stringify({ subagent_type: meta?.subagent_type ?? null }), now);
298
261
 
299
- return {
300
- now,
301
- story_key: run.story_key,
302
- story_title: story?.title ?? "",
303
- };
304
- });
262
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
305
263
 
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
- }
321
- );
264
+ // Explicit wiring point (requested): stage movement
265
+ emit?.({ kind: "stage_started", run_id: runId, stage_key: stageKey, agent_name: meta?.subagent_type });
322
266
  }
323
267
 
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();
330
-
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})`);
334
-
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}`);
338
-
339
- db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
268
+ export function completeRun(db: SqliteDb, runId: string, emit?: UiEmit) {
269
+ const now = nowISO();
270
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
271
+ if (!run) throw new Error(`Run not found: ${runId}`);
272
+ if (run.status !== "running") throw new Error(`Run not running: ${runId} (status=${run.status})`);
340
273
 
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);
274
+ const stageRuns = getStageRuns(db, runId);
275
+ const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
276
+ if (incomplete) throw new Error(`Cannot complete run: stage ${incomplete.stage_key} is ${incomplete.status}`);
344
277
 
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);
278
+ db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
348
279
 
349
- db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
280
+ db.prepare(
281
+ "UPDATE stories SET state='done', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?"
282
+ ).run(now, run.story_key);
350
283
 
351
- const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key) as any;
284
+ db.prepare(
285
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
286
+ ).run(newEventId(), runId, EVENT_TYPES.RUN_COMPLETED, JSON.stringify({ story_key: run.story_key }), now);
352
287
 
353
- return { now, story_key: run.story_key, story_title: story?.title ?? "" };
354
- });
288
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
355
289
 
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
- );
290
+ // ✅ Explicit wiring point (requested): run closed success
291
+ emit?.({ kind: "run_completed", run_id: runId, story_key: run.story_key });
370
292
  }
371
293
 
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();
378
-
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}`);
381
-
382
- db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
294
+ export function failRun(db: SqliteDb, runId: string, stageKey: StageKey, errorText: string, emit?: UiEmit) {
295
+ const now = nowISO();
296
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
297
+ if (!run) throw new Error(`Run not found: ${runId}`);
383
298
 
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);
299
+ db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
387
300
 
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);
301
+ db.prepare(
302
+ "UPDATE stories SET state='blocked', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?"
303
+ ).run(now, run.story_key);
391
304
 
392
- db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
305
+ db.prepare(
306
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)"
307
+ ).run(newEventId(), runId, stageKey, EVENT_TYPES.RUN_FAILED, JSON.stringify({ error_text: errorText }), now);
393
308
 
394
- const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key) as any;
309
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
395
310
 
396
- return { now, story_key: run.story_key, story_title: story?.title ?? "" };
397
- });
398
-
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
- );
311
+ // Explicit wiring point (requested): run closed failure
312
+ emit?.({ kind: "run_failed", run_id: runId, story_key: run.story_key, stage_key: stageKey, error_text: errorText });
415
313
  }
416
314
 
417
315
  export function abortRun(db: SqliteDb, runId: string, reason: string) {
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}`);
316
+ const now = nowISO();
317
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
318
+ if (!run) throw new Error(`Run not found: ${runId}`);
422
319
 
423
- db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
320
+ db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
424
321
 
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);
322
+ db.prepare(
323
+ "UPDATE stories SET state='approved', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?"
324
+ ).run(now, run.story_key);
428
325
 
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);
326
+ db.prepare(
327
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
328
+ ).run(newEventId(), runId, EVENT_TYPES.RUN_ABORTED, JSON.stringify({ reason }), now);
432
329
 
433
- db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
434
- });
330
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
435
331
  }