astrocode-workflow 0.3.0 → 0.3.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.
Files changed (139) hide show
  1. package/dist/index.js +6 -0
  2. package/dist/shared/metrics.d.ts +66 -0
  3. package/dist/shared/metrics.js +112 -0
  4. package/dist/src/agents/commands.d.ts +9 -0
  5. package/dist/src/agents/commands.js +121 -0
  6. package/dist/src/agents/prompts.d.ts +3 -0
  7. package/dist/src/agents/prompts.js +232 -0
  8. package/dist/src/agents/registry.d.ts +6 -0
  9. package/dist/src/agents/registry.js +242 -0
  10. package/dist/src/agents/types.d.ts +14 -0
  11. package/dist/src/agents/types.js +8 -0
  12. package/dist/src/config/config-handler.d.ts +4 -0
  13. package/dist/src/config/config-handler.js +46 -0
  14. package/dist/src/config/defaults.d.ts +3 -0
  15. package/dist/src/config/defaults.js +3 -0
  16. package/dist/src/config/loader.d.ts +11 -0
  17. package/dist/src/config/loader.js +82 -0
  18. package/dist/src/config/schema.d.ts +194 -0
  19. package/dist/src/config/schema.js +223 -0
  20. package/dist/src/hooks/continuation-enforcer.d.ts +34 -0
  21. package/dist/src/hooks/continuation-enforcer.js +190 -0
  22. package/dist/src/hooks/inject-provider.d.ts +22 -0
  23. package/dist/src/hooks/inject-provider.js +120 -0
  24. package/dist/src/hooks/tool-output-truncator.d.ts +25 -0
  25. package/dist/src/hooks/tool-output-truncator.js +57 -0
  26. package/dist/src/index.d.ts +3 -0
  27. package/dist/src/index.js +308 -0
  28. package/dist/src/shared/deep-merge.d.ts +8 -0
  29. package/dist/src/shared/deep-merge.js +25 -0
  30. package/dist/src/shared/hash.d.ts +1 -0
  31. package/dist/src/shared/hash.js +4 -0
  32. package/dist/src/shared/log.d.ts +7 -0
  33. package/dist/src/shared/log.js +24 -0
  34. package/dist/src/shared/metrics.d.ts +66 -0
  35. package/dist/src/shared/metrics.js +112 -0
  36. package/dist/src/shared/model-tuning.d.ts +9 -0
  37. package/dist/src/shared/model-tuning.js +28 -0
  38. package/dist/src/shared/paths.d.ts +19 -0
  39. package/dist/src/shared/paths.js +64 -0
  40. package/dist/src/shared/text.d.ts +4 -0
  41. package/dist/src/shared/text.js +19 -0
  42. package/dist/src/shared/time.d.ts +1 -0
  43. package/dist/src/shared/time.js +3 -0
  44. package/dist/src/state/adapters/index.d.ts +41 -0
  45. package/dist/src/state/adapters/index.js +115 -0
  46. package/dist/src/state/db.d.ts +16 -0
  47. package/dist/src/state/db.js +225 -0
  48. package/dist/src/state/ids.d.ts +8 -0
  49. package/dist/src/state/ids.js +25 -0
  50. package/dist/src/state/repo-lock.d.ts +3 -0
  51. package/dist/src/state/repo-lock.js +29 -0
  52. package/dist/src/state/schema.d.ts +2 -0
  53. package/dist/src/state/schema.js +251 -0
  54. package/dist/src/state/types.d.ts +71 -0
  55. package/dist/src/state/types.js +1 -0
  56. package/dist/src/tools/artifacts.d.ts +18 -0
  57. package/dist/src/tools/artifacts.js +71 -0
  58. package/dist/src/tools/health.d.ts +8 -0
  59. package/dist/src/tools/health.js +119 -0
  60. package/dist/src/tools/index.d.ts +20 -0
  61. package/dist/src/tools/index.js +94 -0
  62. package/dist/src/tools/init.d.ts +17 -0
  63. package/dist/src/tools/init.js +96 -0
  64. package/dist/src/tools/injects.d.ts +53 -0
  65. package/dist/src/tools/injects.js +325 -0
  66. package/dist/src/tools/metrics.d.ts +7 -0
  67. package/dist/src/tools/metrics.js +61 -0
  68. package/dist/src/tools/repair.d.ts +8 -0
  69. package/dist/src/tools/repair.js +25 -0
  70. package/dist/src/tools/reset.d.ts +8 -0
  71. package/dist/src/tools/reset.js +92 -0
  72. package/dist/src/tools/run.d.ts +13 -0
  73. package/dist/src/tools/run.js +54 -0
  74. package/dist/src/tools/spec.d.ts +12 -0
  75. package/dist/src/tools/spec.js +44 -0
  76. package/dist/src/tools/stage.d.ts +23 -0
  77. package/dist/src/tools/stage.js +371 -0
  78. package/dist/src/tools/status.d.ts +8 -0
  79. package/dist/src/tools/status.js +125 -0
  80. package/dist/src/tools/story.d.ts +23 -0
  81. package/dist/src/tools/story.js +85 -0
  82. package/dist/src/tools/workflow.d.ts +13 -0
  83. package/dist/src/tools/workflow.js +355 -0
  84. package/dist/src/ui/inject.d.ts +12 -0
  85. package/dist/src/ui/inject.js +107 -0
  86. package/dist/src/ui/toasts.d.ts +13 -0
  87. package/dist/src/ui/toasts.js +39 -0
  88. package/dist/src/workflow/artifacts.d.ts +24 -0
  89. package/dist/src/workflow/artifacts.js +45 -0
  90. package/dist/src/workflow/baton.d.ts +72 -0
  91. package/dist/src/workflow/baton.js +166 -0
  92. package/dist/src/workflow/context.d.ts +20 -0
  93. package/dist/src/workflow/context.js +113 -0
  94. package/dist/src/workflow/directives.d.ts +39 -0
  95. package/dist/src/workflow/directives.js +137 -0
  96. package/dist/src/workflow/repair.d.ts +8 -0
  97. package/dist/src/workflow/repair.js +99 -0
  98. package/dist/src/workflow/state-machine.d.ts +86 -0
  99. package/dist/src/workflow/state-machine.js +216 -0
  100. package/dist/src/workflow/story-helpers.d.ts +9 -0
  101. package/dist/src/workflow/story-helpers.js +13 -0
  102. package/dist/state/db.d.ts +1 -0
  103. package/dist/state/db.js +9 -0
  104. package/dist/state/repo-lock.d.ts +3 -0
  105. package/dist/state/repo-lock.js +29 -0
  106. package/dist/test/integration/db-transactions.test.d.ts +1 -0
  107. package/dist/test/integration/db-transactions.test.js +126 -0
  108. package/dist/test/integration/injection-metrics.test.d.ts +1 -0
  109. package/dist/test/integration/injection-metrics.test.js +129 -0
  110. package/dist/tools/health.d.ts +8 -0
  111. package/dist/tools/health.js +119 -0
  112. package/dist/tools/index.js +9 -0
  113. package/dist/tools/metrics.d.ts +7 -0
  114. package/dist/tools/metrics.js +61 -0
  115. package/dist/tools/reset.d.ts +8 -0
  116. package/dist/tools/reset.js +92 -0
  117. package/dist/tools/workflow.js +210 -215
  118. package/dist/ui/inject.d.ts +6 -0
  119. package/dist/ui/inject.js +86 -67
  120. package/dist/workflow/state-machine.d.ts +32 -32
  121. package/dist/workflow/state-machine.js +85 -170
  122. package/package.json +6 -3
  123. package/src/index.ts +8 -0
  124. package/src/shared/metrics.ts +148 -0
  125. package/src/state/db.ts +10 -1
  126. package/src/state/repo-lock.ts +158 -0
  127. package/src/tools/health.ts +128 -0
  128. package/src/tools/index.ts +12 -3
  129. package/src/tools/init.ts +26 -14
  130. package/src/tools/metrics.ts +71 -0
  131. package/src/tools/repair.ts +21 -8
  132. package/src/tools/reset.ts +100 -0
  133. package/src/tools/stage.ts +12 -0
  134. package/src/tools/status.ts +17 -3
  135. package/src/tools/story.ts +41 -15
  136. package/src/tools/workflow.ts +123 -121
  137. package/src/ui/inject.ts +113 -79
  138. package/src/workflow/state-machine.ts +123 -227
  139. package/src/tools/workflow.ts.backup +0 -681
