astrocode-workflow 0.3.4 → 0.3.5-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.
@@ -0,0 +1,78 @@
1
+ import path from "node:path";
2
+ import { tool } from "@opencode-ai/plugin/tool";
3
+ import { getLockStatus, tryRemoveStaleLock } from "../state/repo-lock";
4
+ export function createAstroLockStatusTool(opts) {
5
+ const { ctx } = opts;
6
+ return tool({
7
+ description: "Check Astrocode lock status and attempt repair. Shows diagnostics (PID, age, session) and can remove stale/dead locks.",
8
+ args: {
9
+ attempt_repair: tool.schema.boolean().default(false).describe("If true, attempt to remove stale or dead locks"),
10
+ },
11
+ execute: async ({ attempt_repair }) => {
12
+ const repoRoot = ctx.directory;
13
+ const lockPath = path.join(repoRoot, ".astro", "astro.lock");
14
+ const status = getLockStatus(lockPath);
15
+ if (!status.exists) {
16
+ return "✅ No lock file found. Repository is unlocked.";
17
+ }
18
+ const lines = [];
19
+ lines.push("# Astrocode Lock Status");
20
+ lines.push("");
21
+ lines.push("## Lock Details");
22
+ lines.push(`- **Path**: ${status.path}`);
23
+ lines.push(`- **PID**: ${status.pid} (${status.pidAlive ? '🟢 ALIVE' : '🔴 DEAD'})`);
24
+ lines.push(`- **Age**: ${status.ageMs ? Math.floor(status.ageMs / 1000) : '?'}s`);
25
+ lines.push(`- **Status**: ${status.isStale ? '⚠️ STALE' : '✅ FRESH'}`);
26
+ if (status.sessionId)
27
+ lines.push(`- **Session**: ${status.sessionId}`);
28
+ if (status.owner)
29
+ lines.push(`- **Owner**: ${status.owner}`);
30
+ if (status.instanceId)
31
+ lines.push(`- **Instance**: ${status.instanceId.substring(0, 8)}...`);
32
+ if (status.leaseId)
33
+ lines.push(`- **Lease**: ${status.leaseId.substring(0, 8)}...`);
34
+ if (status.createdAt)
35
+ lines.push(`- **Created**: ${status.createdAt}`);
36
+ if (status.updatedAt)
37
+ lines.push(`- **Updated**: ${status.updatedAt}`);
38
+ if (status.repoRoot)
39
+ lines.push(`- **Repo**: ${status.repoRoot}`);
40
+ lines.push(`- **Version**: ${status.version ?? 'unknown'}`);
41
+ lines.push("");
42
+ if (attempt_repair) {
43
+ lines.push("## Repair Attempt");
44
+ const result = tryRemoveStaleLock(lockPath);
45
+ if (result.removed) {
46
+ lines.push(`✅ **Lock removed**: ${result.reason}`);
47
+ lines.push("");
48
+ lines.push("The repository is now unlocked and ready for use.");
49
+ }
50
+ else {
51
+ lines.push(`⚠️ **Lock NOT removed**: ${result.reason}`);
52
+ lines.push("");
53
+ lines.push("**Recommendations**:");
54
+ lines.push("- If the owning process has crashed, wait 30 seconds for automatic stale detection");
55
+ lines.push("- If the process is still running, wait for it to complete");
56
+ lines.push("- As a last resort, manually stop the process and run this tool again with attempt_repair=true");
57
+ }
58
+ }
59
+ else {
60
+ lines.push("## Recommendations");
61
+ if (!status.pidAlive) {
62
+ lines.push("🔧 **Action Required**: Lock belongs to dead process. Run with `attempt_repair=true` to remove it.");
63
+ }
64
+ else if (status.isStale) {
65
+ lines.push("🔧 **Action Suggested**: Lock is stale (not updated recently). Run with `attempt_repair=true` to remove it.");
66
+ }
67
+ else {
68
+ lines.push("✅ Lock is active and healthy. The owning process is running normally.");
69
+ lines.push("");
70
+ lines.push("If you believe this is incorrect:");
71
+ lines.push("- Wait 30 seconds and check again (automatic stale detection)");
72
+ lines.push("- Run with `attempt_repair=true` only if you're certain the process has crashed");
73
+ }
74
+ }
75
+ return lines.join("\n");
76
+ },
77
+ });
78
+ }
@@ -1,25 +1,59 @@
1
+ import path from "node:path";
1
2
  import { tool } from "@opencode-ai/plugin/tool";
