astrocode-workflow 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/state/db.ts CHANGED
@@ -48,7 +48,32 @@ export function configurePragmas(db: SqliteDb, pragmas: Record<string, any>) {
48
48
  if (pragmas.temp_store) db.pragma(`temp_store = ${pragmas.temp_store}`);
49
49
  }
50
50
 
51
- /** BEGIN IMMEDIATE transaction helper. */
51
+ /**
52
+ * Re-entrant transaction helper.
53
+ *
54
+ * SQLite rejects BEGIN inside BEGIN. We use:
55
+ * - depth=0: BEGIN IMMEDIATE ... COMMIT/ROLLBACK
56
+ * - depth>0: SAVEPOINT sp_n ... RELEASE / ROLLBACK TO + RELEASE
57
+ *
58
+ * This allows callers to safely nest withTx across layers (tools -> workflow -> state machine)
59
+ * without "cannot start a transaction within a transaction".
60
+ */
61
+ const TX_DEPTH = new WeakMap<object, number>();
62
+
63
+ function getDepth(db: SqliteDb): number {
64
+ return TX_DEPTH.get(db as any) ?? 0;
65
+ }
66
+
67
+ function setDepth(db: SqliteDb, depth: number) {
68
+ if (depth <= 0) TX_DEPTH.delete(db as any);
69
+ else TX_DEPTH.set(db as any, depth);
70
+ }
71
+
72
+ function savepointName(depth: number): string {
73
+ return `sp_${depth}`;
74
+ }
75
+
76
+ /** BEGIN IMMEDIATE transaction helper (re-entrant). */
52
77
  export function withTx<T>(db: SqliteDb, fn: () => T, opts?: { require?: boolean }): T {
53
78
  const adapter = createDatabaseAdapter();
54
79
  const available = adapter.isAvailable();
@@ -58,18 +83,52 @@ export function withTx<T>(db: SqliteDb, fn: () => T, opts?: { require?: boolean
58
83
  return fn();
59
84
  }
60
85
 
61
- db.exec("BEGIN IMMEDIATE");
86
+ const depth = getDepth(db);
87
+
88
+ if (depth === 0) {
89
+ db.exec("BEGIN IMMEDIATE");
90
+ setDepth(db, 1);
91
+ try {
92
+ const out = fn();
93
+ db.exec("COMMIT");
94
+ return out;
95
+ } catch (e) {
96
+ try {
97
+ db.exec("ROLLBACK");
98
+ } catch {
99
+ // ignore
100
+ }
101
+ throw e;
102
+ } finally {
103
+ setDepth(db, 0);
104
+ }
105
+ }
106
+
107
+ // Nested: use SAVEPOINT
108
+ const nextDepth = depth + 1;
109
+ const sp = savepointName(nextDepth);
110
+
111
+ db.exec(`SAVEPOINT ${sp}`);
112
+ setDepth(db, nextDepth);
113
+
62
114
  try {
63
115
  const out = fn();
64
- db.exec("COMMIT");
116
+ db.exec(`RELEASE SAVEPOINT ${sp}`);
65
117
  return out;
66
118
  } catch (e) {
67
119
  try {
68
- db.exec("ROLLBACK");
120
+ db.exec(`ROLLBACK TO SAVEPOINT ${sp}`);
121
+ } catch {
122
+ // ignore
123
+ }
124
+ try {
125
+ db.exec(`RELEASE SAVEPOINT ${sp}`);
69
126
  } catch {
70
127
  // ignore
71
128
  }
72
129
  throw e;
130
+ } finally {
131
+ setDepth(db, depth);
73
132
  }
74
133
  }
75
134
 
@@ -1,16 +1,27 @@
1
+ // src/tools/workflow.ts
1
2
  import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
2
3
  import type { AstrocodeConfig } from "../config/schema";
3
4
  import type { SqliteDb } from "../state/db";
4
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";
11
21
  import { newEventId } from "../state/ids";
12
22
  import { debug } from "../shared/log";
13
23
  import { createToastManager } from "../ui/toasts";
24
+ import type { AgentConfig } from "@opencode-ai/sdk";
14
25
 
15
26
  // Agent name mapping for case-sensitive resolution
16
27
  export const STAGE_TO_AGENT_MAP: Record<string, string> = {
@@ -37,23 +48,17 @@ export function resolveAgentName(stageKey: StageKey, config: AstrocodeConfig, ag
37
48
  // Validate that the agent actually exists in the registry
38
49
  if (agents && !agents[candidate]) {
39
50
  const warning = `Agent "${candidate}" not found in registry for stage "${stageKey}". Falling back to General.`;
40
- if (warnings) {
41
- warnings.push(warning);
42
- } else {
43
- console.warn(`[Astrocode] ${warning}`);
44
- }
51
+ if (warnings) warnings.push(warning);
52
+ else console.warn(`[Astrocode] ${warning}`);
45
53
  candidate = "General";
46
54
  }
47
55
 
48
56
  // Final guard: ensure General exists, fallback to built-in "general" if not
49
57
  if (agents && !agents[candidate]) {
50
58
  const finalWarning = `Critical: General agent not found in registry. Falling back to built-in "general".`;
51
- if (warnings) {
52
- warnings.push(finalWarning);
53
- } else {
54
- console.warn(`[Astrocode] ${finalWarning}`);
55
- }
56
- return "general"; // built-in, guaranteed by OpenCode
59
+ if (warnings) warnings.push(finalWarning);
60
+ else console.warn(`[Astrocode] ${finalWarning}`);
61
+ return "general";
57
62
  }
58
63
 
59
64
  return candidate;
@@ -94,10 +99,6 @@ function stageConstraints(stage: StageKey, cfg: AstrocodeConfig): string[] {
94
99
  return common;
95
100
  }
96
101
 
97
- function agentNameForStage(stage: StageKey, cfg: AstrocodeConfig): string {
98
- return cfg.agents.stage_agent_names[stage];
99
- }
100
-
101
102
  function buildDelegationPrompt(opts: {
102
103
  stageDirective: string;
103
104
  run_id: string;
@@ -126,13 +127,50 @@ function buildDelegationPrompt(opts: {
126
127
  `Important: do NOT do any stage work yourself in orchestrator mode.`,
127
128
  ].join("\n").trim();
128
129
 
129
- // Debug log the delegation prompt to troubleshoot agent output issues
130
130
  debug(`Delegating stage ${stage_key} to agent ${stage_agent_name}`, { prompt_length: prompt.length });
131
-
132
131
  return prompt;
133
132
  }
134
133
 
135
- import { AgentConfig } from "@opencode-ai/sdk";
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
+ }
136
174
 
137
175
  export function createAstroWorkflowProceedTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb; agents?: Record<string, AgentConfig> }): ToolDefinition {
138
176
  const { ctx, config, db, agents } = opts;
@@ -153,6 +191,10 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
153
191
  const warnings: string[] = [];
154
192
  const startedAt = nowISO();
155
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
+
156
198
  for (let i = 0; i < steps; i++) {
157
199
  const next = decideNextAction(db, config);
158
200
 
@@ -162,72 +204,26 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
162
204
  }
163
205
 
164
206
  if (next.kind === "start_run") {
207
+ // SINGLE tx boundary: caller owns tx, state-machine is pure.
165
208
  const { run_id } = withTx(db, () => createRunForStory(db, config, next.story_key));
166
209
  actions.push(`started run ${run_id} for story ${next.story_key}`);
167
210
 
168
- if (config.ui.toasts.enabled && config.ui.toasts.show_run_started) {
169
- await toasts.show({ title: "Astrocode", message: `Run started (${run_id})`, variant: "success" });
170
- }
171
-
172
211
  if (mode === "step") break;
173
212
  continue;
174
213
  }
175
214
 
176
215
  if (next.kind === "complete_run") {
177
- withTx(db, () => completeRun(db, next.run_id));
216
+ withTx(db, () => completeRun(db, next.run_id, emit));
178
217
  actions.push(`completed run ${next.run_id}`);
179
218
 
180
- if (config.ui.toasts.enabled && config.ui.toasts.show_run_completed) {
181
- await toasts.show({ title: "Astrocode", message: `Run completed (${next.run_id})`, variant: "success" });
182
- }
183
-
184
- // Inject continuation directive for workflow resumption
185
- if (sessionId) {
186
- const continueDirective = [
187
- `[SYSTEM DIRECTIVE: ASTROCODE — CONTINUE]`,
188
- ``,
189
- `Run ${next.run_id} completed successfully.`,
190
- ``,
191
- `The Clara Forms implementation run has finished all stages. The spec has been analyzed and decomposed into prioritized implementation stories.`,
192
- ``,
193
- `Next actions: Review the generated stories and approve the next one to continue development.`,
194
- ].join("\n");
195
-
196
- await injectChatPrompt({
197
- ctx,
198
- sessionId,
199
- text: continueDirective,
200
- agent: "Astro"
201
- });
202
-
203
- actions.push(`injected continuation directive for completed run ${next.run_id}`);
204
- }
205
-
206
- // Check for next approved story to start
207
- const nextStory = db.prepare(
208
- "SELECT story_key, title FROM stories WHERE state = 'approved' ORDER BY priority DESC, created_at ASC LIMIT 1"
209
- ).get() as { story_key: string; title: string } | undefined;
210
-
211
- if (nextStory && sessionId) {
212
- const nextDirective = [
213
- `[SYSTEM DIRECTIVE: ASTROCODE — START_NEXT_STORY]`,
214
- ``,
215
- `The previous run completed successfully. Start the next approved story.`,
216
- ``,
217
- `Next Story: ${nextStory.story_key} — ${nextStory.title}`,
218
- ``,
219
- `Action: Call astro_story_approve with story_key="${nextStory.story_key}" to start it, or select a different story.`,
220
- ].join("\n");
221
-
222
- await injectChatPrompt({
223
- ctx,
224
- sessionId,
225
- text: nextDirective,
226
- agent: "Astro"
227
- });
219
+ if (mode === "step") break;
220
+ continue;
221
+ }
228
222
 
229
- actions.push(`injected directive to start next story ${nextStory.story_key}`);
230
- }
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}`);
231
227
 
232
228
  if (mode === "step") break;
233
229
  continue;
@@ -236,62 +232,45 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
236
232
  if (next.kind === "delegate_stage") {
237
233
  const active = getActiveRun(db);
238
234
  if (!active) throw new Error("Invariant: delegate_stage but no active run.");
235
+
239
236
  const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(active.run_id) as any;
240
237
  const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key) as any;
241
238
 
242
- // Mark stage started + set subagent_type to the stage agent.
243
239
  let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
244
240
 
245
- // Validate agent availability with fallback chain
246
- const systemConfig = config as any;
247
- // Check both the system config agent map (if present) OR the local agents map passed to the tool
248
241
  const agentExists = (name: string) => {
249
- // Check local agents map first (populated from src/index.ts)
250
- if (agents && agents[name]) {
251
- return true;
252
- }
253
- // Check system config agent map
254
- if (systemConfig.agent && systemConfig.agent[name]) {
255
- return true;
256
- }
257
- // For known stage agents, assume they exist (they are system-provided subagents)
258
- const knownStageAgents = ["Frame", "Plan", "Spec", "Implement", "Review", "Verify", "Close"];
259
- if (knownStageAgents.includes(name)) {
260
- return true;
261
- }
262
- return false;
242
+ if (agents && agents[name]) return true;
243
+ const knownStageAgents = ["Frame", "Plan", "Spec", "Implement", "Review", "Verify", "Close", "General", "Astro", "general"];
244
+ if (knownStageAgents.includes(name)) return true;
245
+ return false;
263
246
  };
264
247
 
248
+ if (!agentExists(agentName)) {
249
+ const originalAgent = agentName;
250
+ console.warn(`[Astrocode] Agent ${agentName} not found. Falling back to orchestrator.`);
251
+ agentName = config.agents?.orchestrator_name || "Astro";
265
252
  if (!agentExists(agentName)) {
266
- const originalAgent = agentName;
267
- console.warn(`[Astrocode] Agent ${agentName} not found in config. Falling back to orchestrator.`);
268
- // First fallback: orchestrator
269
- agentName = config.agents?.orchestrator_name || "Astro";
253
+ console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
254
+ agentName = "General";
270
255
  if (!agentExists(agentName)) {
271
- console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
272
- // Final fallback: General (guaranteed to exist)
273
- agentName = "General";
274
- if (!agentExists(agentName)) {
275
- throw new Error(`Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`);
276
- }
256
+ throw new Error(
257
+ `Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`
258
+ );
277
259
  }
278
260
  }
261
+ }
279
262
 
263
+ // NOTE: startStage owns its own tx (state-machine.ts).
280
264
  withTx(db, () => {
281
- startStage(db, active.run_id, next.stage_key, { subagent_type: agentName });
282
-
283
- // Log delegation observability
284
- if (config.debug?.telemetry?.enabled) {
285
- // eslint-disable-next-line no-console
286
- console.log(`[Astrocode:delegate] run_id=${active.run_id} stage=${next.stage_key} agent=${agentName} fallback=${agentName !== resolveAgentName(next.stage_key, config, agents) ? 'yes' : 'no'}`);
287
- }
265
+ startStage(db, active.run_id, next.stage_key, { subagent_type: agentName }, emit);
288
266
  });
289
267
 
290
- if (config.ui.toasts.enabled && config.ui.toasts.show_stage_started) {
291
- await toasts.show({ title: "Astrocode", message: `Stage started: ${next.stage_key}`, variant: "info" });
292
- }
293
-
294
- const context = buildContextSnapshot({ db, config, run_id: active.run_id, next_action: `delegate stage ${next.stage_key}` });
268
+ const context = buildContextSnapshot({
269
+ db,
270
+ config,
271
+ run_id: active.run_id,
272
+ next_action: `delegate stage ${next.stage_key}`,
273
+ });
295
274
 
296
275
  const stageDirective = buildStageDirective({
297
276
  config,
@@ -312,35 +291,35 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
312
291
  stage_agent_name: agentName,
313
292
  });
314
293
 
315
- // Record in continuations as a stage directive (dedupe by hash)
294
+ // Record continuation (best-effort; no tx wrapper needed but safe either way)
316
295
  const h = directiveHash(delegatePrompt);
317
296
  const now = nowISO();
318
297
  if (sessionId) {
298
+ // This assumes continuations table exists in vNext schema.
319
299
  db.prepare(
320
300
  "INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'stage', ?, ?)"
321
301
  ).run(sessionId, active.run_id, h, `delegate ${next.stage_key}`, now);
322
302
  }
323
303
 
324
- // Visible injection so user can see state
325
- if (sessionId) {
326
- await injectChatPrompt({ ctx, sessionId, text: delegatePrompt, agent: "Astro" });
304
+ // Visible injection so user can see state (awaited)
305
+ if (sessionId) {
306
+ await injectChatPrompt({ ctx, sessionId, text: delegatePrompt, agent: "Astro" });
327
307
 
328
- // Inject continuation guidance
329
- const continueMessage = [
330
- `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_COMPLETION]`,
331
- ``,
332
- `Stage ${next.stage_key} delegated to ${agentName}.`,
333
- ``,
334
- `When ${agentName} completes, call:`,
335
- `astro_stage_complete(run_id="${active.run_id}", stage_key="${next.stage_key}", output_text="[paste subagent output here]")`,
336
- ``,
337
- `This advances the workflow.`,
338
- ].join("\n");
308
+ const continueMessage = [
309
+ `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_COMPLETION]`,
310
+ ``,
311
+ `Stage \`${next.stage_key}\` delegated to \`${agentName}\`.`,
312
+ ``,
313
+ `When \`${agentName}\` completes, call:`,
314
+ `astro_stage_complete(run_id="${active.run_id}", stage_key="${next.stage_key}", output_text="[paste subagent output here]")`,
315
+ ``,
316
+ `This advances the workflow.`,
317
+ ].join("\n");
339
318
 