package/src/ui/inject.ts CHANGED
@@ -1,107 +1,141 @@
1
1
  // src/ui/inject.ts
2
-
3
- let isInjecting = false;
4
-
5
- const injectionQueue: Array<{
2
+ import { recordInjection, recordError } from "../shared/metrics";
3
+ type InjectionItem = {
6
4
  ctx: any;
7
- sessionId?: string;
5
+ sessionId: string;
8
6
  text: string;
9
7
  agent?: string;
10
- toast?: { title: string; message: string; variant?: "info" | "success" | "warning" | "error"; durationMs?: number };
11
- retry?: { maxAttempts?: number; baseDelayMs?: number };
12
- }> = [];
8
+ attempts: number;
9
+ resolve: () => void;
10
+ reject: (err: any) => void;
11
+ };
12
+
13
+ const MAX_ATTEMPTS = 4;
14
+ const RETRY_DELAYS_MS = [250, 500, 1000, 2000]; // attempt 1..4
15
+
16
+ // Per-session queues so one stuck session doesn't block others
17
+ const queues = new Map<string, InjectionItem[]>();
18
+ const running = new Set<string>();
13
19
 
14
20
  function sleep(ms: number) {
15
21
  return new Promise((r) => setTimeout(r, ms));
16
22
  }
17
23
 
