astrocode-workflow 0.1.59 → 0.2.1

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,12 +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";
9
+ import { SCHEMA_VERSION } from "../state/schema";
10
10
 
11
11
  export const EVENT_TYPES = {
12
12
  RUN_STARTED: "run.started",
@@ -18,23 +18,40 @@ export const EVENT_TYPES = {
18
18
  WORKFLOW_PROCEED: "workflow.proceed",
19
19
  } as const;
20
20
 
21
+ function tableExists(db: SqliteDb, tableName: string): boolean {
22
+ try {
23
+ const row = db
24
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
25
+ .get(tableName) as { name?: string } | undefined;
26
+ return row?.name === tableName;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
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
+
21
39
  /**
22
40
  * PLANNING-FIRST REDESIGN
23
41
  * ----------------------
24
- * Old behavior: mutate an approved story into a planning/decomposition instruction.
25
- * New behavior: NEVER mutate story title/body.
26
- *
27
- * Planning-first is a run-scoped directive stored in `injects` (scope = run:<run_id>).
42
+ * Never mutate story title/body.
28
43
  *
29
- * Deterministic trigger (config-driven):
44
+ * Deterministic trigger:
30
45
  * - config.workflow.genesis_planning:
31
46
  * - "off" => never attach directive
32
- * - "first_story_only"=> attach only when story_key === "S-0001"
47
+ * - "first_story_only"=> only when story_key === "S-0001"
33
48
  * - "always" => attach for every run
34
49
  *
35
50
  * Contract: DB is already initialized before workflow is used:
36
51
  * - schema tables exist
37
52
  * - repo_state singleton row (id=1) exists
53
+ *
54
+ * IMPORTANT: Do NOT call withTx() in here. The caller owns transaction boundaries.
38
55
  */
39
56
 
40
57
  export type NextAction =
@@ -114,7 +131,7 @@ type GenesisPlanningMode = "off" | "first_story_only" | "always";
114
131
  function getGenesisPlanningMode(config: AstrocodeConfig): GenesisPlanningMode {
115
132
  const raw = (config as any)?.workflow?.genesis_planning;
116
133
  if (raw === "off" || raw === "first_story_only" || raw === "always") return raw;
117
- if (raw != null) warn(`Invalid workflow.genesis_planning config: ${String(raw)}. Using default "first_story_only".`);
134
+ warn(`Invalid genesis_planning config: ${String(raw)}. Using default "first_story_only".`);
118
135
  return "first_story_only";
119
136
  }
120
137
 
@@ -126,6 +143,8 @@ function shouldAttachPlanningDirective(config: AstrocodeConfig, story: StoryRow)
126
143
  }
127
144
 
128
145
  function attachRunPlanningDirective(db: SqliteDb, runId: string, story: StoryRow, pipeline: StageKey[]) {
146
+ if (!tableExists(db, "injects")) return;
147
+
129
148
  const now = nowISO();
130
149
  const injectId = `inj_${runId}_genesis_plan`;
131
150
 
@@ -145,16 +164,9 @@ function attachRunPlanningDirective(db: SqliteDb, runId: string, story: StoryRow
145
164
  ``,
146
165
  ].join("\n");
147
166
 
148
- let hash: string | null = null;
149
- try {
150
- hash = sha256Hex(body);
151
- } catch {
152
- // Hash is optional; directive must never be blocked by hashing.
153
- hash = null;
154
- }
167
+ const hash = sha256Hex(body);
155
168
 
156
169
  try {
157
- // Do not clobber user edits. If it exists, we leave it.
158
170
  db.prepare(
159
171
  `
160
172
  INSERT OR IGNORE INTO injects (
@@ -169,173 +181,151 @@ function attachRunPlanningDirective(db: SqliteDb, runId: string, story: StoryRow
169
181
 
170
182
  db.prepare(
171
183
  "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
172
- ).run(
173
- newEventId(),
174
- runId,
175
- EVENT_TYPES.RUN_GENESIS_PLANNING_ATTACHED,
176
- JSON.stringify({ story_key: story.story_key, inject_id: injectId }),
177
- now
178
- );
184
+ ).run(newEventId(), runId, EVENT_TYPES.RUN_GENESIS_PLANNING_ATTACHED, JSON.stringify({ story_key: story.story_key, inject_id: injectId }), now);
179
185
  } catch (e) {
180
- // Helpful, never required for correctness.
181
186
  warn("Failed to attach genesis planning inject", { run_id: runId, story_key: story.story_key, err: e });
182
187
  }
183
188
  }
184
189
 
185
- function updateRepoStateLastEvent(db: SqliteDb, now: string, fields: { last_run_id?: string; last_story_key?: string }) {
186
- // Contract: repo_state row exists. If not, fail deterministically (don’t “bootstrap” here).
187
- const res = db
188
- .prepare(
189
- `
190
- UPDATE repo_state
191
- SET last_run_id = COALESCE(?, last_run_id),
192
- last_story_key = COALESCE(?, last_story_key),
193
- last_event_at = ?,
194
- updated_at = ?
195
- WHERE id = 1
196
- `
197
- )
198
- .run(fields.last_run_id ?? null, fields.last_story_key ?? null, now, now);
199
-
200
- if (!res || res.changes === 0) {
201
- throw new Error("repo_state missing (id=1). Database not initialized; run init must be performed before workflow.");
202
- }
203
- }
204
-
205
190
  export function createRunForStory(db: SqliteDb, config: AstrocodeConfig, storyKey: string): { run_id: string } {
206
- return withTx(db, () => {
207
- const story = getStory(db, storyKey);
208
- if (!story) throw new Error(`Story not found: ${storyKey}`);
209
- if (story.state !== "approved") throw new Error(`Story must be approved to run: ${storyKey} (state=${story.state})`);
210
-
211
- const run_id = newRunId();
212
- const now = nowISO();
213
- const pipeline = getPipelineFromConfig(config);
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})`);
214
194
 
215
- db.prepare(
216
- "UPDATE stories SET state='in_progress', in_progress=1, locked_by_run_id=?, locked_at=?, updated_at=? WHERE story_key=?"
217
- ).run(run_id, now, now, storyKey);
195
+ const run_id = newRunId();
196
+ const now = nowISO();
197
+ const pipeline = getPipelineFromConfig(config);
218
198
 
219
- db.prepare(
220
- "INSERT INTO runs (run_id, story_key, status, pipeline_stages_json, current_stage_key, created_at, started_at, updated_at) VALUES (?, ?, 'running', ?, ?, ?, ?, ?)"
221
- ).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);
222
202
 
223
- const insertStage = db.prepare(
224
- "INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)"
225
- );
226
- pipeline.forEach((stageKey, idx) => {
227
- insertStage.run(newStageRunId(), run_id, stageKey, idx, now, now);
228
- });
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);
229
206
 
230
- db.prepare(
231
- "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
232
- ).run(newEventId(), run_id, EVENT_TYPES.RUN_STARTED, JSON.stringify({ story_key: storyKey, pipeline }), now);
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);
212
+ });
233
213
 
