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.
@@ -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.status });
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
- return withTx(db, () => {
157
- const story = getStory(db, storyKey);
158
- if (!story)
159
- throw new Error(`Story not found: ${storyKey}`);
160
- if (story.state !== "approved")
161
- throw new Error(`Story must be approved to run: ${storyKey} (state=${story.state})`);
162
- const run_id = newRunId();
163
- const now = nowISO();
164
- const pipeline = getPipelineFromConfig(config);
165
- 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);
166
- 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);
167
- const insertStage = db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)");
168
- pipeline.forEach((stageKey, idx) => {
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
- return withTx(db, () => {
292
- const now = nowISO();
293
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
294
- if (!run)
295
- throw new Error(`Run not found: ${runId}`);
296
- db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
297
- 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);
298
- 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);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astrocode-workflow",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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 { decideNextAction, createRunForStory, startStage, completeRun, getActiveRun, EVENT_TYPES } from "../workflow/state-machine";
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
- // NOTE: createRunForStory owns its own tx (state-machine.ts).
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
- // NOTE: completeRun owns its own tx (state-machine.ts).
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 (config.ui.toasts.enabled && config.ui.toasts.show_run_completed) {
188
- await toasts.show({ title: "Astrocode", message: `Run completed (${next.run_id})`, variant: "success" });
189
- }
219
+ if (mode === "step") break;
220
+ continue;
221
+ }
190
222
 
191
- // explicit injection on completeRun (requested)
192
- if (sessionId) {
193
- await injectChatPrompt({
194
- ctx,
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
- startStage(db, active.run_id, next.stage_key, { subagent_type: agentName });
245
-
246
- actions.push(`stage started: ${next.stage_key}`);
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
- // Best-effort: continuations table may not exist on older DBs.
296
- try {
297
- const h = directiveHash(delegatePrompt);
298
- const now = nowISO();
299
- if (sessionId) {
300
- db.prepare(
301
- "INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'stage', ?, ?)"
302
- ).run(sessionId, active.run_id, h, `delegate ${next.stage_key}`, now);
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
- `Then run **astro_workflow_proceed** again.`,
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
- try {
354
- const h = directiveHash(prompt);
355
- const now = nowISO();
356
- db.prepare(
357
- "INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'continue', ?, ?)"
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
- if (next.kind === "failed") {
370
- actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
361
+ actions.push(`unhandled next action: ${(next as any).kind}`);
362
+ break;
363
+ }
371
364
 
372
- if (sessionId) {
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(`unhandled next action: ${(next as any).kind}`);
391
- break;
388
+ actions.push(`ui: flushed ${uiEvents.length} event(s)`);
392
389
  }
393
390
 
394
- // Housekeeping event (best-effort)
395
- try {
396
- db.prepare(
397
- "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, ?, ?, ?)"
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[] = [];