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,8 +1,8 @@
1
- import { withTx } from "../state/db";
2
1
  import { nowISO } from "../shared/time";
3
2
  import { newEventId, newRunId, newStageRunId } from "../state/ids";
4
3
  import { warn } from "../shared/log";
5
4
  import { sha256Hex } from "../shared/hash";
5
+ import { SCHEMA_VERSION } from "../state/schema";
6
6
  export const EVENT_TYPES = {
7
7
  RUN_STARTED: "run.started",
8
8
  RUN_COMPLETED: "run.completed",
@@ -12,6 +12,17 @@ export const EVENT_TYPES = {
12
12
  STAGE_STARTED: "stage.started",
13
13
  WORKFLOW_PROCEED: "workflow.proceed",
14
14
  };
15
+ function tableExists(db, tableName) {
16
+ try {
17
+ const row = db
18
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
19
+ .get(tableName);
20
+ return row?.name === tableName;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
15
26
  export function getActiveRun(db) {
16
27
  const row = db
17
28
  .prepare("SELECT * FROM runs WHERE status = 'running' ORDER BY started_at DESC, created_at DESC LIMIT 1")
@@ -66,8 +77,7 @@ function getGenesisPlanningMode(config) {
66
77
  const raw = config?.workflow?.genesis_planning;
67
78
  if (raw === "off" || raw === "first_story_only" || raw === "always")
68
79
  return raw;
69
- if (raw != null)
70
- warn(`Invalid workflow.genesis_planning config: ${String(raw)}. Using default "first_story_only".`);
80
+ warn(`Invalid genesis_planning config: ${String(raw)}. Using default "first_story_only".`);
71
81
  return "first_story_only";
72
82
  }
73
83
  function shouldAttachPlanningDirective(config, story) {
@@ -79,6 +89,8 @@ function shouldAttachPlanningDirective(config, story) {
79
89
  return story.story_key === "S-0001";
80
90
  }
81
91
  function attachRunPlanningDirective(db, runId, story, pipeline) {
92
+ if (!tableExists(db, "injects"))
93
+ return;
82
94
  const now = nowISO();
83
95
  const injectId = `inj_${runId}_genesis_plan`;
84
96
  const body = [
@@ -96,16 +108,8 @@ function attachRunPlanningDirective(db, runId, story, pipeline) {
96
108
  `- Pipeline: ${pipeline.join(" → ")}`,
97
109
  ``,
98
110
  ].join("\n");
99
- let hash = null;
111
+ const hash = sha256Hex(body);
100
112
  try {
101
- hash = sha256Hex(body);
102
- }
103
- catch {
104
- // Hash is optional; directive must never be blocked by hashing.
105
- hash = null;
106
- }
107
- try {
108
- // Do not clobber user edits. If it exists, we leave it.
109
113
  db.prepare(`
110
114
  INSERT OR IGNORE INTO injects (
111
115
  inject_id, type, title, body_md, tags_json, scope, source, priority,
@@ -118,108 +122,95 @@ function attachRunPlanningDirective(db, runId, story, pipeline) {
118
122
  db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_GENESIS_PLANNING_ATTACHED, JSON.stringify({ story_key: story.story_key, inject_id: injectId }), now);
119
123
  }
120
124
  catch (e) {
121
- // Helpful, never required for correctness.
122
125
  warn("Failed to attach genesis planning inject", { run_id: runId, story_key: story.story_key, err: e });
123
126
  }
124
127
  }
125
- function updateRepoStateLastEvent(db, now, fields) {
126
- // Contract: repo_state row exists. If not, fail deterministically (don’t “bootstrap” here).
127
- const res = db
128
- .prepare(`
129
- UPDATE repo_state
130
- SET last_run_id = COALESCE(?, last_run_id),
131
- last_story_key = COALESCE(?, last_story_key),
132
- last_event_at = ?,
133
- updated_at = ?
134
- WHERE id = 1
135
- `)
136
- .run(fields.last_run_id ?? null, fields.last_story_key ?? null, now, now);
137
- if (!res || res.changes === 0) {
138
- throw new Error("repo_state missing (id=1). Database not initialized; run init must be performed before workflow.");
139
- }
140
- }
141
128
  export function createRunForStory(db, config, storyKey) {
142
- return withTx(db, () => {
143
- const story = getStory(db, storyKey);
144
- if (!story)
145
- throw new Error(`Story not found: ${storyKey}`);
146
- if (story.state !== "approved")
147
- throw new Error(`Story must be approved to run: ${storyKey} (state=${story.state})`);
148
- const run_id = newRunId();
149
- const now = nowISO();
150
- const pipeline = getPipelineFromConfig(config);
151
- db.prepare("UPDATE stories SET state='in_progress', in_progress=1, locked_by_run_id=?, locked_at=?, updated_at=? WHERE story_key=?").run(run_id, now, now, storyKey);
152
- db.prepare("INSERT INTO runs (run_id, story_key, status, pipeline_stages_json, current_stage_key, created_at, started_at, updated_at) VALUES (?, ?, 'running', ?, ?, ?, ?, ?)").run(run_id, storyKey, JSON.stringify(pipeline), pipeline[0] ?? null, now, now, now);
153
- const insertStage = db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)");
154
- pipeline.forEach((stageKey, idx) => {
155
- insertStage.run(newStageRunId(), run_id, stageKey, idx, now, now);
156
- });
157
- db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), run_id, EVENT_TYPES.RUN_STARTED, JSON.stringify({ story_key: storyKey, pipeline }), now);
158
- if (shouldAttachPlanningDirective(config, story)) {
159
- attachRunPlanningDirective(db, run_id, story, pipeline);
160
- }
161
- updateRepoStateLastEvent(db, now, { last_run_id: run_id, last_story_key: storyKey });
162
- return { run_id };
129
+ const story = getStory(db, storyKey);
130
+ if (!story)
131
+ throw new Error(`Story not found: ${storyKey}`);
132
+ if (story.state !== "approved")
133
+ throw new Error(`Story must be approved to run: ${storyKey} (state=${story.state})`);
134
+ const run_id = newRunId();
135
+ const now = nowISO();
136
+ const pipeline = getPipelineFromConfig(config);
137
+ db.prepare("UPDATE stories SET state='in_progress', in_progress=1, locked_by_run_id=?, locked_at=?, updated_at=? WHERE story_key=?").run(run_id, now, now, storyKey);
138
+ db.prepare("INSERT INTO runs (run_id, story_key, status, pipeline_stages_json, current_stage_key, created_at, started_at, updated_at) VALUES (?, ?, 'running', ?, ?, ?, ?, ?)").run(run_id, storyKey, JSON.stringify(pipeline), pipeline[0] ?? null, now, now, now);
139
+ const insertStage = db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)");
140
+ pipeline.forEach((stageKey, idx) => {
141
+ insertStage.run(newStageRunId(), run_id, stageKey, idx, now, now);
163
142
  });
143
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), run_id, EVENT_TYPES.RUN_STARTED, JSON.stringify({ story_key: storyKey, pipeline }), now);
144
+ if (shouldAttachPlanningDirective(config, story)) {
145
+ attachRunPlanningDirective(db, run_id, story, pipeline);
146
+ }
147
+ db.prepare(`
148
+ INSERT INTO repo_state (id, schema_version, created_at, updated_at, last_run_id, last_story_key, last_event_at)
149
+ VALUES (1, ?, ?, ?, ?, ?, ?)
150
+ ON CONFLICT(id) DO UPDATE SET
151
+ last_run_id=excluded.last_run_id,
152
+ last_story_key=excluded.last_story_key,
153
+ last_event_at=excluded.last_event_at,
154
+ updated_at=excluded.updated_at
155
+ `).run(SCHEMA_VERSION, now, now, now, run_id, storyKey, now);
156
+ return { run_id };
164
157
  }
165
- export function startStage(db, runId, stageKey, meta) {
166
- return withTx(db, () => {
167
- const now = nowISO();
168
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
169
- if (!run)
170
- throw new Error(`Run not found: ${runId}`);
171
- if (run.status !== "running")
172
- throw new Error(`Run is not running: ${runId} (status=${run.status})`);
173
- const stage = db.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?").get(runId, stageKey);
174
- if (!stage)
175
- throw new Error(`Stage run not found: ${runId}/${stageKey}`);
176
- if (stage.status !== "pending")
177
- throw new Error(`Stage is not pending: ${stageKey} (status=${stage.status})`);
178
- db.prepare("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=?").run(now, now, meta?.subagent_type ?? null, meta?.subagent_session_id ?? null, stage.stage_run_id);
179
- db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
180
- db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), runId, stageKey, EVENT_TYPES.STAGE_STARTED, JSON.stringify({ subagent_type: meta?.subagent_type ?? null }), now);
181
- updateRepoStateLastEvent(db, now, {});
182
- });
158
+ export function startStage(db, runId, stageKey, meta, emit) {
159
+ const now = nowISO();
160
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
161
+ if (!run)
162
+ throw new Error(`Run not found: ${runId}`);
163
+ if (run.status !== "running")
164
+ throw new Error(`Run is not running: ${runId} (status=${run.status})`);
165
+ const stage = db.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?").get(runId, stageKey);
166
+ if (!stage)
167
+ throw new Error(`Stage run not found: ${runId}/${stageKey}`);
168
+ if (stage.status !== "pending")
169
+ throw new Error(`Stage is not pending: ${stageKey} (status=${stage.status})`);
170
+ db.prepare("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=?").run(now, now, meta?.subagent_type ?? null, meta?.subagent_session_id ?? null, stage.stage_run_id);
171
+ db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
172
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), runId, stageKey, EVENT_TYPES.STAGE_STARTED, JSON.stringify({ subagent_type: meta?.subagent_type ?? null }), now);
173
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
174
+ // ✅ Explicit wiring point (requested): stage movement
175
+ emit?.({ kind: "stage_started", run_id: runId, stage_key: stageKey, agent_name: meta?.subagent_type });
183
176
  }
184
- export function completeRun(db, runId) {
185
- return withTx(db, () => {
186
- const now = nowISO();
187
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
188
- if (!run)
189
- throw new Error(`Run not found: ${runId}`);
190
- if (run.status !== "running")
191
- throw new Error(`Run not running: ${runId} (status=${run.status})`);
192
- const stageRuns = getStageRuns(db, runId);
193
- const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
194
- if (incomplete)
195
- throw new Error(`Cannot complete run: stage ${incomplete.stage_key} is ${incomplete.status}`);
196
- db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
197
- db.prepare("UPDATE stories SET state='done', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
198
- db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_COMPLETED, JSON.stringify({ story_key: run.story_key }), now);
199
- updateRepoStateLastEvent(db, now, {});
200
- });
177
+ export function completeRun(db, runId, emit) {
178
+ const now = nowISO();
179
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
180
+ if (!run)
181
+ throw new Error(`Run not found: ${runId}`);
182
+ if (run.status !== "running")
183
+ throw new Error(`Run not running: ${runId} (status=${run.status})`);
184
+ const stageRuns = getStageRuns(db, runId);
185
+ const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
186
+ if (incomplete)
187
+ throw new Error(`Cannot complete run: stage ${incomplete.stage_key} is ${incomplete.status}`);
188
+ db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
189
+ db.prepare("UPDATE stories SET state='done', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
190
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_COMPLETED, JSON.stringify({ story_key: run.story_key }), now);
191
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
192
+ // ✅ Explicit wiring point (requested): run closed success
193
+ emit?.({ kind: "run_completed", run_id: runId, story_key: run.story_key });
201
194
  }
202
- export function failRun(db, runId, stageKey, errorText) {
203
- return withTx(db, () => {
204
- const now = nowISO();
205
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
206
- if (!run)
207
- throw new Error(`Run not found: ${runId}`);
208
- db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
209
- db.prepare("UPDATE stories SET state='blocked', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
210
- db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), runId, stageKey, EVENT_TYPES.RUN_FAILED, JSON.stringify({ error_text: errorText }), now);
211
- updateRepoStateLastEvent(db, now, {});
212
- });
195
+ export function failRun(db, runId, stageKey, errorText, emit) {
196
+ const now = nowISO();
197
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
198
+ if (!run)
199
+ throw new Error(`Run not found: ${runId}`);
200
+ db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
201
+ db.prepare("UPDATE stories SET state='blocked', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
202
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), runId, stageKey, EVENT_TYPES.RUN_FAILED, JSON.stringify({ error_text: errorText }), now);
203
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
204
+ // ✅ Explicit wiring point (requested): run closed failure
205
+ emit?.({ kind: "run_failed", run_id: runId, story_key: run.story_key, stage_key: stageKey, error_text: errorText });
213
206
  }
214
207
  export function abortRun(db, runId, reason) {
215
- return withTx(db, () => {
216
- const now = nowISO();
217
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
218
- if (!run)
219
- throw new Error(`Run not found: ${runId}`);
220
- db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
221
- db.prepare("UPDATE stories SET state='approved', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
222
- db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_ABORTED, JSON.stringify({ reason }), now);
223
- updateRepoStateLastEvent(db, now, {});
224
- });
208
+ const now = nowISO();
209
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
210
+ if (!run)
211
+ throw new Error(`Run not found: ${runId}`);
212
+ db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
213
+ db.prepare("UPDATE stories SET state='approved', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
214
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_ABORTED, JSON.stringify({ reason }), now);
215
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
225
216
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astrocode-workflow",
3
- "version": "0.1.59",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/state/db.ts CHANGED
@@ -48,7 +48,32 @@ export function configurePragmas(db: SqliteDb, pragmas: Record<string, any>) {
48
48
  if (pragmas.temp_store) db.pragma(`temp_store = ${pragmas.temp_store}`);
49
49
  }
50
50
 
51
- /** BEGIN IMMEDIATE transaction helper. */
51
+ /**
52
+ * Re-entrant transaction helper.
53
+ *
54
+ * SQLite rejects BEGIN inside BEGIN. We use:
55
+ * - depth=0: BEGIN IMMEDIATE ... COMMIT/ROLLBACK
56
+ * - depth>0: SAVEPOINT sp_n ... RELEASE / ROLLBACK TO + RELEASE
57
+ *
58
+ * This allows callers to safely nest withTx across layers (tools -> workflow -> state machine)
59
+ * without "cannot start a transaction within a transaction".
60
+ */
61
+ const TX_DEPTH = new WeakMap<object, number>();
62
+
63
+ function getDepth(db: SqliteDb): number {
64
+ return TX_DEPTH.get(db as any) ?? 0;
65
+ }
66
+
67
+ function setDepth(db: SqliteDb, depth: number) {
68
+ if (depth <= 0) TX_DEPTH.delete(db as any);
69
+ else TX_DEPTH.set(db as any, depth);
70
+ }
71
+
72
+ function savepointName(depth: number): string {
73
+ return `sp_${depth}`;
74
+ }
75
+
76
+ /** BEGIN IMMEDIATE transaction helper (re-entrant). */
52
77
  export function withTx<T>(db: SqliteDb, fn: () => T, opts?: { require?: boolean }): T {
53
78
  const adapter = createDatabaseAdapter();
54
79
  const available = adapter.isAvailable();
@@ -58,18 +83,52 @@ export function withTx<T>(db: SqliteDb, fn: () => T, opts?: { require?: boolean
58
83
  return fn();
59
84
  }
60
85
 
61
- db.exec("BEGIN IMMEDIATE");
86
+ const depth = getDepth(db);
87
+
88
+ if (depth === 0) {
89
+ db.exec("BEGIN IMMEDIATE");
90
+ setDepth(db, 1);
91
+ try {
92
+ const out = fn();
93
+ db.exec("COMMIT");
94
+ return out;
95
+ } catch (e) {
96
+ try {
97
+ db.exec("ROLLBACK");
98
+ } catch {
99
+ // ignore
100
+ }
101
+ throw e;
102
+ } finally {
103
+ setDepth(db, 0);
104
+ }
105
+ }
106
+
107
+ // Nested: use SAVEPOINT
108
+ const nextDepth = depth + 1;
109
+ const sp = savepointName(nextDepth);
110
+
111
+ db.exec(`SAVEPOINT ${sp}`);
112
+ setDepth(db, nextDepth);
113
+
62
114
  try {
63
115
  const out = fn();
64
- db.exec("COMMIT");
116
+ db.exec(`RELEASE SAVEPOINT ${sp}`);
65
117
  return out;
66
118
  } catch (e) {
67
119
  try {
68
- db.exec("ROLLBACK");
120
+ db.exec(`ROLLBACK TO SAVEPOINT ${sp}`);
121
+ } catch {
122
+ // ignore
123
+ }
124
+ try {
125
+ db.exec(`RELEASE SAVEPOINT ${sp}`);
69
126
  } catch {
70
127
  // ignore
71
128
  }
72
129
  throw e;
130
+ } finally {
131
+ setDepth(db, depth);
73
132
  }
74
133
  }
75
134