2
3
  import { withTx } from "../state/db";
3
4
  import { repairState, formatRepairReport } from "../workflow/repair";
4
5
  import { putArtifact } from "../workflow/artifacts";
5
6
  import { nowISO } from "../shared/time";
7
+ import { getLockStatus, tryRemoveStaleLock } from "../state/repo-lock";
6
8
  export function createAstroRepairTool(opts) {
7
9
  const { ctx, config, db } = opts;
8
10
  return tool({
9
- description: "Repair Astrocode invariants and recover from inconsistent DB state. Writes a repair report artifact.",
11
+ description: "Repair Astrocode invariants and recover from inconsistent DB state. Also checks and repairs lock files. Writes a repair report artifact.",
10
12
  args: {
11
13
  write_report_artifact: tool.schema.boolean().default(true),
14
+ repair_lock: tool.schema.boolean().default(true).describe("Attempt to remove stale/dead lock files"),
12
15
  },
13
- execute: async ({ write_report_artifact }) => {
16
+ execute: async ({ write_report_artifact, repair_lock }) => {
14
17
  const repoRoot = ctx.directory;
18
+ const lockPath = path.join(repoRoot, ".astro", "astro.lock");
19
+ // First, check and repair lock if requested
20
+ const lockLines = [];
21
+ const lockStatus = getLockStatus(lockPath);
22
+ if (lockStatus.exists) {
23
+ lockLines.push("## Lock Status");
24
+ lockLines.push(`- Lock found: ${lockPath}`);
25
+ lockLines.push(`- PID: ${lockStatus.pid} (${lockStatus.pidAlive ? 'alive' : 'dead'})`);
26
+ lockLines.push(`- Age: ${lockStatus.ageMs ? Math.floor(lockStatus.ageMs / 1000) : '?'}s`);
27
+ lockLines.push(`- Status: ${lockStatus.isStale ? 'stale' : 'fresh'}`);
28
+ if (repair_lock) {
29
+ const result = tryRemoveStaleLock(lockPath);
30
+ if (result.removed) {
31
+ lockLines.push(`- **Removed**: ${result.reason}`);
32
+ }
33
+ else {
34
+ lockLines.push(`- **Not removed**: ${result.reason}`);
35
+ }
36
+ }
37
+ else {
38
+ if (!lockStatus.pidAlive || lockStatus.isStale) {
39
+ lockLines.push(`- **Recommendation**: Run with repair_lock=true to remove this ${!lockStatus.pidAlive ? 'dead' : 'stale'} lock`);
40
+ }
41
+ }
42
+ lockLines.push("");
43
+ }
44
+ // Then repair database state
15
45
  const report = withTx(db, () => repairState(db, config));
16
- const md = formatRepairReport(report);
46
+ const dbMd = formatRepairReport(report);
47
+ // Combine lock and DB repair
48
+ const fullMd = lockLines.length > 0
49
+ ? `# Astrocode Repair Report\n\n${lockLines.join("\n")}\n${dbMd.replace(/^# Astrocode repair report\n*/i, "")}`
50
+ : dbMd;
17
51
  if (write_report_artifact) {
18
52
  const rel = `.astro/repair/repair_${nowISO().replace(/[:.]/g, "-")}.md`;
19
- const a = putArtifact({ repoRoot, db, run_id: null, stage_key: null, type: "log", rel_path: rel, content: md, meta: { kind: "repair" } });
20
- return md + `\n\nReport saved: ${rel} (artifact=${a.artifact_id})`;
53
+ const a = putArtifact({ repoRoot, db, run_id: null, stage_key: null, type: "log", rel_path: rel, content: fullMd, meta: { kind: "repair" } });
54
+ return fullMd + `\n\nReport saved: ${rel} (artifact=${a.artifact_id})`;
21
55
  }
22
- return md;
56
+ return fullMd;
23
57
  },
24
58
  });
25
59
  }
@@ -31,7 +31,7 @@ function stageIcon(status) {
31
31
  }
32
32
  }
