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.
@@ -4,7 +4,7 @@ export declare function openSqlite(dbPath: string, opts?: {
4
4
  busyTimeoutMs?: number;
5
5
  }): SqliteDb;
6
6
  export declare function configurePragmas(db: SqliteDb, pragmas: Record<string, any>): void;
7
- /** BEGIN IMMEDIATE transaction helper. */
7
+ /** BEGIN IMMEDIATE transaction helper (re-entrant). */
8
8
  export declare function withTx<T>(db: SqliteDb, fn: () => T, opts?: {
9
9
  require?: boolean;
10
10
  }): T;
package/dist/state/db.js CHANGED
@@ -46,7 +46,30 @@ export function configurePragmas(db, pragmas) {
46
46
  if (pragmas.temp_store)
47
47
  db.pragma(`temp_store = ${pragmas.temp_store}`);
48
48
  }
49
- /** BEGIN IMMEDIATE transaction helper. */
49
+ /**
50
+ * Re-entrant transaction helper.
51
+ *
52
+ * SQLite rejects BEGIN inside BEGIN. We use:
53
+ * - depth=0: BEGIN IMMEDIATE ... COMMIT/ROLLBACK
54
+ * - depth>0: SAVEPOINT sp_n ... RELEASE / ROLLBACK TO + RELEASE
55
+ *
56
+ * This allows callers to safely nest withTx across layers (tools -> workflow -> state machine)
57
+ * without "cannot start a transaction within a transaction".
58
+ */
59
+ const TX_DEPTH = new WeakMap();
60
+ function getDepth(db) {
61
+ return TX_DEPTH.get(db) ?? 0;
62
+ }
63
+ function setDepth(db, depth) {
64
+ if (depth <= 0)
65
+ TX_DEPTH.delete(db);
66
+ else
67
+ TX_DEPTH.set(db, depth);
68
+ }
69
+ function savepointName(depth) {
70
+ return `sp_${depth}`;
71
+ }
72
+ /** BEGIN IMMEDIATE transaction helper (re-entrant). */
50
73
  export function withTx(db, fn, opts) {
51
74
  const adapter = createDatabaseAdapter();
52
75
  const available = adapter.isAvailable();
@@ -55,21 +78,56 @@ export function withTx(db, fn, opts) {
55
78
  throw new Error("Database adapter unavailable; transaction required.");
56
79
  return fn();
57
80
  }
58
- db.exec("BEGIN IMMEDIATE");
81
+ const depth = getDepth(db);
82
+ if (depth === 0) {
83
+ db.exec("BEGIN IMMEDIATE");
84
+ setDepth(db, 1);
85
+ try {
86
+ const out = fn();
87
+ db.exec("COMMIT");
88
+ return out;
89
+ }
90
+ catch (e) {
91
+ try {
92
+ db.exec("ROLLBACK");
93
+ }
94
+ catch {
95
+ // ignore
96
+ }
97
+ throw e;
98
+ }
99
+ finally {
100
+ setDepth(db, 0);
101
+ }
102
+ }
103
+ // Nested: use SAVEPOINT
104
+ const nextDepth = depth + 1;
105
+ const sp = savepointName(nextDepth);
106
+ db.exec(`SAVEPOINT ${sp}`);
107
+ setDepth(db, nextDepth);
59
108
  try {
60
109
  const out = fn();
61
- db.exec("COMMIT");
110
+ db.exec(`RELEASE SAVEPOINT ${sp}`);
62
111
  return out;
63
112
  }
64
113
  catch (e) {
65
114
  try {
66
- db.exec("ROLLBACK");
115
+ db.exec(`ROLLBACK TO SAVEPOINT ${sp}`);
116
+ }
117
+ catch {
118
+ // ignore
119
+ }
120
+ try {
121
+ db.exec(`RELEASE SAVEPOINT ${sp}`);
67
122
  }
68
123
  catch {
69
124
  // ignore
70
125
  }
71
126
  throw e;
72
127
  }
128
+ finally {
129
+ setDepth(db, depth);
130
+ }
73
131
  }
