astrocode-workflow 0.1.58 → 0.2.0
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/README.md +243 -11
- package/dist/agents/prompts.d.ts +1 -0
- package/dist/agents/prompts.js +159 -0
- package/dist/agents/registry.js +11 -1
- package/dist/config/loader.js +34 -0
- package/dist/config/schema.d.ts +7 -1
- package/dist/config/schema.js +2 -0
- package/dist/hooks/continuation-enforcer.d.ts +9 -1
- package/dist/hooks/continuation-enforcer.js +2 -1
- package/dist/hooks/inject-provider.d.ts +9 -1
- package/dist/hooks/inject-provider.js +2 -1
- package/dist/hooks/tool-output-truncator.d.ts +9 -1
- package/dist/hooks/tool-output-truncator.js +2 -1
- package/dist/index.js +228 -45
- package/dist/state/adapters/index.d.ts +4 -2
- package/dist/state/adapters/index.js +23 -27
- package/dist/state/db.d.ts +6 -8
- package/dist/state/db.js +106 -45
- package/dist/tools/index.d.ts +13 -3
- package/dist/tools/index.js +14 -31
- package/dist/tools/init.d.ts +10 -1
- package/dist/tools/init.js +73 -18
- package/dist/tools/injects.js +90 -26
- package/dist/tools/spec.d.ts +0 -1
- package/dist/tools/spec.js +4 -1
- package/dist/tools/status.d.ts +1 -1
- package/dist/tools/status.js +70 -52
- package/dist/tools/workflow.js +2 -2
- package/dist/ui/inject.d.ts +16 -2
- package/dist/ui/inject.js +104 -33
- package/dist/workflow/directives.d.ts +2 -0
- package/dist/workflow/directives.js +34 -19
- package/dist/workflow/state-machine.d.ts +46 -3
- package/dist/workflow/state-machine.js +249 -92
- package/package.json +1 -1
- package/src/agents/prompts.ts +160 -0
- package/src/agents/registry.ts +16 -1
- package/src/config/loader.ts +39 -4
- package/src/config/schema.ts +3 -0
- package/src/hooks/continuation-enforcer.ts +9 -2
- package/src/hooks/inject-provider.ts +9 -2
- package/src/hooks/tool-output-truncator.ts +9 -2
- package/src/index.ts +260 -56
- package/src/state/adapters/index.ts +21 -26
- package/src/state/db.ts +114 -58
- package/src/tools/index.ts +29 -31
- package/src/tools/init.ts +91 -22
- package/src/tools/injects.ts +147 -53
- package/src/tools/spec.ts +6 -2
- package/src/tools/status.ts +71 -55
- package/src/tools/workflow.ts +3 -3
- package/src/ui/inject.ts +115 -41
- package/src/workflow/directives.ts +103 -75
- package/src/workflow/state-machine.ts +327 -109
|
@@ -1,12 +1,86 @@
|
|
|
1
|
+
// src/workflow/state-machine.ts
|
|
1
2
|
import type { AstrocodeConfig } from "../config/schema";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
3
|
import type { SqliteDb } from "../state/db";
|
|
4
|
+
import { withTx } from "../state/db";
|
|
5
5
|
import type { RunRow, StageKey, StageRunRow, StoryRow } from "../state/types";
|
|
6
6
|
import { nowISO } from "../shared/time";
|
|
7
7
|
import { newEventId, newRunId, newStageRunId } from "../state/ids";
|
|
8
8
|
import { warn } from "../shared/log";
|
|
9
|
+
import { sha256Hex } from "../shared/hash";
|
|
10
|
+
import { SCHEMA_VERSION } from "../state/schema";
|
|
11
|
+
import type { ToastOptions } from "../ui/toasts";
|
|
12
|
+
import { injectChatPrompt } from "../ui/inject";
|
|
13
|
+
|
|
14
|
+
export const EVENT_TYPES = {
|
|
15
|
+
RUN_STARTED: "run.started",
|
|
16
|
+
RUN_COMPLETED: "run.completed",
|
|
17
|
+
RUN_FAILED: "run.failed",
|
|
18
|
+
RUN_ABORTED: "run.aborted",
|
|
19
|
+
RUN_GENESIS_PLANNING_ATTACHED: "run.genesis_planning_attached",
|
|
20
|
+
STAGE_STARTED: "stage.started",
|
|
21
|
+
WORKFLOW_PROCEED: "workflow.proceed",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
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
|
+
}
|
|
9
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
|
+
function tableExists(db: SqliteDb, tableName: string): boolean {
|
|
66
|
+
try {
|
|
67
|
+
const row = db
|
|
68
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
|
|
69
|
+
.get(tableName) as { name?: string } | undefined;
|
|
70
|
+
return row?.name === tableName;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* PLANNING-FIRST REDESIGN
|
|
78
|
+
* ----------------------
|
|
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"
|
|
83
|
+
*/
|
|
10
84
|
export type NextAction =
|
|
11
85
|
| { kind: "idle"; reason: "no_approved_stories" }
|
|
12
86
|
| { kind: "start_run"; story_key: string }
|
|
@@ -53,9 +127,7 @@ export function decideNextAction(db: SqliteDb, config: AstrocodeConfig): NextAct
|
|
|
53
127
|
const stageRuns = getStageRuns(db, activeRun.run_id);
|
|
54
128
|
const current = getCurrentStageRun(stageRuns);
|
|
55
129
|
|
|
56
|
-
if (!current) {
|
|
57
|
-
return { kind: "complete_run", run_id: activeRun.run_id };
|
|
58
|
-
}
|
|
130
|
+
if (!current) return { kind: "complete_run", run_id: activeRun.run_id };
|
|
59
131
|
|
|
60
132
|
if (current.status === "pending") {
|
|
61
133
|
return { kind: "delegate_stage", run_id: activeRun.run_id, stage_key: current.stage_key, stage_run_id: current.stage_run_id };
|
|
@@ -69,149 +141,295 @@ export function decideNextAction(db: SqliteDb, config: AstrocodeConfig): NextAct
|
|
|
69
141
|
return { kind: "failed", run_id: activeRun.run_id, stage_key: current.stage_key, error_text: current.error_text ?? "stage failed" };
|
|
70
142
|
}
|
|
71
143
|
|
|
72
|
-
|
|
73
|
-
warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.stage_key });
|
|
144
|
+
warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.status });
|
|
74
145
|
return { kind: "await_stage_completion", run_id: activeRun.run_id, stage_key: current.stage_key, stage_run_id: current.stage_run_id };
|
|
75
146
|
}
|
|
76
147
|
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
148
|
+
function getPipelineFromConfig(config: AstrocodeConfig): StageKey[] {
|
|
149
|
+
const pipeline = (config as any)?.workflow?.pipeline as StageKey[] | undefined;
|
|
150
|
+
if (!Array.isArray(pipeline) || pipeline.length === 0) {
|
|
151
|
+
throw new Error("Invalid config: workflow.pipeline must be a non-empty array of stage keys.");
|
|
152
|
+
}
|
|
153
|
+
return pipeline;
|
|
81
154
|
}
|
|
82
155
|
|
|
83
|
-
|
|
84
|
-
const story = getStory(db, storyKey);
|
|
85
|
-
if (!story) throw new Error(`Story not found: ${storyKey}`);
|
|
86
|
-
if (story.state !== "approved") throw new Error(`Story must be approved to run: ${storyKey} (state=${story.state})`);
|
|
156
|
+
type GenesisPlanningMode = "off" | "first_story_only" | "always";
|
|
87
157
|
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
158
|
+
function getGenesisPlanningMode(config: AstrocodeConfig): GenesisPlanningMode {
|
|
159
|
+
const raw = (config as any)?.workflow?.genesis_planning;
|
|
160
|
+
if (raw === "off" || raw === "first_story_only" || raw === "always") return raw;
|
|
161
|
+
warn(`Invalid genesis_planning config: ${String(raw)}. Using default "first_story_only".`);
|
|
162
|
+
return "first_story_only";
|
|
163
|
+
}
|
|
91
164
|
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
165
|
+
function shouldAttachPlanningDirective(config: AstrocodeConfig, story: StoryRow): boolean {
|
|
166
|
+
const mode = getGenesisPlanningMode(config);
|
|
167
|
+
if (mode === "off") return false;
|
|
168
|
+
if (mode === "always") return true;
|
|
169
|
+
return story.story_key === "S-0001";
|
|
170
|
+
}
|
|
96
171
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const alreadyDecomposed = existingStoriesCount.count > 10; // Arbitrary threshold
|
|
172
|
+
function attachRunPlanningDirective(db: SqliteDb, runId: string, story: StoryRow, pipeline: StageKey[]) {
|
|
173
|
+
if (!tableExists(db, "injects")) return;
|
|
100
174
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
175
|
+
const now = nowISO();
|
|
176
|
+
const injectId = `inj_${runId}_genesis_plan`;
|
|
177
|
+
|
|
178
|
+
const body = [
|
|
179
|
+
`# Genesis planning directive`,
|
|
180
|
+
``,
|
|
181
|
+
`This run is configured to perform a planning/decomposition pass before implementation.`,
|
|
182
|
+
`Do not edit the origin story title/body. Create additional stories instead.`,
|
|
183
|
+
``,
|
|
184
|
+
`## Required output`,
|
|
185
|
+
`- Produce 50–200 granular implementation stories with clear acceptance criteria.`,
|
|
186
|
+
`- Each story: single focused change, explicit done conditions, dependencies listed.`,
|
|
187
|
+
``,
|
|
188
|
+
`## Context`,
|
|
189
|
+
`- Origin story: ${story.story_key} — ${story.title ?? ""}`,
|
|
190
|
+
`- Pipeline: ${pipeline.join(" → ")}`,
|
|
191
|
+
``,
|
|
192
|
+
].join("\n");
|
|
193
|
+
|
|
194
|
+
const hash = sha256Hex(body);
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
db.prepare(
|
|
198
|
+
`
|
|
199
|
+
INSERT OR IGNORE INTO injects (
|
|
200
|
+
inject_id, type, title, body_md, tags_json, scope, source, priority,
|
|
201
|
+
expires_at, sha256, created_at, updated_at
|
|
202
|
+
) VALUES (
|
|
203
|
+
?, 'note', ?, ?, '["genesis","planning","decompose"]', ?, 'tool', 100,
|
|
204
|
+
NULL, ?, ?, ?
|
|
205
|
+
)
|
|
206
|
+
`
|
|
207
|
+
).run(injectId, "Genesis planning: decompose into stories", body, `run:${runId}`, hash, now, now);
|
|
208
|
+
|
|
209
|
+
db.prepare(
|
|
210
|
+
"INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
|
|
211
|
+
).run(newEventId(), runId, EVENT_TYPES.RUN_GENESIS_PLANNING_ATTACHED, JSON.stringify({ story_key: story.story_key, inject_id: injectId }), now);
|
|
212
|
+
} catch (e) {
|
|
213
|
+
warn("Failed to attach genesis planning inject", { run_id: runId, story_key: story.story_key, err: e });
|
|
105
214
|
}
|
|
215
|
+
}
|
|
106
216
|
|
|
107
|
-
|
|
108
|
-
db
|
|
109
|
-
|
|
110
|
-
|
|
217
|
+
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})`);
|
|
222
|
+
|
|
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);
|
|
230
|
+
|
|
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);
|
|
234
|
+
|
|
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
|
+
});
|
|
241
|
+
|
|
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 };
|
|
261
|
+
});
|
|
262
|
+
}
|
|
111
263
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
264
|
+
/**
|
|
265
|
+
* STAGE MOVEMENT (START) — now async so UI injection is deterministic.
|
|
266
|
+
*/
|
|
267
|
+
export async function startStage(
|
|
268
|
+
db: SqliteDb,
|
|
269
|
+
runId: string,
|
|
270
|
+
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})`);
|
|
284
|
+
|
|
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);
|
|
288
|
+
|
|
289
|
+
db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
|
|
290
|
+
|
|
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);
|
|
294
|
+
|
|
295
|
+
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
296
|
+
|
|
297
|
+
const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key) as any;
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
now,
|
|
301
|
+
story_key: run.story_key,
|
|
302
|
+
story_title: story?.title ?? "",
|
|
303
|
+
};
|
|
304
|
+
});
|
|
115
305
|
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
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
|
+
}
|
|
119
321
|
);
|
|
322
|
+
}
|
|
120
323
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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();
|
|
124
330
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
).run(newEventId(), run_id, JSON.stringify({ story_key: storyKey, pipeline }), now);
|
|
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})`);
|
|
129
334
|
|
|
130
|
-
|
|
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}`);
|
|
131
338
|
|
|
132
|
-
|
|
133
|
-
}
|
|
339
|
+
db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
|
|
134
340
|
|
|
135
|
-
|
|
136
|
-
|
|
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);
|
|
137
344
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (run.status !== "running") throw new Error(`Run is not running: ${runId} (status=${run.status})`);
|
|
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);
|
|
142
348
|
|
|
143
|
-
|
|
144
|
-
.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?")
|
|
145
|
-
.get(runId, stageKey) as StageRunRow | undefined;
|
|
146
|
-
if (!stage) throw new Error(`Stage run not found: ${runId}/${stageKey}`);
|
|
147
|
-
if (stage.status !== "pending") throw new Error(`Stage is not pending: ${stageKey} (status=${stage.status})`);
|
|
349
|
+
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
148
350
|
|
|
149
|
-
|
|
150
|
-
"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=?"
|
|
151
|
-
).run(now, now, meta?.subagent_type ?? null, meta?.subagent_session_id ?? null, stage.stage_run_id);
|
|
351
|
+
const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key) as any;
|
|
152
352
|
|
|
153
|
-
|
|
353
|
+
return { now, story_key: run.story_key, story_title: story?.title ?? "" };
|
|
354
|
+
});
|
|
154
355
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
);
|
|
158
370
|
}
|
|
159
371
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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();
|
|
165
378
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
|
|
169
|
-
if (incomplete) throw new Error(`Cannot complete run: stage ${incomplete.stage_key} is ${incomplete.status}`);
|
|
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}`);
|
|
170
381
|
|
|
171
|
-
|
|
172
|
-
db.prepare(
|
|
173
|
-
"UPDATE stories SET state='done', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?"
|
|
174
|
-
).run(now, run.story_key);
|
|
382
|
+
db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
|
|
175
383
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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);
|
|
179
387
|
|
|
180
|
-
|
|
181
|
-
|
|
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);
|
|
182
391
|
|
|
183
|
-
|
|
184
|
-
const now = nowISO();
|
|
185
|
-
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
|
|
186
|
-
if (!run) throw new Error(`Run not found: ${runId}`);
|
|
392
|
+
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
187
393
|
|
|
188
|
-
|
|
189
|
-
db.prepare(
|
|
190
|
-
"UPDATE stories SET state='blocked', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?"
|
|
191
|
-
).run(now, run.story_key);
|
|
394
|
+
const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key) as any;
|
|
192
395
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
).run(newEventId(), runId, stageKey, JSON.stringify({ error_text: errorText }), now);
|
|
396
|
+
return { now, story_key: run.story_key, story_title: story?.title ?? "" };
|
|
397
|
+
});
|
|
196
398
|
|
|
197
|
-
|
|
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
|
+
);
|
|
198
415
|
}
|
|
199
416
|
|
|
200
417
|
export function abortRun(db: SqliteDb, runId: string, reason: string) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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}`);
|
|
204
422
|
|
|
205
|
-
|
|
423
|
+
db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
|
|
206
424
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
).run(now, run.story_key);
|
|
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);
|
|
211
428
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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);
|
|
215
432
|
|
|
216
|
-
|
|
433
|
+
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
434
|
+
});
|
|
217
435
|
}
|