33
33
  export function createAstroStatusTool(opts) {
34
- const { config, db } = opts;
34
+ const { ctx, config, db } = opts;
35
35
  return tool({
36
36
  description: "Show a compact Astrocode status dashboard: active run/stage, pipeline, story board counts, and next action.",
37
37
  args: {
@@ -1,5 +1,6 @@
1
1
  // src/tools/workflow.ts
2
2
  import { tool } from "@opencode-ai/plugin/tool";
3
+ import path from "node:path";
3
4
  import { withTx } from "../state/db";
4
5
  import { buildContextSnapshot } from "../workflow/context";
5
6
  import { decideNextAction, createRunForStory, startStage, completeRun, failRun, getActiveRun, EVENT_TYPES, } from "../workflow/state-machine";
@@ -10,6 +11,7 @@ import { newEventId } from "../state/ids";
10
11
  import { debug } from "../shared/log";
11
12
  import { createToastManager } from "../ui/toasts";
12
13
  import { acquireRepoLock } from "../state/repo-lock";
14
+ import { workflowRepoLock } from "../state/workflow-repo-lock";
13
15
  // Agent name mapping for case-sensitive resolution
14
16
  export const STAGE_TO_AGENT_MAP = {
15
17
  frame: "Frame",
@@ -156,200 +158,201 @@ export function createAstroWorkflowProceedTool(opts) {
156
158
  max_steps: tool.schema.number().int().positive().default(config.workflow.default_max_steps),
157
159
  },
158
160
  execute: async ({ mode, max_steps }) => {
159
- // Acquire repo lock to ensure no concurrent workflow operations
160
- const lockPath = `${ctx.directory}/.astro/astro.lock`;
161
- const repoLock = acquireRepoLock(lockPath);
162
- try {
163
- const sessionId = ctx.sessionID;
164
- const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
165
- const actions = [];
166
- const warnings = [];
167
- const startedAt = nowISO();
168
- // Collect UI events emitted inside state-machine functions, then flush AFTER tx.
169
- const uiEvents = [];
170
- const emit = (e) => uiEvents.push(e);
171
- for (let i = 0; i < steps; i++) {
172
- const next = decideNextAction(db, config);
173
- if (next.kind === "idle") {
174
- actions.push("idle: no approved stories");
175
- break;
176
- }
177
- if (next.kind === "start_run") {
178
- // SINGLE tx boundary: caller owns tx, state-machine is pure.
179
- const { run_id } = withTx(db, () => createRunForStory(db, config, next.story_key));
180
- actions.push(`started run ${run_id} for story ${next.story_key}`);
181
- if (mode === "step")
182
- break;
183
- continue;
184
- }
185
- if (next.kind === "complete_run") {
186
- withTx(db, () => completeRun(db, next.run_id, emit));
187
- actions.push(`completed run ${next.run_id}`);
188
- if (mode === "step")
161
+ const repoRoot = ctx.directory;
162
+ const lockPath = path.join(repoRoot, ".astro", "astro.lock");
163
+ const sessionId = ctx.sessionID;
164
+ return workflowRepoLock({ acquireRepoLock }, {
165
+ lockPath,
166
+ repoRoot,
167
+ sessionId,
168
+ owner: "astro_workflow_proceed",
169
+ fn: async () => {
170
+ const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
171
+ const actions = [];
172
+ const warnings = [];
173
+ const startedAt = nowISO();
174
+ // Collect UI events emitted inside state-machine functions, then flush AFTER tx.
175
+ const uiEvents = [];
176
+ const emit = (e) => uiEvents.push(e);
177
+ for (let i = 0; i < steps; i++) {
178
+ const next = decideNextAction(db, config);
179
+ if (next.kind === "idle") {
180
+ actions.push("idle: no approved stories");
189
181
  break;
190
- continue;
191
- }
192
- if (next.kind === "failed") {
193
- // Ensure DB state reflects failure in one tx; emit UI event.
194
- withTx(db, () => failRun(db, next.run_id, next.stage_key, next.error_text, emit));
195
- actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
196
- if (mode === "step")
197
- break;
198
- continue;
199
- }
200
- if (next.kind === "delegate_stage") {
201
- const active = getActiveRun(db);
202
- if (!active)
203
- throw new Error("Invariant: delegate_stage but no active run.");
204
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(active.run_id);
205
- const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key);
206
- let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
207
- const agentExists = (name) => {
208
- if (agents && agents[name])
209
- return true;
210
- const knownStageAgents = ["Frame", "Plan", "Spec", "Implement", "Review", "Verify", "Close", "General", "Astro", "general"];
211
- if (knownStageAgents.includes(name))
212
- return true;
213
- return false;
214
- };
215
- if (!agentExists(agentName)) {
216
- const originalAgent = agentName;
217
- console.warn(`[Astrocode] Agent ${agentName} not found. Falling back to orchestrator.`);
218
- agentName = config.agents?.orchestrator_name || "Astro";
182
+ }
183
+ if (next.kind === "start_run") {
184
+ // SINGLE tx boundary: caller owns tx, state-machine is pure.
185
+ const { run_id } = withTx(db, () => createRunForStory(db, config, next.story_key));
186
+ actions.push(`started run ${run_id} for story ${next.story_key}`);
187
+ if (mode === "step")
188
+ break;
189
+ continue;
190
+ }
191
+ if (next.kind === "complete_run") {
192
+ withTx(db, () => completeRun(db, next.run_id, emit));
193
+ actions.push(`completed run ${next.run_id}`);
194
+ if (mode === "step")
195
+ break;
196
+ continue;
197
+ }
198
+ if (next.kind === "failed") {
199
+ // Ensure DB state reflects failure in one tx; emit UI event.
200
+ withTx(db, () => failRun(db, next.run_id, next.stage_key, next.error_text, emit));
201
+ actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
202
+ if (mode === "step")
203
+ break;
204
+ continue;
205
+ }
206
+ if (next.kind === "delegate_stage") {
207
+ const active = getActiveRun(db);
208
+ if (!active)
209
+ throw new Error("Invariant: delegate_stage but no active run.");
210
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(active.run_id);
211
+ const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key);
212
+ let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
213
+ const agentExists = (name) => {
214
+ if (agents && agents[name])
215
+ return true;
216
+ const knownStageAgents = ["Frame", "Plan", "Spec", "Implement", "Review", "Verify", "Close", "General", "Astro", "general"];
217
+ if (knownStageAgents.includes(name))
218
+ return true;
219
+ return false;
220
+ };
219
221
  if (!agentExists(agentName)) {
220
- console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
221
- agentName = "General";
222
+ const originalAgent = agentName;
223
+ console.warn(`[Astrocode] Agent ${agentName} not found. Falling back to orchestrator.`);
224
+ agentName = config.agents?.orchestrator_name || "Astro";
222
225
  if (!agentExists(agentName)) {
223
- throw new Error(`Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`);
226
+ console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
227
+ agentName = "General";
228
+ if (!agentExists(agentName)) {
229
+ throw new Error(`Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`);
230
+ }
224
231
  }
225
232
  }
226
- }
227
- // NOTE: startStage owns its own tx (state-machine.ts).
228
- withTx(db, () => {
229
- startStage(db, active.run_id, next.stage_key, { subagent_type: agentName }, emit);
230
- });
231
- const context = buildContextSnapshot({
232
- db,
233
- config,
234
- run_id: active.run_id,
235
- next_action: `delegate stage ${next.stage_key}`,
236
- });
237
- const stageDirective = buildStageDirective({
238
- config,
239
- stage_key: next.stage_key,
240
- run_id: active.run_id,
241
- story_key: run.story_key,
242
- story_title: story?.title ?? "(missing)",
243
- stage_agent_name: agentName,
244
- stage_goal: stageGoal(next.stage_key, config),
245
- stage_constraints: stageConstraints(next.stage_key, config),
246
- context_snapshot_md: context,
247
- }).body;
248
- const delegatePrompt = buildDelegationPrompt({
249
- stageDirective,
250
- run_id: active.run_id,
251
- stage_key: next.stage_key,
252
- stage_agent_name: agentName,
253
- });
254
- // Record continuation (best-effort; no tx wrapper needed but safe either way)
255
- const h = directiveHash(delegatePrompt);
256
- const now = nowISO();
257
- if (sessionId) {
258
- // This assumes continuations table exists in vNext schema.
259
- 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);
260
- }
261
- // Visible injection so user can see state (awaited)
262
- if (sessionId) {
263
- await injectChatPrompt({ ctx, sessionId, text: delegatePrompt, agent: "Astro" });
264
- const continueMessage = [
265
- `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_COMPLETION]`,
266
- ``,
267
- `Stage \`${next.stage_key}\` delegated to \`${agentName}\`.`,
268
- ``,
269
- `When \`${agentName}\` completes, call:`,
270
- `astro_stage_complete(run_id="${active.run_id}", stage_key="${next.stage_key}", output_text="[paste subagent output here]")`,
271
- ``,
272
- `This advances the workflow.`,
273
- ].join("\n");
274
- await injectChatPrompt({ ctx, sessionId, text: continueMessage, agent: "Astro" });
275
- }
276
- actions.push(`delegated stage ${next.stage_key} via ${agentName}`);
277
- // Stop here; subagent needs to run.
278
- break;
279
- }
280
- if (next.kind === "await_stage_completion") {
281
- actions.push(`await stage completion: ${next.stage_key}`);
282
- if (sessionId) {
233
+ // NOTE: startStage owns its own tx (state-machine.ts).
234
+ withTx(db, () => {
235
+ startStage(db, active.run_id, next.stage_key, { subagent_type: agentName }, emit);
236
+ });
283
237
  const context = buildContextSnapshot({
284
238
  db,
285
239
  config,
286
- run_id: next.run_id,
287
- next_action: `complete stage ${next.stage_key}`,
240
+ run_id: active.run_id,
241
+ next_action: `delegate stage ${next.stage_key}`,
242
+ });
243
+ const stageDirective = buildStageDirective({
244
+ config,
245
+ stage_key: next.stage_key,
246
+ run_id: active.run_id,
247
+ story_key: run.story_key,
248
+ story_title: story?.title ?? "(missing)",
249
+ stage_agent_name: agentName,
250
+ stage_goal: stageGoal(next.stage_key, config),
251
+ stage_constraints: stageConstraints(next.stage_key, config),
252
+ context_snapshot_md: context,
253
+ }).body;
254
+ const delegatePrompt = buildDelegationPrompt({
255
+ stageDirective,
256
+ run_id: active.run_id,
257
+ stage_key: next.stage_key,
258
+ stage_agent_name: agentName,
288
259
  });
289
- const prompt = [
290
- `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_OUTPUT]`,
291
- ``,
292
- `Run \`${next.run_id}\` is waiting for stage \`${next.stage_key}\` output.`,
293
- `If you have the subagent output, call astro_stage_complete with output_text=the FULL output.`,
294
- ``,
295
- `Context snapshot:`,
296
- context,
297
- ].join("\n").trim();
298
- const h = directiveHash(prompt);
260
+ // Record continuation (best-effort; no tx wrapper needed but safe either way)
261
+ const h = directiveHash(delegatePrompt);
299
262
  const now = nowISO();
300
- db.prepare("INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'continue', ?, ?)").run(sessionId, next.run_id, h, `await ${next.stage_key}`, now);
301
- await injectChatPrompt({ ctx, sessionId, text: prompt, agent: "Astro" });
263
+ if (sessionId) {
264
+ // This assumes continuations table exists in vNext schema.
265
+ 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);
266
+ }
267
+ // Visible injection so user can see state (awaited)
268
+ if (sessionId) {
269
+ await injectChatPrompt({ ctx, sessionId, text: delegatePrompt, agent: "Astro" });
270
+ const continueMessage = [
271
+ `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_COMPLETION]`,
272
+ ``,
273
+ `Stage \`${next.stage_key}\` delegated to \`${agentName}\`.`,
274
+ ``,
275
+ `When \`${agentName}\` completes, call:`,
276
+ `astro_stage_complete(run_id="${active.run_id}", stage_key="${next.stage_key}", output_text="[paste subagent output here]")`,
277
+ ``,
278
+ `This advances the workflow.`,
279
+ ].join("\n");
280
+ await injectChatPrompt({ ctx, sessionId, text: continueMessage, agent: "Astro" });
281
+ }
282
+ actions.push(`delegated stage ${next.stage_key} via ${agentName}`);
283
+ // Stop here; subagent needs to run.
284
+ break;
302
285
  }
286
+ if (next.kind === "await_stage_completion") {
287
+ actions.push(`await stage completion: ${next.stage_key}`);
288
+ if (sessionId) {
289
+ const context = buildContextSnapshot({
290
+ db,
291
+ config,
292
+ run_id: next.run_id,
293
+ next_action: `complete stage ${next.stage_key}`,
294
+ });
295
+ const prompt = [
296
+ `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_OUTPUT]`,
297
+ ``,
298
+ `Run \`${next.run_id}\` is waiting for stage \`${next.stage_key}\` output.`,
299
+ `If you have the subagent output, call astro_stage_complete with output_text=the FULL output.`,
300
+ ``,
301
+ `Context snapshot:`,
302
+ context,
303
+ ].join("\n").trim();
304
+ const h = directiveHash(prompt);
305
+ const now = nowISO();
306
+ db.prepare("INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'continue', ?, ?)").run(sessionId, next.run_id, h, `await ${next.stage_key}`, now);
307
+ await injectChatPrompt({ ctx, sessionId, text: prompt, agent: "Astro" });
308
+ }
309
+ break;
310
+ }
311
+ actions.push(`unhandled next action: ${next.kind}`);
303
312
  break;
304
313
  }
305
- actions.push(`unhandled next action: ${next.kind}`);
306
- break;
307
- }
308
- // Flush UI events (toast + prompt) AFTER state transitions
309
- if (uiEvents.length > 0) {
310
- for (const e of uiEvents) {
311
- const msg = buildUiMessage(e);
312
- if (config.ui.toasts.enabled) {
313
- await toasts.show({
314
- title: msg.title,
315
- message: msg.message,
316
- variant: msg.variant,
317
- });
318
- }
319
- if (ctx?.sessionID) {
320
- await injectChatPrompt({
321
- ctx,
322
- sessionId: ctx.sessionID,
323
- text: msg.chatText,
324
- agent: "Astro",
325
- });
314
+ // Flush UI events (toast + prompt) AFTER state transitions
315
+ if (uiEvents.length > 0) {
316
+ for (const e of uiEvents) {
317
+ const msg = buildUiMessage(e);
318
+ if (config.ui.toasts.enabled) {
319
+ await toasts.show({
320
+ title: msg.title,
321
+ message: msg.message,
322
+ variant: msg.variant,
323
+ });
324
+ }
325
+ if (ctx?.sessionID) {
326
+ await injectChatPrompt({
327
+ ctx,
328
+ sessionId: ctx.sessionID,
329
+ text: msg.chatText,
330
+ agent: "Astro",
331
+ });
332
+ }
326
333
  }
334
+ actions.push(`ui: flushed ${uiEvents.length} event(s)`);
335
+ }
336
+ // Housekeeping event
337
+ 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());
338
+ const active = getActiveRun(db);
339
+ const lines = [];
340
+ lines.push(`# astro_workflow_proceed`);
341
+ lines.push(`- mode: ${mode}`);
342
+ lines.push(`- steps requested: ${max_steps} (cap=${steps})`);
343
+ if (active)
344
+ lines.push(`- active run: \`${active.run_id}\` (stage=${active.current_stage_key ?? "?"})`);
345
+ lines.push(``, `## Actions`);
346
+ for (const a of actions)
347
+ lines.push(`- ${a}`);
348
+ if (warnings.length > 0) {
349
+ lines.push(``, `## Warnings`);
350
+ for (const w of warnings)
351
+ lines.push(`⚠️ ${w}`);
327
352
  }
328
- actions.push(`ui: flushed ${uiEvents.length} event(s)`);
329
- }
330
- // Housekeeping event
331
- 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());
332
- const active = getActiveRun(db);
333
- const lines = [];
334
- lines.push(`# astro_workflow_proceed`);
335
- lines.push(`- mode: ${mode}`);
336
- lines.push(`- steps requested: ${max_steps} (cap=${steps})`);
337
- if (active)
338
- lines.push(`- active run: \`${active.run_id}\` (stage=${active.current_stage_key ?? "?"})`);
339
- lines.push(``, `## Actions`);
340
- for (const a of actions)
341
- lines.push(`- ${a}`);
342
- if (warnings.length > 0) {
343
- lines.push(``, `## Warnings`);
344
- for (const w of warnings)
345
- lines.push(`⚠️ ${w}`);
346
- }
347
- return lines.join("\n").trim();
348
- }
349
- finally {
350
- // Always release the lock
351
- repoLock.release();
352
- }
353
+ return lines.join("\n").trim();
354
+ },
355
+ });
353
356
  },
354
357
  });
355
358
  }