234
- if (shouldAttachPlanningDirective(config, story)) {
235
- attachRunPlanningDirective(db, run_id, story, pipeline);
236
- }
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);
237
217
 
238
- updateRepoStateLastEvent(db, now, { last_run_id: run_id, last_story_key: storyKey });
218
+ if (shouldAttachPlanningDirective(config, story)) {
219
+ attachRunPlanningDirective(db, run_id, story, pipeline);
220
+ }
239
221
 
240
- return { run_id };
241
- });
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 };
242
233
  }
243
234
 
244
235
  export function startStage(
245
236
  db: SqliteDb,
246
237
  runId: string,
247
238
  stageKey: StageKey,
248
- meta?: { subagent_type?: string; subagent_session_id?: string }
239
+ meta?: { subagent_type?: string; subagent_session_id?: string },
240
+ emit?: UiEmit
249
241
  ) {
250
- return withTx(db, () => {
251
- const now = nowISO();
242
+ const now = nowISO();
252
243
 
253
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
254
- if (!run) throw new Error(`Run not found: ${runId}`);
255
- if (run.status !== "running") throw new Error(`Run is not running: ${runId} (status=${run.status})`);
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})`);
256
247
 
257
- const stage = db.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?").get(runId, stageKey) as StageRunRow | undefined;
258
- if (!stage) throw new Error(`Stage run not found: ${runId}/${stageKey}`);
259
- if (stage.status !== "pending") throw new Error(`Stage is not pending: ${stageKey} (status=${stage.status})`);
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})`);
260
251
 
