astrocode-workflow 0.1.59 → 0.3.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/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 +114 -94
- package/dist/ui/inject.d.ts +1 -1
- package/dist/ui/inject.js +74 -33
- package/dist/workflow/state-machine.d.ts +33 -17
- package/dist/workflow/state-machine.js +116 -40
- package/package.json +1 -1
- package/src/state/db.ts +63 -4
- package/src/tools/injects.ts +147 -53
- package/src/tools/workflow.ts +155 -136
- package/src/tools/workflow.ts.backup +681 -0
- package/src/ui/inject.ts +86 -41
- package/src/workflow/state-machine.ts +161 -67
package/src/ui/inject.ts
CHANGED
|
@@ -1,62 +1,107 @@
|
|
|
1
|
+
// src/ui/inject.ts
|
|
2
|
+
|
|
1
3
|
let isInjecting = false;
|
|
4
|
+
|
|
2
5
|
const injectionQueue: Array<{
|
|
3
6
|
ctx: any;
|
|
4
|
-
sessionId
|
|
7
|
+
sessionId?: string;
|
|
5
8
|
text: string;
|
|
6
9
|
agent?: string;
|
|
10
|
+
toast?: { title: string; message: string; variant?: "info" | "success" | "warning" | "error"; durationMs?: number };
|
|
11
|
+
retry?: { maxAttempts?: number; baseDelayMs?: number };
|
|
7
12
|
}> = [];
|
|
8
13
|
|
|
9
|
-
|
|
10
|
-
|
|
14
|
+
function sleep(ms: number) {
|
|
15
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
}
|
|
11
17
|
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
}
|
|
14
24
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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) {
|
|
31
|
+
throw new Error("API not available (ctx.client.session.prompt)");
|
|
18
32
|
}
|
|
19
33
|
|
|
34
|
+
const prefixedText = `[${agent}]\n\n${text}`;
|
|
35
|
+
|
|
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
|
+
}
|
|
44
|
+
|
|
45
|
+
async function processQueue() {
|
|
46
|
+
if (isInjecting) return;
|
|
47
|
+
if (injectionQueue.length === 0) return;
|
|
48
|
+
|
|
49
|
+
isInjecting = true;
|
|
50
|
+
|
|
20
51
|
try {
|
|
21
|
-
|
|
22
|
-
|
|
52
|
+
while (injectionQueue.length > 0) {
|
|
53
|
+
const item = injectionQueue.shift();
|
|
54
|
+
if (!item) continue;
|
|
23
55
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
console.warn("[Astrocode] Skipping injection: No sessionId provided");
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
56
|
+
const { ctx, text, agent = "Astro" } = item;
|
|
57
|
+
const sessionId = resolveSessionId(ctx, item.sessionId);
|
|
29
58
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
34
85
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
agent: agent,
|
|
41
|
-
},
|
|
42
|
-
});
|
|
43
|
-
} catch (error) {
|
|
44
|
-
console.warn(`[Astrocode] Injection failed: ${error}`);
|
|
86
|
+
if (lastErr) {
|
|
87
|
+
// eslint-disable-next-line no-console
|
|
88
|
+
console.warn(`[Astrocode] Injection failed permanently after ${maxAttempts} attempts: ${String(lastErr)}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
45
91
|
} finally {
|
|
46
92
|
isInjecting = false;
|
|
47
|
-
// Process next item immediately
|
|
48
|
-
if (injectionQueue.length > 0) {
|
|
49
|
-
setImmediate(processQueue);
|
|
50
|
-
}
|
|
51
93
|
}
|
|
52
94
|
}
|
|
53
95
|
|
|
54
|
-
export async function injectChatPrompt(opts: {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
});
|
|
104
|
+
|
|
105
|
+
// Fire-and-forget; queue drain is serialized by isInjecting.
|
|
106
|
+
void processQueue();
|
|
62
107
|
}
|
|
@@ -7,6 +7,9 @@ import { nowISO } from "../shared/time";
|
|
|
7
7
|
import { newEventId, newRunId, newStageRunId } from "../state/ids";
|
|
8
8
|
import { warn } from "../shared/log";
|
|
9
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";
|
|
10
13
|
|
|
11
14
|
export const EVENT_TYPES = {
|
|
12
15
|
RUN_STARTED: "run.started",
|
|
@@ -19,24 +22,65 @@ export const EVENT_TYPES = {
|
|
|
19
22
|
} as const;
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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>).
|
|
28
|
-
*
|
|
29
|
-
* Deterministic trigger (config-driven):
|
|
30
|
-
* - config.workflow.genesis_planning:
|
|
31
|
-
* - "off" => never attach directive
|
|
32
|
-
* - "first_story_only"=> attach only when story_key === "S-0001"
|
|
33
|
-
* - "always" => attach for every run
|
|
25
|
+
* UI HOOKS
|
|
26
|
+
* --------
|
|
27
|
+
* This workflow module is DB-first. UI emission is optional and happens AFTER the DB tx commits.
|
|
34
28
|
*
|
|
35
|
-
* Contract:
|
|
36
|
-
* -
|
|
37
|
-
* -
|
|
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).
|
|
38
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
|
+
}
|
|
39
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
|
+
*/
|
|
40
84
|
export type NextAction =
|
|
41
85
|
| { kind: "idle"; reason: "no_approved_stories" }
|
|
42
86
|
| { kind: "start_run"; story_key: string }
|
|
@@ -97,7 +141,7 @@ export function decideNextAction(db: SqliteDb, config: AstrocodeConfig): NextAct
|
|
|
97
141
|
return { kind: "failed", run_id: activeRun.run_id, stage_key: current.stage_key, error_text: current.error_text ?? "stage failed" };
|
|
98
142
|
}
|
|
99
143
|
|
|
100
|
-
warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.
|
|
144
|
+
warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.status });
|
|
101
145
|
return { kind: "await_stage_completion", run_id: activeRun.run_id, stage_key: current.stage_key, stage_run_id: current.stage_run_id };
|
|
102
146
|
}
|
|
103
147
|
|
|
@@ -114,7 +158,7 @@ type GenesisPlanningMode = "off" | "first_story_only" | "always";
|
|
|
114
158
|
function getGenesisPlanningMode(config: AstrocodeConfig): GenesisPlanningMode {
|
|
115
159
|
const raw = (config as any)?.workflow?.genesis_planning;
|
|
116
160
|
if (raw === "off" || raw === "first_story_only" || raw === "always") return raw;
|
|
117
|
-
|
|
161
|
+
warn(`Invalid genesis_planning config: ${String(raw)}. Using default "first_story_only".`);
|
|
118
162
|
return "first_story_only";
|
|
119
163
|
}
|
|
120
164
|
|
|
@@ -126,6 +170,8 @@ function shouldAttachPlanningDirective(config: AstrocodeConfig, story: StoryRow)
|
|
|
126
170
|
}
|
|
127
171
|
|
|
128
172
|
function attachRunPlanningDirective(db: SqliteDb, runId: string, story: StoryRow, pipeline: StageKey[]) {
|
|
173
|
+
if (!tableExists(db, "injects")) return;
|
|
174
|
+
|
|
129
175
|
const now = nowISO();
|
|
130
176
|
const injectId = `inj_${runId}_genesis_plan`;
|
|
131
177
|
|
|
@@ -145,16 +191,9 @@ function attachRunPlanningDirective(db: SqliteDb, runId: string, story: StoryRow
|
|
|
145
191
|
``,
|
|
146
192
|
].join("\n");
|
|
147
193
|
|
|
148
|
-
|
|
149
|
-
try {
|
|
150
|
-
hash = sha256Hex(body);
|
|
151
|
-
} catch {
|
|
152
|
-
// Hash is optional; directive must never be blocked by hashing.
|
|
153
|
-
hash = null;
|
|
154
|
-
}
|
|
194
|
+
const hash = sha256Hex(body);
|
|
155
195
|
|
|
156
196
|
try {
|
|
157
|
-
// Do not clobber user edits. If it exists, we leave it.
|
|
158
197
|
db.prepare(
|
|
159
198
|
`
|
|
160
199
|
INSERT OR IGNORE INTO injects (
|
|
@@ -169,39 +208,12 @@ function attachRunPlanningDirective(db: SqliteDb, runId: string, story: StoryRow
|
|
|
169
208
|
|
|
170
209
|
db.prepare(
|
|
171
210
|
"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
|
-
);
|
|
211
|
+
).run(newEventId(), runId, EVENT_TYPES.RUN_GENESIS_PLANNING_ATTACHED, JSON.stringify({ story_key: story.story_key, inject_id: injectId }), now);
|
|
179
212
|
} catch (e) {
|
|
180
|
-
// Helpful, never required for correctness.
|
|
181
213
|
warn("Failed to attach genesis planning inject", { run_id: runId, story_key: story.story_key, err: e });
|
|
182
214
|
}
|
|
183
215
|
}
|
|
184
216
|
|
|
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
217
|
export function createRunForStory(db: SqliteDb, config: AstrocodeConfig, storyKey: string): { run_id: string } {
|
|
206
218
|
return withTx(db, () => {
|
|
207
219
|
const story = getStory(db, storyKey);
|
|
@@ -235,19 +247,31 @@ export function createRunForStory(db: SqliteDb, config: AstrocodeConfig, storyKe
|
|
|
235
247
|
attachRunPlanningDirective(db, run_id, story, pipeline);
|
|
236
248
|
}
|
|
237
249
|
|
|
238
|
-
|
|
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);
|
|
239
259
|
|
|
240
260
|
return { run_id };
|
|
241
261
|
});
|
|
242
262
|
}
|
|
243
263
|
|
|
244
|
-
|
|
264
|
+
/**
|
|
265
|
+
* STAGE MOVEMENT (START) — now async so UI injection is deterministic.
|
|
266
|
+
*/
|
|
267
|
+
export async function startStage(
|
|
245
268
|
db: SqliteDb,
|
|
246
269
|
runId: string,
|
|
247
270
|
stageKey: StageKey,
|
|
248
|
-
meta?: { subagent_type?: string; subagent_session_id?: string }
|
|
249
|
-
) {
|
|
250
|
-
|
|
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, () => {
|
|
251
275
|
const now = nowISO();
|
|
252
276
|
|
|
253
277
|
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
|
|
@@ -268,12 +292,40 @@ export function startStage(
|
|
|
268
292
|
"INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
269
293
|
).run(newEventId(), runId, stageKey, EVENT_TYPES.STAGE_STARTED, JSON.stringify({ subagent_type: meta?.subagent_type ?? null }), now);
|
|
270
294
|
|
|
271
|
-
|
|
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
|
+
};
|
|
272
304
|
});
|
|
305
|
+
|
|
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
|
+
);
|
|
273
322
|
}
|
|
274
323
|
|
|
275
|
-
|
|
276
|
-
|
|
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, () => {
|
|
277
329
|
const now = nowISO();
|
|
278
330
|
|
|
279
331
|
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
|
|
@@ -294,12 +346,34 @@ export function completeRun(db: SqliteDb, runId: string) {
|
|
|
294
346
|
"INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
|
|
295
347
|
).run(newEventId(), runId, EVENT_TYPES.RUN_COMPLETED, JSON.stringify({ story_key: run.story_key }), now);
|
|
296
348
|
|
|
297
|
-
|
|
349
|
+
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
350
|
+
|
|
351
|
+
const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key) as any;
|
|
352
|
+
|
|
353
|
+
return { now, story_key: run.story_key, story_title: story?.title ?? "" };
|
|
298
354
|
});
|
|
355
|
+
|
|
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
|
+
);
|
|
299
370
|
}
|
|
300
371
|
|
|
301
|
-
|
|
302
|
-
|
|
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, () => {
|
|
303
377
|
const now = nowISO();
|
|
304
378
|
|
|
305
379
|
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
|
|
@@ -315,14 +389,34 @@ export function failRun(db: SqliteDb, runId: string, stageKey: StageKey, errorTe
|
|
|
315
389
|
"INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
316
390
|
).run(newEventId(), runId, stageKey, EVENT_TYPES.RUN_FAILED, JSON.stringify({ error_text: errorText }), now);
|
|
317
391
|
|
|
318
|
-
|
|
392
|
+
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
393
|
+
|
|
394
|
+
const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key) as any;
|
|
395
|
+
|
|
396
|
+
return { now, story_key: run.story_key, story_title: story?.title ?? "" };
|
|
319
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
|
+
);
|
|
320
415
|
}
|
|
321
416
|
|
|
322
417
|
export function abortRun(db: SqliteDb, runId: string, reason: string) {
|
|
323
418
|
return withTx(db, () => {
|
|
324
419
|
const now = nowISO();
|
|
325
|
-
|
|
326
420
|
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
|
|
327
421
|
if (!run) throw new Error(`Run not found: ${runId}`);
|
|
328
422
|
|
|
@@ -336,6 +430,6 @@ export function abortRun(db: SqliteDb, runId: string, reason: string) {
|
|
|
336
430
|
"INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
|
|
337
431
|
).run(newEventId(), runId, EVENT_TYPES.RUN_ABORTED, JSON.stringify({ reason }), now);
|
|
338
432
|
|
|
339
|
-
|
|
433
|
+
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
340
434
|
});
|
|
341
435
|
}
|