@@ -43,10 +43,10 @@ export function repairState(db, config) {
43
43
  .all(active.run_id);
44
44
  if (stageRuns.length < pipeline.length) {
45
45
  const existingKeys = new Set(stageRuns.map((s) => s.stage_key));
46
- const insert = db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, updated_at) VALUES (?, ?, ?, ?, 'pending', ?)");
46
+ const insert = db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)");
47
47
  pipeline.forEach((key, idx) => {
48
48
  if (!existingKeys.has(key)) {
49
- insert.run(newStageRunId(), active.run_id, key, idx, now);
49
+ insert.run(newStageRunId(), active.run_id, key, idx, now, now);
50
50
  push(report, `Inserted missing stage_run ${key} for run ${active.run_id}`);
51
51
  }
52
52
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astrocode-workflow",
3
- "version": "0.3.4",
3
+ "version": "0.3.5-1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -9,6 +9,11 @@ type ChatMessageInput = {
9
9
  agent: string;
10
10
  };
11
11
 
12
+ type ToolExecuteAfterInput = {
13
+ tool: string;
14
+ sessionID?: string;
15
+ };
16
+
12
17
  type RuntimeState = {
13
18
  db: SqliteDb | null;
14
19
  limitedMode: boolean;
@@ -156,5 +161,16 @@ export function createInjectProvider(opts: {
156
161
  // Inject eligible injects before processing the user's message
157
162
  await injectEligibleInjects(input.sessionID);
158
163
  },
164
+
165
+ async onToolAfter(input: ToolExecuteAfterInput) {
166
+ if (!config.inject?.enabled) return;
167
+
168
+ // Extract sessionID (same pattern as continuation enforcer)
169
+ const sessionId = input.sessionID ?? (ctx as any).sessionID;
170
+ if (!sessionId) return;
171
+
172
+ // Inject eligible injects after tool execution
173
+ await injectEligibleInjects(sessionId);
174
+ },
159
175
  };
160
176
  }