261
- db.prepare(
262
- "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=?"
263
- ).run(now, now, meta?.subagent_type ?? null, meta?.subagent_session_id ?? null, stage.stage_run_id);
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);
264
255
 
265
- db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
256
+ db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
266
257
 
267
- db.prepare(
268
- "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)"
269
- ).run(newEventId(), runId, stageKey, EVENT_TYPES.STAGE_STARTED, JSON.stringify({ subagent_type: meta?.subagent_type ?? null }), now);
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);
270
261
 
271
- updateRepoStateLastEvent(db, now, {});
272
- });
262
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
263
+
264
+ // ✅ Explicit wiring point (requested): stage movement
265
+ emit?.({ kind: "stage_started", run_id: runId, stage_key: stageKey, agent_name: meta?.subagent_type });
273
266
  }
274
267
 
275
- export function completeRun(db: SqliteDb, runId: string) {
276
- return withTx(db, () => {
277
- const now = nowISO();
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})`);
278
273
 
279
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
280
- if (!run) throw new Error(`Run not found: ${runId}`);
281
- if (run.status !== "running") throw new Error(`Run not running: ${runId} (status=${run.status})`);
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}`);
282
277
 
283
- const stageRuns = getStageRuns(db, runId);
284
- const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
285
- if (incomplete) throw new Error(`Cannot complete run: stage ${incomplete.stage_key} is ${incomplete.status}`);
278
+ db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
286
279
 
287
- db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
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);
288
283
 
289
- db.prepare(
290
- "UPDATE stories SET state='done', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?"
291
- ).run(now, run.story_key);
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);
292
287
 
293
- db.prepare(
294
- "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
295
- ).run(newEventId(), runId, EVENT_TYPES.RUN_COMPLETED, JSON.stringify({ story_key: run.story_key }), now);
288
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
296
289
 
297
- updateRepoStateLastEvent(db, now, {});
298
- });
290
+ // ✅ Explicit wiring point (requested): run closed success
291
+ emit?.({ kind: "run_completed", run_id: runId, story_key: run.story_key });
299
292
  }
300
293
 
301
- export function failRun(db: SqliteDb, runId: string, stageKey: StageKey, errorText: string) {
302
- return withTx(db, () => {
303
- const now = nowISO();
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}`);
304
298
 
305
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
306
- if (!run) throw new Error(`Run not found: ${runId}`);
299
+ db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
307
300
 
308
- db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
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);
309
304
 
310
- db.prepare(
311
- "UPDATE stories SET state='blocked', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?"
312
- ).run(now, run.story_key);
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);
313
308
 
314
- db.prepare(
315
- "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)"
316
- ).run(newEventId(), runId, stageKey, EVENT_TYPES.RUN_FAILED, JSON.stringify({ error_text: errorText }), now);
309
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
317
310
 
318
- updateRepoStateLastEvent(db, now, {});
319
- });
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 });
320
313
  }
321
314
 
322
315
  export function abortRun(db: SqliteDb, runId: string, reason: string) {
323
- return withTx(db, () => {
324
- const now = nowISO();
325
-
326
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
327
- 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}`);
328
319
 
329
- 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);
330
321
 
331
- db.prepare(
332
- "UPDATE stories SET state='approved', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?"
333
- ).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);
334
325
 
335
- db.prepare(
336
- "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
337
- ).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);
338
329
 
339
- updateRepoStateLastEvent(db, now, {});
340
- });
330
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
341
331
  }