astrocode-workflow 0.3.0 → 0.3.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/tools/workflow.js +90 -105
- package/dist/ui/inject.d.ts +6 -0
- package/dist/ui/inject.js +70 -63
- package/dist/workflow/state-machine.d.ts +32 -32
- package/dist/workflow/state-machine.js +85 -170
- package/package.json +1 -1
- package/src/tools/workflow.ts +108 -120
- package/src/ui/inject.ts +96 -74
- package/src/workflow/state-machine.ts +123 -227
- package/src/tools/workflow.ts.backup +0 -681
|
@@ -1,10 +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";
|
|
6
5
|
import { SCHEMA_VERSION } from "../state/schema";
|
|
7
|
-
import { injectChatPrompt } from "../ui/inject";
|
|
8
6
|
export const EVENT_TYPES = {
|
|
9
7
|
RUN_STARTED: "run.started",
|
|
10
8
|
RUN_COMPLETED: "run.completed",
|
|
@@ -14,31 +12,6 @@ export const EVENT_TYPES = {
|
|
|
14
12
|
STAGE_STARTED: "stage.started",
|
|
15
13
|
WORKFLOW_PROCEED: "workflow.proceed",
|
|
16
14
|
};
|
|
17
|
-
async function emitUi(ui, text, toast) {
|
|
18
|
-
if (!ui)
|
|
19
|
-
return;
|
|
20
|
-
// Prefer toast (if provided) AND also inject chat (for audit trail / visibility).
|
|
21
|
-
// If you want toast-only, pass a toast function and omit ctx/sessionId.
|
|
22
|
-
if (toast && ui.toast) {
|
|
23
|
-
try {
|
|
24
|
-
await ui.toast(toast);
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
// non-fatal
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
try {
|
|
31
|
-
await injectChatPrompt({
|
|
32
|
-
ctx: ui.ctx,
|
|
33
|
-
sessionId: ui.sessionId,
|
|
34
|
-
text,
|
|
35
|
-
agent: ui.agentName ?? "Astro",
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
// non-fatal (workflow correctness is DB-based)
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
15
|
function tableExists(db, tableName) {
|
|
43
16
|
try {
|
|
44
17
|
const row = db
|
|
@@ -90,7 +63,7 @@ export function decideNextAction(db, config) {
|
|
|
90
63
|
if (current.status === "failed") {
|
|
91
64
|
return { kind: "failed", run_id: activeRun.run_id, stage_key: current.stage_key, error_text: current.error_text ?? "stage failed" };
|
|
92
65
|
}
|
|
93
|
-
warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.
|
|
66
|
+
warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.stage_key });
|
|
94
67
|
return { kind: "await_stage_completion", run_id: activeRun.run_id, stage_key: current.stage_key, stage_run_id: current.stage_run_id };
|
|
95
68
|
}
|
|
96
69
|
function getPipelineFromConfig(config) {
|
|
@@ -153,149 +126,91 @@ function attachRunPlanningDirective(db, runId, story, pipeline) {
|
|
|
153
126
|
}
|
|
154
127
|
}
|
|
155
128
|
export function createRunForStory(db, config, storyKey) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
insertStage.run(newStageRunId(), run_id, stageKey, idx, now, now);
|
|
170
|
-
});
|
|
171
|
-
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);
|
|
172
|
-
if (shouldAttachPlanningDirective(config, story)) {
|
|
173
|
-
attachRunPlanningDirective(db, run_id, story, pipeline);
|
|
174
|
-
}
|
|
175
|
-
db.prepare(`
|
|
176
|
-
INSERT INTO repo_state (id, schema_version, created_at, updated_at, last_run_id, last_story_key, last_event_at)
|
|
177
|
-
VALUES (1, ?, ?, ?, ?, ?, ?)
|
|
178
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
179
|
-
last_run_id=excluded.last_run_id,
|
|
180
|
-
last_story_key=excluded.last_story_key,
|
|
181
|
-
last_event_at=excluded.last_event_at,
|
|
182
|
-
updated_at=excluded.updated_at
|
|
183
|
-
`).run(SCHEMA_VERSION, now, now, run_id, storyKey, now);
|
|
184
|
-
return { run_id };
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
/**
|
|
188
|
-
* STAGE MOVEMENT (START) — now async so UI injection is deterministic.
|
|
189
|
-
*/
|
|
190
|
-
export async function startStage(db, runId, stageKey, meta) {
|
|
191
|
-
// Do DB work inside tx, capture what we need for UI outside.
|
|
192
|
-
const payload = withTx(db, () => {
|
|
193
|
-
const now = nowISO();
|
|
194
|
-
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
|
|
195
|
-
if (!run)
|
|
196
|
-
throw new Error(`Run not found: ${runId}`);
|
|
197
|
-
if (run.status !== "running")
|
|
198
|
-
throw new Error(`Run is not running: ${runId} (status=${run.status})`);
|
|
199
|
-
const stage = db.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?").get(runId, stageKey);
|
|
200
|
-
if (!stage)
|
|
201
|
-
throw new Error(`Stage run not found: ${runId}/${stageKey}`);
|
|
202
|
-
if (stage.status !== "pending")
|
|
203
|
-
throw new Error(`Stage is not pending: ${stageKey} (status=${stage.status})`);
|
|
204
|
-
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);
|
|
205
|
-
db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
|
|
206
|
-
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);
|
|
207
|
-
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
208
|
-
const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key);
|
|
209
|
-
return {
|
|
210
|
-
now,
|
|
211
|
-
story_key: run.story_key,
|
|
212
|
-
story_title: story?.title ?? "",
|
|
213
|
-
};
|
|
214
|
-
});
|
|
215
|
-
// Deterministic UI emission AFTER commit (never inside tx).
|
|
216
|
-
await emitUi(meta?.ui, [
|
|
217
|
-
`🟦 Stage started`,
|
|
218
|
-
`- Run: \`${runId}\``,
|
|
219
|
-
`- Stage: \`${stageKey}\``,
|
|
220
|
-
`- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
|
|
221
|
-
].join("\n"), {
|
|
222
|
-
title: "Stage started",
|
|
223
|
-
message: `${stageKey} (${payload.story_key})`,
|
|
224
|
-
variant: "info",
|
|
225
|
-
durationMs: 2500,
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
/**
|
|
229
|
-
* STAGE CLOSED (RUN COMPLETED) — now async so UI injection is deterministic.
|
|
230
|
-
*/
|
|
231
|
-
export async function completeRun(db, runId, ui) {
|
|
232
|
-
const payload = withTx(db, () => {
|
|
233
|
-
const now = nowISO();
|
|
234
|
-
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
|
|
235
|
-
if (!run)
|
|
236
|
-
throw new Error(`Run not found: ${runId}`);
|
|
237
|
-
if (run.status !== "running")
|
|
238
|
-
throw new Error(`Run not running: ${runId} (status=${run.status})`);
|
|
239
|
-
const stageRuns = getStageRuns(db, runId);
|
|
240
|
-
const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
|
|
241
|
-
if (incomplete)
|
|
242
|
-
throw new Error(`Cannot complete run: stage ${incomplete.stage_key} is ${incomplete.status}`);
|
|
243
|
-
db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
|
|
244
|
-
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);
|
|
245
|
-
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);
|
|
246
|
-
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
247
|
-
const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key);
|
|
248
|
-
return { now, story_key: run.story_key, story_title: story?.title ?? "" };
|
|
249
|
-
});
|
|
250
|
-
await emitUi(ui, [
|
|
251
|
-
`✅ Run completed`,
|
|
252
|
-
`- Run: \`${runId}\``,
|
|
253
|
-
`- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
|
|
254
|
-
].join("\n"), {
|
|
255
|
-
title: "Run completed",
|
|
256
|
-
message: `${payload.story_key} — done`,
|
|
257
|
-
variant: "success",
|
|
258
|
-
durationMs: 3000,
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
/**
|
|
262
|
-
* STAGE CLOSED (RUN FAILED) — now async so UI injection is deterministic.
|
|
263
|
-
*/
|
|
264
|
-
export async function failRun(db, runId, stageKey, errorText, ui) {
|
|
265
|
-
const payload = withTx(db, () => {
|
|
266
|
-
const now = nowISO();
|
|
267
|
-
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
|
|
268
|
-
if (!run)
|
|
269
|
-
throw new Error(`Run not found: ${runId}`);
|
|
270
|
-
db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
|
|
271
|
-
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);
|
|
272
|
-
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);
|
|
273
|
-
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
274
|
-
const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key);
|
|
275
|
-
return { now, story_key: run.story_key, story_title: story?.title ?? "" };
|
|
276
|
-
});
|
|
277
|
-
await emitUi(ui, [
|
|
278
|
-
`⛔ Run failed`,
|
|
279
|
-
`- Run: \`${runId}\``,
|
|
280
|
-
`- Stage: \`${stageKey}\``,
|
|
281
|
-
`- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
|
|
282
|
-
`- Error: ${errorText}`,
|
|
283
|
-
].join("\n"), {
|
|
284
|
-
title: "Run failed",
|
|
285
|
-
message: `${stageKey}: ${errorText}`,
|
|
286
|
-
variant: "error",
|
|
287
|
-
durationMs: 4500,
|
|
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);
|
|
288
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 };
|
|
157
|
+
}
|
|
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 });
|
|
176
|
+
}
|
|
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 });
|
|
194
|
+
}
|
|
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 });
|
|
289
206
|
}
|
|
290
207
|
export function abortRun(db, runId, reason) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
300
|
-
});
|
|
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);
|
|
301
216
|
}
|
package/package.json
CHANGED
package/src/tools/workflow.ts
CHANGED
|
@@ -2,9 +2,19 @@
|
|
|
2
2
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
|
|
3
3
|
import type { AstrocodeConfig } from "../config/schema";
|
|
4
4
|
import type { SqliteDb } from "../state/db";
|
|
5
|
+
import { withTx } from "../state/db";
|
|
5
6
|
import type { StageKey } from "../state/types";
|
|
7
|
+
import type { UiEmitEvent } from "../workflow/state-machine";
|
|
6
8
|
import { buildContextSnapshot } from "../workflow/context";
|
|
7
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
decideNextAction,
|
|
11
|
+
createRunForStory,
|
|
12
|
+
startStage,
|
|
13
|
+
completeRun,
|
|
14
|
+
failRun,
|
|
15
|
+
getActiveRun,
|
|
16
|
+
EVENT_TYPES,
|
|
17
|
+
} from "../workflow/state-machine";
|
|
8
18
|
import { buildStageDirective, directiveHash } from "../workflow/directives";
|
|
9
19
|
import { injectChatPrompt } from "../ui/inject";
|
|
10
20
|
import { nowISO } from "../shared/time";
|
|
@@ -118,10 +128,50 @@ function buildDelegationPrompt(opts: {
|
|
|
118
128
|
].join("\n").trim();
|
|
119
129
|
|
|
120
130
|
debug(`Delegating stage ${stage_key} to agent ${stage_agent_name}`, { prompt_length: prompt.length });
|
|
121
|
-
|
|
122
131
|
return prompt;
|
|
123
132
|
}
|
|
124
133
|
|
|
134
|
+
function buildUiMessage(e: UiEmitEvent): { title: string; message: string; variant: "info" | "success" | "error"; chatText: string } {
|
|
135
|
+
switch (e.kind) {
|
|
136
|
+
case "stage_started": {
|
|
137
|
+
const agent = e.agent_name ? ` (${e.agent_name})` : "";
|
|
138
|
+
const title = "Astrocode";
|
|
139
|
+
const message = `Stage started: ${e.stage_key}${agent}`;
|
|
140
|
+
const chatText = [
|
|
141
|
+
`[SYSTEM DIRECTIVE: ASTROCODE — STAGE_STARTED]`,
|
|
142
|
+
``,
|
|
143
|
+
`Run: ${e.run_id}`,
|
|
144
|
+
`Stage: ${e.stage_key}${agent}`,
|
|
145
|
+
].join("\n");
|
|
146
|
+
return { title, message, variant: "info", chatText };
|
|
147
|
+
}
|
|
148
|
+
case "run_completed": {
|
|
149
|
+
const title = "Astrocode";
|
|
150
|
+
const message = `Run completed: ${e.run_id}`;
|
|
151
|
+
const chatText = [
|
|
152
|
+
`[SYSTEM DIRECTIVE: ASTROCODE — RUN_COMPLETED]`,
|
|
153
|
+
``,
|
|
154
|
+
`Run: ${e.run_id}`,
|
|
155
|
+
`Story: ${e.story_key}`,
|
|
156
|
+
].join("\n");
|
|
157
|
+
return { title, message, variant: "success", chatText };
|
|
158
|
+
}
|
|
159
|
+
case "run_failed": {
|
|
160
|
+
const title = "Astrocode";
|
|
161
|
+
const message = `Run failed: ${e.run_id} (${e.stage_key})`;
|
|
162
|
+
const chatText = [
|
|
163
|
+
`[SYSTEM DIRECTIVE: ASTROCODE — RUN_FAILED]`,
|
|
164
|
+
``,
|
|
165
|
+
`Run: ${e.run_id}`,
|
|
166
|
+
`Story: ${e.story_key}`,
|
|
167
|
+
`Stage: ${e.stage_key}`,
|
|
168
|
+
`Error: ${e.error_text}`,
|
|
169
|
+
].join("\n");
|
|
170
|
+
return { title, message, variant: "error", chatText };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
125
175
|
export function createAstroWorkflowProceedTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb; agents?: Record<string, AgentConfig> }): ToolDefinition {
|
|
126
176
|
const { ctx, config, db, agents } = opts;
|
|
127
177
|
const toasts = createToastManager({ ctx, throttleMs: config.ui.toasts.throttle_ms });
|
|
@@ -141,6 +191,10 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
|
|
|
141
191
|
const warnings: string[] = [];
|
|
142
192
|
const startedAt = nowISO();
|
|
143
193
|
|
|
194
|
+
// Collect UI events emitted inside state-machine functions, then flush AFTER tx.
|
|
195
|
+
const uiEvents: UiEmitEvent[] = [];
|
|
196
|
+
const emit = (e: UiEmitEvent) => uiEvents.push(e);
|
|
197
|
+
|
|
144
198
|
for (let i = 0; i < steps; i++) {
|
|
145
199
|
const next = decideNextAction(db, config);
|
|
146
200
|
|
|
@@ -150,60 +204,26 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
|
|
|
150
204
|
}
|
|
151
205
|
|
|
152
206
|
if (next.kind === "start_run") {
|
|
153
|
-
//
|
|
154
|
-
const { run_id } = createRunForStory(db, config, next.story_key);
|
|
207
|
+
// SINGLE tx boundary: caller owns tx, state-machine is pure.
|
|
208
|
+
const { run_id } = withTx(db, () => createRunForStory(db, config, next.story_key));
|
|
155
209
|
actions.push(`started run ${run_id} for story ${next.story_key}`);
|
|
156
210
|
|
|
157
|
-
if (config.ui.toasts.enabled && config.ui.toasts.show_run_started) {
|
|
158
|
-
await toasts.show({ title: "Astrocode", message: `Run started (${run_id})`, variant: "success" });
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (sessionId) {
|
|
162
|
-
await injectChatPrompt({
|
|
163
|
-
ctx,
|
|
164
|
-
sessionId,
|
|
165
|
-
agent: "Astro",
|
|
166
|
-
text: [
|
|
167
|
-
`[SYSTEM DIRECTIVE: ASTROCODE — RUN_STARTED]`,
|
|
168
|
-
``,
|
|
169
|
-
`Run started: \`${run_id}\``,
|
|
170
|
-
`Story: \`${next.story_key}\``,
|
|
171
|
-
``,
|
|
172
|
-
`Next: call **astro_workflow_proceed** again to delegate the first stage.`,
|
|
173
|
-
].join("\n"),
|
|
174
|
-
});
|
|
175
|
-
actions.push(`injected run started message for ${run_id}`);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
211
|
if (mode === "step") break;
|
|
179
212
|
continue;
|
|
180
213
|
}
|
|
181
214
|
|
|
182
215
|
if (next.kind === "complete_run") {
|
|
183
|
-
|
|
184
|
-
completeRun(db, next.run_id);
|
|
216
|
+
withTx(db, () => completeRun(db, next.run_id, emit));
|
|
185
217
|
actions.push(`completed run ${next.run_id}`);
|
|
186
218
|
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
219
|
+
if (mode === "step") break;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
190
222
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
sessionId,
|
|
196
|
-
agent: "Astro",
|
|
197
|
-
text: [
|
|
198
|
-
`[SYSTEM DIRECTIVE: ASTROCODE — RUN_COMPLETED]`,
|
|
199
|
-
``,
|
|
200
|
-
`Run \`${next.run_id}\` completed.`,
|
|
201
|
-
``,
|
|
202
|
-
`Next: call **astro_workflow_proceed** (mode=step) to start the next approved story (if any).`,
|
|
203
|
-
].join("\n"),
|
|
204
|
-
});
|
|
205
|
-
actions.push(`injected run completed message for ${next.run_id}`);
|
|
206
|
-
}
|
|
223
|
+
if (next.kind === "failed") {
|
|
224
|
+
// Ensure DB state reflects failure in one tx; emit UI event.
|
|
225
|
+
withTx(db, () => failRun(db, next.run_id, next.stage_key, next.error_text, emit));
|
|
226
|
+
actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
|
|
207
227
|
|
|
208
228
|
if (mode === "step") break;
|
|
209
229
|
continue;
|
|
@@ -241,30 +261,9 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
|
|
|
241
261
|
}
|
|
242
262
|
|
|
243
263
|
// NOTE: startStage owns its own tx (state-machine.ts).
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (config.ui.toasts.enabled && config.ui.toasts.show_stage_started) {
|
|
249
|
-
await toasts.show({ title: "Astrocode", message: `Stage started: ${next.stage_key}`, variant: "info" });
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// ✅ explicit injection on startStage (requested)
|
|
253
|
-
if (sessionId) {
|
|
254
|
-
await injectChatPrompt({
|
|
255
|
-
ctx,
|
|
256
|
-
sessionId,
|
|
257
|
-
agent: "Astro",
|
|
258
|
-
text: [
|
|
259
|
-
`[SYSTEM DIRECTIVE: ASTROCODE — STAGE_STARTED]`,
|
|
260
|
-
``,
|
|
261
|
-
`Run: \`${active.run_id}\``,
|
|
262
|
-
`Stage: \`${next.stage_key}\``,
|
|
263
|
-
`Delegated to: \`${agentName}\``,
|
|
264
|
-
].join("\n"),
|
|
265
|
-
});
|
|
266
|
-
actions.push(`injected stage started message for ${next.stage_key}`);
|
|
267
|
-
}
|
|
264
|
+
withTx(db, () => {
|
|
265
|
+
startStage(db, active.run_id, next.stage_key, { subagent_type: agentName }, emit);
|
|
266
|
+
});
|
|
268
267
|
|
|
269
268
|
const context = buildContextSnapshot({
|
|
270
269
|
db,
|
|
@@ -292,20 +291,17 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
|
|
|
292
291
|
stage_agent_name: agentName,
|
|
293
292
|
});
|
|
294
293
|
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
} catch (e) {
|
|
305
|
-
warnings.push(`continuations insert failed (non-fatal): ${String(e)}`);
|
|
294
|
+
// Record continuation (best-effort; no tx wrapper needed but safe either way)
|
|
295
|
+
const h = directiveHash(delegatePrompt);
|
|
296
|
+
const now = nowISO();
|
|
297
|
+
if (sessionId) {
|
|
298
|
+
// This assumes continuations table exists in vNext schema.
|
|
299
|
+
db.prepare(
|
|
300
|
+
"INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'stage', ?, ?)"
|
|
301
|
+
).run(sessionId, active.run_id, h, `delegate ${next.stage_key}`, now);
|
|
306
302
|
}
|
|
307
303
|
|
|
308
|
-
// Visible injection so user can see state
|
|
304
|
+
// Visible injection so user can see state (awaited)
|
|
309
305
|
if (sessionId) {
|
|
310
306
|
await injectChatPrompt({ ctx, sessionId, text: delegatePrompt, agent: "Astro" });
|
|
311
307
|
|
|
@@ -317,7 +313,7 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
|
|
|
317
313
|
`When \`${agentName}\` completes, call:`,
|
|
318
314
|
`astro_stage_complete(run_id="${active.run_id}", stage_key="${next.stage_key}", output_text="[paste subagent output here]")`,
|
|
319
315
|
``,
|
|
320
|
-
`
|
|
316
|
+
`This advances the workflow.`,
|
|
321
317
|
].join("\n");
|
|
322
318
|
|
|
323
319
|
await injectChatPrompt({ ctx, sessionId, text: continueMessage, agent: "Astro" });
|
|
@@ -350,15 +346,11 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
|
|
|
350
346
|
context,
|
|
351
347
|
].join("\n").trim();
|
|
352
348
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
).run(sessionId, next.run_id, h, `await ${next.stage_key}`, now);
|
|
359
|
-
} catch (e) {
|
|
360
|
-
warnings.push(`continuations insert failed (non-fatal): ${String(e)}`);
|
|
361
|
-
}
|
|
349
|
+
const h = directiveHash(prompt);
|
|
350
|
+
const now = nowISO();
|
|
351
|
+
db.prepare(
|
|
352
|
+
"INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'continue', ?, ?)"
|
|
353
|
+
).run(sessionId, next.run_id, h, `await ${next.stage_key}`, now);
|
|
362
354
|
|
|
363
355
|
await injectChatPrompt({ ctx, sessionId, text: prompt, agent: "Astro" });
|
|
364
356
|
}
|
|
@@ -366,44 +358,40 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
|
|
|
366
358
|
break;
|
|
367
359
|
}
|
|
368
360
|
|
|
369
|
-
|
|
370
|
-
|
|
361
|
+
actions.push(`unhandled next action: ${(next as any).kind}`);
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
371
364
|
|
|
372
|
-
|
|
365
|
+
// Flush UI events (toast + prompt) AFTER state transitions
|
|
366
|
+
if (uiEvents.length > 0) {
|
|
367
|
+
for (const e of uiEvents) {
|
|
368
|
+
const msg = buildUiMessage(e);
|
|
369
|
+
|
|
370
|
+
if (config.ui.toasts.enabled) {
|
|
371
|
+
await toasts.show({
|
|
372
|
+
title: msg.title,
|
|
373
|
+
message: msg.message,
|
|
374
|
+
variant: msg.variant,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if ((ctx as any)?.sessionID) {
|
|
373
379
|
await injectChatPrompt({
|
|
374
380
|
ctx,
|
|
375
|
-
sessionId,
|
|
381
|
+
sessionId: (ctx as any).sessionID,
|
|
382
|
+
text: msg.chatText,
|
|
376
383
|
agent: "Astro",
|
|
377
|
-
text: [
|
|
378
|
-
`[SYSTEM DIRECTIVE: ASTROCODE — RUN_FAILED]`,
|
|
379
|
-
``,
|
|
380
|
-
`Run \`${next.run_id}\` failed at stage \`${next.stage_key}\`.`,
|
|
381
|
-
`Error: ${next.error_text}`,
|
|
382
|
-
].join("\n"),
|
|
383
384
|
});
|
|
384
|
-
actions.push(`injected run failed message for ${next.run_id}`);
|
|
385
385
|
}
|
|
386
|
-
|
|
387
|
-
break;
|
|
388
386
|
}
|
|
389
387
|
|
|
390
|
-
actions.push(`
|
|
391
|
-
break;
|
|
388
|
+
actions.push(`ui: flushed ${uiEvents.length} event(s)`);
|
|
392
389
|
}
|
|
393
390
|
|
|
394
|
-
// Housekeeping event
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
).run(
|
|
399
|
-
newEventId(),
|
|
400
|
-
EVENT_TYPES.WORKFLOW_PROCEED,
|
|
401
|
-
JSON.stringify({ started_at: startedAt, mode, max_steps: steps, actions }),
|
|
402
|
-
nowISO()
|
|
403
|
-
);
|
|
404
|
-
} catch (e) {
|
|
405
|
-
warnings.push(`workflow.proceed event insert failed (non-fatal): ${String(e)}`);
|
|
406
|
-
}
|
|
391
|
+
// Housekeeping event
|
|
392
|
+
db.prepare(
|
|
393
|
+
"INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, ?, ?, ?)"
|
|
394
|
+
).run(newEventId(), EVENT_TYPES.WORKFLOW_PROCEED, JSON.stringify({ started_at: startedAt, mode, max_steps: steps, actions }), nowISO());
|
|
407
395
|
|
|
408
396
|
const active = getActiveRun(db);
|
|
409
397
|
const lines: string[] = [];
|