18
- function resolveSessionId(ctx: any, sessionId?: string): string | null {
19
- if (sessionId) return sessionId;
20
- const direct = (ctx as any)?.sessionID ?? (ctx as any)?.sessionId ?? (ctx as any)?.session?.id;
21
- if (typeof direct === "string" && direct.length > 0) return direct;
22
- return null;
23
- }
24
-
25
- async function tryInjectOnce(opts: { ctx: any; sessionId: string; text: string; agent: string }): Promise<void> {
26
- const { ctx, sessionId, text, agent } = opts;
27
-
28
- // Prefer explicit chat prompt API
29
- const promptApi = (ctx as any)?.client?.session?.prompt;
30
- if (!promptApi) {
24
+ function getPromptInvoker(ctx: any): { session: any; prompt: Function } {
25
+ const session = ctx?.client?.session;
26
+ const prompt = session?.prompt;
27
+ if (!session || typeof prompt !== "function") {
31
28
  throw new Error("API not available (ctx.client.session.prompt)");
32
29
  }
30
+ return { session, prompt };
31
+ }
33
32
 
33
+ async function tryInjectOnce(item: InjectionItem): Promise<void> {
34
+ const { ctx, sessionId, text, agent = "Astro" } = item;
34
35
  const prefixedText = `[${agent}]\n\n${text}`;
35
36
 
36
- // Some hosts reject unknown fields; keep body minimal and stable.
37
- await promptApi({
38
- path: { id: sessionId },
39
- body: {
40
- parts: [{ type: "text", text: prefixedText }],
41
- },
42
- });
43
- }
37
+ const injectionRecorder = recordInjection({ sessionId, attempts: item.attempts + 1, agent });
38
+ const injectionStart = injectionRecorder.start();
44
39
 
45
- async function processQueue() {
46
- if (isInjecting) return;
47
- if (injectionQueue.length === 0) return;
40
+ try {
41
+ const { session, prompt } = getPromptInvoker(ctx);
42
+
43
+ // IMPORTANT: force correct `this` binding
44
+ await prompt.call(session, {
45
+ path: { id: sessionId },
46
+ body: {
47
+ parts: [{ type: "text", text: prefixedText }],
48
+ agent,
49
+ },
50
+ });
51
+
52
+ injectionRecorder.end(injectionStart, true);
53
+ } catch (error) {
54
+ injectionRecorder.end(injectionStart, false);
55
+ throw error;
56
+ }
57
+ }
48
58
 
49
- isInjecting = true;
59
+ async function runSessionQueue(sessionId: string) {
60
+ if (running.has(sessionId)) return;
61
+ running.add(sessionId);
50
62
 
51
63
  try {
52
- while (injectionQueue.length > 0) {
53
- const item = injectionQueue.shift();
54
- if (!item) continue;
55
-
56
- const { ctx, text, agent = "Astro" } = item;
57
- const sessionId = resolveSessionId(ctx, item.sessionId);
58
-
59
- if (!sessionId) {
60
- // Drop on floor: we cannot recover without a session id.
61
- // Keep draining the queue so we don't stall.
62
- // eslint-disable-next-line no-console
63
- console.warn("[Astrocode] Injection skipped: no sessionId");
64
- continue;
65
- }
66
-
67
- const maxAttempts = item.retry?.maxAttempts ?? 4;
68
- const baseDelayMs = item.retry?.baseDelayMs ?? 250;
69
-
70
- let lastErr: any = null;
71
-
72
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
73
- try {
74
- await tryInjectOnce({ ctx, sessionId, text, agent });
75
- lastErr = null;
76
- break;
77
- } catch (e) {
78
- lastErr = e;
79
- const delay = baseDelayMs * Math.pow(2, attempt - 1); // 250, 500, 1000, 2000
80
- // eslint-disable-next-line no-console
81
- console.warn(`[Astrocode] Injection attempt ${attempt}/${maxAttempts} failed: ${String(e)}; retrying in ${delay}ms`);
82
- await sleep(delay);
64
+ // eslint-disable-next-line no-constant-condition
65
+ while (true) {
66
+ const q = queues.get(sessionId);
67
+ if (!q || q.length === 0) break;
68
+
69
+ const item = q.shift()!;
70
+ try {
71
+ await tryInjectOnce(item);
72
+ item.resolve();
73
+ } catch (err) {
74
+ item.attempts += 1;
75
+
76
+ const msg = err instanceof Error ? err.message : String(err);
77
+ const delay = RETRY_DELAYS_MS[Math.min(item.attempts - 1, RETRY_DELAYS_MS.length - 1)] ?? 2000;
78
+
79
+ if (item.attempts >= MAX_ATTEMPTS) {
80
+ console.warn(
81
+ `[Astrocode] Injection failed permanently after ${item.attempts} attempts: ${msg}`
82
+ );
83
+ recordError("injection_failure", `Injection failed after ${item.attempts} attempts: ${msg}`);
84
+ item.reject(err);
85
+ continue;
83
86
  }
84
- }
85
87
 
86
- if (lastErr) {
87
- // eslint-disable-next-line no-console
88
- console.warn(`[Astrocode] Injection failed permanently after ${maxAttempts} attempts: ${String(lastErr)}`);
88
+ console.warn(
89
+ `[Astrocode] Injection attempt ${item.attempts}/${MAX_ATTEMPTS} failed: ${msg}; retrying in ${delay}ms`
90
+ );
91
+
92
+ await sleep(delay);
93
+
94
+ // Requeue at front to preserve order (and avoid starving later messages)
95
+ const q2 = queues.get(sessionId) ?? [];
96
+ q2.unshift(item);
97
+ queues.set(sessionId, q2);
89
98
  }
90
99
  }
91
100
  } finally {
92
- isInjecting = false;
101
+ running.delete(sessionId);
93
102
  }
94
103
  }
95
104
 
96
- export async function injectChatPrompt(opts: { ctx: any; sessionId?: string; text: string; agent?: string }) {
97
- injectionQueue.push({
98
- ctx: opts.ctx,
99
- sessionId: opts.sessionId,
100
- text: opts.text,
101
- agent: opts.agent ?? "Astro",
102
- retry: { maxAttempts: 4, baseDelayMs: 250 },
103
- });
105
+ /**
106
+ * Inject a visible prompt into the conversation.
107
+ * - Deterministic ordering per session
108
+ * - Correct SDK binding (prevents `this._client` undefined)
109
+ * - Awaitable: resolves when delivered, rejects after max retries
110
+ */
111
+ export async function injectChatPrompt(opts: {
112
+ ctx: any;
113
+ sessionId?: string;
114
+ text: string;
115
+ agent?: string;
116
+ }): Promise<void> {
117
+ const sessionId = opts.sessionId ?? (opts.ctx as any)?.sessionID;
118
+ if (!sessionId) {
119
+ console.warn("[Astrocode] Skipping injection: No sessionId provided");
120
+ return;
121
+ }
104
122
 
105
- // Fire-and-forget; queue drain is serialized by isInjecting.
106
- void processQueue();
123
+ return new Promise<void>((resolve, reject) => {
124
+ const item: InjectionItem = {
125
+ ctx: opts.ctx,
126
+ sessionId,
127
+ text: opts.text,
128
+ agent: opts.agent,
129
+ attempts: 0,
130
+ resolve,
131
+ reject,
132
+ };
133
+
134
+ const q = queues.get(sessionId) ?? [];
135
+ q.push(item);
136
+ queues.set(sessionId, q);
137
+
138
+ // Fire worker (don't await here; caller awaits the returned Promise)
139
+ void runSessionQueue(sessionId);
140
+ });
107
141
  }
@@ -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
  }