74
132
  export function getSchemaVersion(db) {
75
133
  try {
@@ -2,9 +2,9 @@ import { type ToolDefinition } from "@opencode-ai/plugin/tool";
2
2
  import type { AstrocodeConfig } from "../config/schema";
3
3
  import type { SqliteDb } from "../state/db";
4
4
  import type { StageKey } from "../state/types";
5
+ import type { AgentConfig } from "@opencode-ai/sdk";
5
6
  export declare const STAGE_TO_AGENT_MAP: Record<string, string>;
6
7
  export declare function resolveAgentName(stageKey: StageKey, config: AstrocodeConfig, agents?: any, warnings?: string[]): string;
7
- import { AgentConfig } from "@opencode-ai/sdk";
8
8
  export declare function createAstroWorkflowProceedTool(opts: {
9
9
  ctx: any;
10
10
  config: AstrocodeConfig;
@@ -1,7 +1,8 @@
1
+ // src/tools/workflow.ts
1
2
  import { tool } from "@opencode-ai/plugin/tool";
2
3
  import { withTx } from "../state/db";
3
4
  import { buildContextSnapshot } from "../workflow/context";
4
- import { decideNextAction, createRunForStory, startStage, completeRun, getActiveRun, EVENT_TYPES } from "../workflow/state-machine";
5
+ import { decideNextAction, createRunForStory, startStage, completeRun, failRun, getActiveRun, EVENT_TYPES, } from "../workflow/state-machine";
5
6
  import { buildStageDirective, directiveHash } from "../workflow/directives";
6
7
  import { injectChatPrompt } from "../ui/inject";
7
8
  import { nowISO } from "../shared/time";
@@ -31,24 +32,20 @@ export function resolveAgentName(stageKey, config, agents, warnings) {
31
32
  // Validate that the agent actually exists in the registry
32
33
  if (agents && !agents[candidate]) {
33
34
  const warning = `Agent "${candidate}" not found in registry for stage "${stageKey}". Falling back to General.`;
34
- if (warnings) {
35
+ if (warnings)
35
36
  warnings.push(warning);
36
- }
37
- else {
37
+ else
38
38
  console.warn(`[Astrocode] ${warning}`);
39
- }
40
39
  candidate = "General";
41
40
  }
42
41
  // Final guard: ensure General exists, fallback to built-in "general" if not
43
42
  if (agents && !agents[candidate]) {
44
43
  const finalWarning = `Critical: General agent not found in registry. Falling back to built-in "general".`;
45
- if (warnings) {
44
+ if (warnings)
46
45
  warnings.push(finalWarning);
47
- }
48
- else {
46
+ else
49
47
  console.warn(`[Astrocode] ${finalWarning}`);
50
- }
51
- return "general"; // built-in, guaranteed by OpenCode
48
+ return "general";
52
49
  }
53
50
  return candidate;
54
51
  }
@@ -84,9 +81,6 @@ function stageConstraints(stage, cfg) {
84
81
  }
85
82
  return common;
86
83
  }
87
- function agentNameForStage(stage, cfg) {
88
- return cfg.agents.stage_agent_names[stage];
89
- }
90
84
  function buildDelegationPrompt(opts) {
91
85
  const { stageDirective, run_id, stage_key, stage_agent_name } = opts;
92
86
  const stageUpper = stage_key.toUpperCase();
@@ -108,10 +102,49 @@ function buildDelegationPrompt(opts) {
108
102
  ``,
109
103
  `Important: do NOT do any stage work yourself in orchestrator mode.`,
110
104
  ].join("\n").trim();
111
- // Debug log the delegation prompt to troubleshoot agent output issues
112
105
  debug(`Delegating stage ${stage_key} to agent ${stage_agent_name}`, { prompt_length: prompt.length });
113
106
  return prompt;
114
107
  }
108
+ function buildUiMessage(e) {
109
+ switch (e.kind) {
110
+ case "stage_started": {
111
+ const agent = e.agent_name ? ` (${e.agent_name})` : "";
112
+ const title = "Astrocode";
113
+ const message = `Stage started: ${e.stage_key}${agent}`;
114
+ const chatText = [
115
+ `[SYSTEM DIRECTIVE: ASTROCODE — STAGE_STARTED]`,
116
+ ``,
117
+ `Run: ${e.run_id}`,
118
+ `Stage: ${e.stage_key}${agent}`,
119
+ ].join("\n");
120
+ return { title, message, variant: "info", chatText };
121
+ }
122
+ case "run_completed": {
123
+ const title = "Astrocode";
124
+ const message = `Run completed: ${e.run_id}`;
125
+ const chatText = [
126
+ `[SYSTEM DIRECTIVE: ASTROCODE — RUN_COMPLETED]`,
127
+ ``,
128
+ `Run: ${e.run_id}`,
129
+ `Story: ${e.story_key}`,
130
+ ].join("\n");
131
+ return { title, message, variant: "success", chatText };
132
+ }
133
+ case "run_failed": {
134
+ const title = "Astrocode";
135
+ const message = `Run failed: ${e.run_id} (${e.stage_key})`;
136
+ const chatText = [
137
+ `[SYSTEM DIRECTIVE: ASTROCODE — RUN_FAILED]`,
138
+ ``,
139
+ `Run: ${e.run_id}`,
140
+ `Story: ${e.story_key}`,
141
+ `Stage: ${e.stage_key}`,
142
+ `Error: ${e.error_text}`,
143
+ ].join("\n");
144
+ return { title, message, variant: "error", chatText };
145
+ }
146
+ }
147
+ }
115
148
  export function createAstroWorkflowProceedTool(opts) {
116
149
  const { ctx, config, db, agents } = opts;
117
150
  const toasts = createToastManager({ ctx, throttleMs: config.ui.toasts.throttle_ms });
@@ -127,6 +160,9 @@ export function createAstroWorkflowProceedTool(opts) {
127
160
  const actions = [];
128
161
  const warnings = [];
129
162
  const startedAt = nowISO();
163
+ // Collect UI events emitted inside state-machine functions, then flush AFTER tx.
164
+ const uiEvents = [];
165
+ const emit = (e) => uiEvents.push(e);
130
166
  for (let i = 0; i < steps; i++) {
131
167
  const next = decideNextAction(db, config);
132
168
  if (next.kind === "idle") {
@@ -134,60 +170,24 @@ export function createAstroWorkflowProceedTool(opts) {
134
170
  break;
135
171
  }
136
172
  if (next.kind === "start_run") {
173
+ // SINGLE tx boundary: caller owns tx, state-machine is pure.
137
174
  const { run_id } = withTx(db, () => createRunForStory(db, config, next.story_key));
138
175
  actions.push(`started run ${run_id} for story ${next.story_key}`);
139
- if (config.ui.toasts.enabled && config.ui.toasts.show_run_started) {
140
- await toasts.show({ title: "Astrocode", message: `Run started (${run_id})`, variant: "success" });
141
- }
142
176
  if (mode === "step")
143
177
  break;
144
178
  continue;
145
179
  }
146
180
  if (next.kind === "complete_run") {
147
- withTx(db, () => completeRun(db, next.run_id));
181
+ withTx(db, () => completeRun(db, next.run_id, emit));
148
182
  actions.push(`completed run ${next.run_id}`);
149
- if (config.ui.toasts.enabled && config.ui.toasts.show_run_completed) {
150
- await toasts.show({ title: "Astrocode", message: `Run completed (${next.run_id})`, variant: "success" });
151
- }
152
- // Inject continuation directive for workflow resumption
153
- if (sessionId) {
154
- const continueDirective = [
155
- `[SYSTEM DIRECTIVE: ASTROCODE CONTINUE]`,
156
- ``,
157
- `Run ${next.run_id} completed successfully.`,
158
- ``,
159
- `The Clara Forms implementation run has finished all stages. The spec has been analyzed and decomposed into prioritized implementation stories.`,
160
- ``,
161
- `Next actions: Review the generated stories and approve the next one to continue development.`,
162
- ].join("\n");
163
- await injectChatPrompt({
164
- ctx,
165
- sessionId,
166
- text: continueDirective,
167
- agent: "Astro"
168
- });
169
- actions.push(`injected continuation directive for completed run ${next.run_id}`);
170
- }
171
- // Check for next approved story to start
172
- const nextStory = db.prepare("SELECT story_key, title FROM stories WHERE state = 'approved' ORDER BY priority DESC, created_at ASC LIMIT 1").get();
173
- if (nextStory && sessionId) {
174
- const nextDirective = [
175
- `[SYSTEM DIRECTIVE: ASTROCODE — START_NEXT_STORY]`,
176
- ``,
177
- `The previous run completed successfully. Start the next approved story.`,
178
- ``,
179
- `Next Story: ${nextStory.story_key} — ${nextStory.title}`,
180
- ``,
181
- `Action: Call astro_story_approve with story_key="${nextStory.story_key}" to start it, or select a different story.`,
182
- ].join("\n");
183
- await injectChatPrompt({
184
- ctx,
185
- sessionId,
186
- text: nextDirective,
187
- agent: "Astro"
188
- });
189
- actions.push(`injected directive to start next story ${nextStory.story_key}`);
190
- }
183
+ if (mode === "step")
184
+ break;
185
+ continue;
186
+ }
187
+ if (next.kind === "failed") {
188
+ // Ensure DB state reflects failure in one tx; emit UI event.
189
+ withTx(db, () => failRun(db, next.run_id, next.stage_key, next.error_text, emit));
190
+ actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
191
191
  if (mode === "step")
192
192
  break;
193
193
  continue;
@@ -198,53 +198,37 @@ export function createAstroWorkflowProceedTool(opts) {
198
198
  throw new Error("Invariant: delegate_stage but no active run.");
199
199
  const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(active.run_id);
200
200
  const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key);
201
- // Mark stage started + set subagent_type to the stage agent.
202
201
  let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
203
- // Validate agent availability with fallback chain
204
- const systemConfig = config;
205
- // Check both the system config agent map (if present) OR the local agents map passed to the tool
206
202
  const agentExists = (name) => {
207
- // Check local agents map first (populated from src/index.ts)
208
- if (agents && agents[name]) {
209
- return true;
210
- }
211
- // Check system config agent map
212
- if (systemConfig.agent && systemConfig.agent[name]) {
203
+ if (agents && agents[name])
213
204
  return true;
214
- }
215
- // For known stage agents, assume they exist (they are system-provided subagents)
216
- const knownStageAgents = ["Frame", "Plan", "Spec", "Implement", "Review", "Verify", "Close"];
217
- if (knownStageAgents.includes(name)) {
205
+ const knownStageAgents = ["Frame", "Plan", "Spec", "Implement", "Review", "Verify", "Close", "General", "Astro", "general"];
206
+ if (knownStageAgents.includes(name))
218
207
  return true;
219
- }
220
208
  return false;
221
209
  };
222
210
  if (!agentExists(agentName)) {
223
211
  const originalAgent = agentName;
224
- console.warn(`[Astrocode] Agent ${agentName} not found in config. Falling back to orchestrator.`);
225
- // First fallback: orchestrator
212
+ console.warn(`[Astrocode] Agent ${agentName} not found. Falling back to orchestrator.`);
226
213
  agentName = config.agents?.orchestrator_name || "Astro";
227
214
  if (!agentExists(agentName)) {
228
215
  console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
229
- // Final fallback: General (guaranteed to exist)
230
216
  agentName = "General";
231
217
  if (!agentExists(agentName)) {
232
218
  throw new Error(`Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`);
233
219
  }
234
220
  }
235
221
  }
222
+ // NOTE: startStage owns its own tx (state-machine.ts).
236
223
  withTx(db, () => {
237
- startStage(db, active.run_id, next.stage_key, { subagent_type: agentName });
238
- // Log delegation observability
239
- if (config.debug?.telemetry?.enabled) {
240
- // eslint-disable-next-line no-console
241
- 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'}`);
242
- }
224
+ startStage(db, active.run_id, next.stage_key, { subagent_type: agentName }, emit);
225
+ });
226
+ const context = buildContextSnapshot({
227
+ db,
228
+ config,
229
+ run_id: active.run_id,
230
+ next_action: `delegate stage ${next.stage_key}`,
243
231
  });
244
- if (config.ui.toasts.enabled && config.ui.toasts.show_stage_started) {
245
- await toasts.show({ title: "Astrocode", message: `Stage started: ${next.stage_key}`, variant: "info" });
246
- }
247
- const context = buildContextSnapshot({ db, config, run_id: active.run_id, next_action: `delegate stage ${next.stage_key}` });
248
232
  const stageDirective = buildStageDirective({
249
233
  config,
250
234
  stage_key: next.stage_key,
@@ -262,22 +246,22 @@ export function createAstroWorkflowProceedTool(opts) {
262
246
  stage_key: next.stage_key,
263
247
  stage_agent_name: agentName,
264
248
  });
265
- // Record in continuations as a stage directive (dedupe by hash)
249
+ // Record continuation (best-effort; no tx wrapper needed but safe either way)
266
250
  const h = directiveHash(delegatePrompt);
267
251
  const now = nowISO();
268
252
  if (sessionId) {
253
+ // This assumes continuations table exists in vNext schema.
269
254
  db.prepare("INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'stage', ?, ?)").run(sessionId, active.run_id, h, `delegate ${next.stage_key}`, now);
270
255
  }
271
- // Visible injection so user can see state
256
+ // Visible injection so user can see state (awaited)
272
257
  if (sessionId) {
273
258
  await injectChatPrompt({ ctx, sessionId, text: delegatePrompt, agent: "Astro" });
274
- // Inject continuation guidance
275
259
  const continueMessage = [
276
260
  `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_COMPLETION]`,
277
261
  ``,
278
- `Stage ${next.stage_key} delegated to ${agentName}.`,
262
+ `Stage \`${next.stage_key}\` delegated to \`${agentName}\`.`,
279
263
  ``,
280
- `When ${agentName} completes, call:`,
264
+ `When \`${agentName}\` completes, call:`,
281
265
  `astro_stage_complete(run_id="${active.run_id}", stage_key="${next.stage_key}", output_text="[paste subagent output here]")`,
282
266
  ``,
283
267
  `This advances the workflow.`,
@@ -290,13 +274,17 @@ export function createAstroWorkflowProceedTool(opts) {
290
274
  }
291
275
  if (next.kind === "await_stage_completion") {
292
276
  actions.push(`await stage completion: ${next.stage_key}`);
293
- // Optionally nudge with a short directive
294
277
  if (sessionId) {
295
- const context = buildContextSnapshot({ db, config, run_id: next.run_id, next_action: `complete stage ${next.stage_key}` });
278
+ const context = buildContextSnapshot({
279
+ db,
280
+ config,
281
+ run_id: next.run_id,
282
+ next_action: `complete stage ${next.stage_key}`,
283
+ });
296
284
  const prompt = [
297
285
  `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_OUTPUT]`,
298
286
  ``,
299
- `Run ${next.run_id} is waiting for stage ${next.stage_key} output.`,
287
+ `Run \`${next.run_id}\` is waiting for stage \`${next.stage_key}\` output.`,
300
288
  `If you have the subagent output, call astro_stage_complete with output_text=the FULL output.`,
301
289
  ``,
302
290
  `Context snapshot:`,
@@ -309,14 +297,31 @@ export function createAstroWorkflowProceedTool(opts) {
309
297
  }
310
298
  break;
311
299
  }
312
- if (next.kind === "failed") {
313
- actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
314
- break;
315
- }
316
- // safety
317
300
  actions.push(`unhandled next action: ${next.kind}`);
318
301
  break;
319
302
  }
303
+ // Flush UI events (toast + prompt) AFTER state transitions
304
+ if (uiEvents.length > 0) {
305
+ for (const e of uiEvents) {
306
+ const msg = buildUiMessage(e);
307
+ if (config.ui.toasts.enabled) {
308
+ await toasts.show({
309
+ title: msg.title,
310
+ message: msg.message,
311
+ variant: msg.variant,
312
+ });
313
+ }
314
+ if (ctx?.sessionID) {
315
+ await injectChatPrompt({
316
+ ctx,
317
+ sessionId: ctx.sessionID,
318
+ text: msg.chatText,
319
+ agent: "Astro",
320
+ });
321
+ }
322
+ }
323
+ actions.push(`ui: flushed ${uiEvents.length} event(s)`);
324
+ }
320
325
  // Housekeeping event
321
326
  db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, ?, ?, ?)").run(newEventId(), EVENT_TYPES.WORKFLOW_PROCEED, JSON.stringify({ started_at: startedAt, mode, max_steps: steps, actions }), nowISO());
322
327
  const active = getActiveRun(db);
@@ -1,20 +1,12 @@
1
- type QueueItem = {
1
+ /**
2
+ * Inject a visible prompt into the conversation.
3
+ * - Deterministic ordering per session
4
+ * - Correct SDK binding (prevents `this._client` undefined)
5
+ * - Awaitable: resolves when delivered, rejects after max retries
6
+ */
7
+ export declare function injectChatPrompt(opts: {
2
8
  ctx: any;
3
- sessionId: string;
9
+ sessionId?: string;
4
10
  text: string;
5
11
  agent?: string;
6
- };
7
- /**
8
- * Enqueue an injection and ensure the worker is running.
9
- * Does NOT wait for delivery — use `flushChatPrompts()` to wait.
10
- */
11
- export declare function enqueueChatPrompt(opts: QueueItem): void;
12
- /**
13
- * Wait until all queued injections have been processed (sent or exhausted retries).
14
- */
15
- export declare function flushChatPrompts(): Promise<void>;
16
- /**
17
- * Deterministic helper: enqueue + flush (recommended for stage boundaries).
18
- */
19
- export declare function injectChatPrompt(opts: QueueItem): Promise<void>;
20
- export {};
12
+ }): Promise<void>;