340
- await injectChatPrompt({ ctx, sessionId, text: continueMessage, agent: "Astro" });
341
- }
319
+ await injectChatPrompt({ ctx, sessionId, text: continueMessage, agent: "Astro" });
320
+ }
342
321
 
343
- actions.push(`delegated stage ${next.stage_key} via ${agentName}`);
322
+ actions.push(`delegated stage ${next.stage_key} via ${agentName}`);
344
323
 
345
324
  // Stop here; subagent needs to run.
346
325
  break;
@@ -348,18 +327,25 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
348
327
 
349
328
  if (next.kind === "await_stage_completion") {
350
329
  actions.push(`await stage completion: ${next.stage_key}`);
351
- // Optionally nudge with a short directive
330
+
352
331
  if (sessionId) {
353
- const context = buildContextSnapshot({ db, config, run_id: next.run_id, next_action: `complete stage ${next.stage_key}` });
332
+ const context = buildContextSnapshot({
333
+ db,
334
+ config,
335
+ run_id: next.run_id,
336
+ next_action: `complete stage ${next.stage_key}`,
337
+ });
338
+
354
339
  const prompt = [
355
340
  `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_OUTPUT]`,
356
341
  ``,
357
- `Run ${next.run_id} is waiting for stage ${next.stage_key} output.`,
342
+ `Run \`${next.run_id}\` is waiting for stage \`${next.stage_key}\` output.`,
358
343
  `If you have the subagent output, call astro_stage_complete with output_text=the FULL output.`,
359
344
  ``,
360
345
  `Context snapshot:`,
361
346
  context,
362
347
  ].join("\n").trim();
348
+
363
349
  const h = directiveHash(prompt);
364
350
  const now = nowISO();
365
351
  db.prepare(
@@ -368,19 +354,40 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
368
354
 
369
355
  await injectChatPrompt({ ctx, sessionId, text: prompt, agent: "Astro" });
370
356
  }
371
- break;
372
- }
373
357
 
374
- if (next.kind === "failed") {
375
- actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
376
358
  break;
377
359
  }
378
360
 
379
- // safety
380
361
  actions.push(`unhandled next action: ${(next as any).kind}`);
381
362
  break;
382
363
  }
383
364
 
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) {
379
+ await injectChatPrompt({
380
+ ctx,
381
+ sessionId: (ctx as any).sessionID,
382
+ text: msg.chatText,
383
+ agent: "Astro",
384
+ });
385
+ }
386
+ }
387
+
388
+ actions.push(`ui: flushed ${uiEvents.length} event(s)`);
389
+ }
390
+
384
391
  // Housekeeping event
385
392
  db.prepare(
386
393
  "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, ?, ?, ?)"