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.
- package/dist/state/db.d.ts +1 -1
- package/dist/state/db.js +62 -4
- package/dist/tools/injects.js +90 -26
- package/dist/tools/workflow.d.ts +1 -1
- package/dist/tools/workflow.js +106 -101
- package/dist/ui/inject.d.ts +7 -1
- package/dist/ui/inject.js +86 -38
- package/dist/workflow/state-machine.d.ts +25 -9
- package/dist/workflow/state-machine.js +97 -106
- package/package.json +1 -1
- package/src/state/db.ts +63 -4
- package/src/tools/injects.ts +147 -53
- package/src/tools/workflow.ts +147 -140
- package/src/ui/inject.ts +107 -40
- package/src/workflow/state-machine.ts +127 -137
|
@@ -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
|
-
*
|
|
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
|
|
44
|
+
* Deterministic trigger:
|
|
30
45
|
* - config.workflow.genesis_planning:
|
|
31
46
|
* - "off" => never attach directive
|
|
32
|
-
* - "first_story_only"=>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
195
|
+
const run_id = newRunId();
|
|
196
|
+
const now = nowISO();
|
|
197
|
+
const pipeline = getPipelineFromConfig(config);
|
|
218
198
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
218
|
+
if (shouldAttachPlanningDirective(config, story)) {
|
|
219
|
+
attachRunPlanningDirective(db, run_id, story, pipeline);
|
|
220
|
+
}
|
|
239
221
|
|
|
240
|
-
|
|
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
|
-
|
|
251
|
-
const now = nowISO();
|
|
242
|
+
const now = nowISO();
|
|
252
243
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
256
|
+
db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
|
|
266
257
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
340
|
-
});
|
|
330
|
+
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
341
331
|
}
|