astrocode-workflow 0.4.0 → 0.4.2

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.
Files changed (144) hide show
  1. package/dist/index.js +6 -0
  2. package/dist/shared/metrics.d.ts +66 -0
  3. package/dist/shared/metrics.js +112 -0
  4. package/dist/src/agents/commands.d.ts +9 -0
  5. package/dist/src/agents/commands.js +121 -0
  6. package/dist/src/agents/prompts.d.ts +3 -0
  7. package/dist/src/agents/prompts.js +232 -0
  8. package/dist/src/agents/registry.d.ts +6 -0
  9. package/dist/src/agents/registry.js +242 -0
  10. package/dist/src/agents/types.d.ts +14 -0
  11. package/dist/src/agents/types.js +8 -0
  12. package/dist/src/astro/workflow-runner.d.ts +11 -0
  13. package/dist/src/astro/workflow-runner.js +14 -0
  14. package/dist/src/config/config-handler.d.ts +4 -0
  15. package/dist/src/config/config-handler.js +46 -0
  16. package/dist/src/config/defaults.d.ts +3 -0
  17. package/dist/src/config/defaults.js +3 -0
  18. package/dist/src/config/loader.d.ts +11 -0
  19. package/dist/src/config/loader.js +82 -0
  20. package/dist/src/config/schema.d.ts +195 -0
  21. package/dist/src/config/schema.js +224 -0
  22. package/dist/src/hooks/continuation-enforcer.d.ts +34 -0
  23. package/dist/src/hooks/continuation-enforcer.js +190 -0
  24. package/dist/src/hooks/inject-provider.d.ts +27 -0
  25. package/dist/src/hooks/inject-provider.js +189 -0
  26. package/dist/src/hooks/tool-output-truncator.d.ts +25 -0
  27. package/dist/src/hooks/tool-output-truncator.js +57 -0
  28. package/dist/src/index.d.ts +3 -0
  29. package/dist/src/index.js +307 -0
  30. package/dist/src/shared/deep-merge.d.ts +8 -0
  31. package/dist/src/shared/deep-merge.js +25 -0
  32. package/dist/src/shared/hash.d.ts +1 -0
  33. package/dist/src/shared/hash.js +4 -0
  34. package/dist/src/shared/log.d.ts +7 -0
  35. package/dist/src/shared/log.js +24 -0
  36. package/dist/src/shared/metrics.d.ts +66 -0
  37. package/dist/src/shared/metrics.js +112 -0
  38. package/dist/src/shared/model-tuning.d.ts +9 -0
  39. package/dist/src/shared/model-tuning.js +28 -0
  40. package/dist/src/shared/paths.d.ts +19 -0
  41. package/dist/src/shared/paths.js +64 -0
  42. package/dist/src/shared/text.d.ts +4 -0
  43. package/dist/src/shared/text.js +19 -0
  44. package/dist/src/shared/time.d.ts +1 -0
  45. package/dist/src/shared/time.js +3 -0
  46. package/dist/src/state/adapters/index.d.ts +41 -0
  47. package/dist/src/state/adapters/index.js +115 -0
  48. package/dist/src/state/db.d.ts +16 -0
  49. package/dist/src/state/db.js +225 -0
  50. package/dist/src/state/ids.d.ts +8 -0
  51. package/dist/src/state/ids.js +25 -0
  52. package/dist/src/state/repo-lock.d.ts +67 -0
  53. package/dist/src/state/repo-lock.js +580 -0
  54. package/dist/src/state/schema.d.ts +2 -0
  55. package/dist/src/state/schema.js +258 -0
  56. package/dist/src/state/types.d.ts +71 -0
  57. package/dist/src/state/types.js +1 -0
  58. package/dist/src/state/workflow-repo-lock.d.ts +23 -0
  59. package/dist/src/state/workflow-repo-lock.js +83 -0
  60. package/dist/src/tools/artifacts.d.ts +18 -0
  61. package/dist/src/tools/artifacts.js +71 -0
  62. package/dist/src/tools/health.d.ts +8 -0
  63. package/dist/src/tools/health.js +88 -0
  64. package/dist/src/tools/index.d.ts +20 -0
  65. package/dist/src/tools/index.js +94 -0
  66. package/dist/src/tools/init.d.ts +17 -0
  67. package/dist/src/tools/init.js +96 -0
  68. package/dist/src/tools/injects.d.ts +53 -0
  69. package/dist/src/tools/injects.js +325 -0
  70. package/dist/src/tools/lock.d.ts +4 -0
  71. package/dist/src/tools/lock.js +78 -0
  72. package/dist/src/tools/metrics.d.ts +7 -0
  73. package/dist/src/tools/metrics.js +61 -0
  74. package/dist/src/tools/repair.d.ts +8 -0
  75. package/dist/src/tools/repair.js +26 -0
  76. package/dist/src/tools/reset.d.ts +8 -0
  77. package/dist/src/tools/reset.js +92 -0
  78. package/dist/src/tools/run.d.ts +13 -0
  79. package/dist/src/tools/run.js +54 -0
  80. package/dist/src/tools/spec.d.ts +12 -0
  81. package/dist/src/tools/spec.js +44 -0
  82. package/dist/src/tools/stage.d.ts +23 -0
  83. package/dist/src/tools/stage.js +371 -0
  84. package/dist/src/tools/status.d.ts +8 -0
  85. package/dist/src/tools/status.js +125 -0
  86. package/dist/src/tools/story.d.ts +23 -0
  87. package/dist/src/tools/story.js +85 -0
  88. package/dist/src/tools/workflow.d.ts +13 -0
  89. package/dist/src/tools/workflow.js +345 -0
  90. package/dist/src/ui/inject.d.ts +12 -0
  91. package/dist/src/ui/inject.js +107 -0
  92. package/dist/src/ui/toasts.d.ts +13 -0
  93. package/dist/src/ui/toasts.js +39 -0
  94. package/dist/src/workflow/artifacts.d.ts +24 -0
  95. package/dist/src/workflow/artifacts.js +45 -0
  96. package/dist/src/workflow/baton.d.ts +72 -0
  97. package/dist/src/workflow/baton.js +166 -0
  98. package/dist/src/workflow/context.d.ts +20 -0
  99. package/dist/src/workflow/context.js +113 -0
  100. package/dist/src/workflow/directives.d.ts +39 -0
  101. package/dist/src/workflow/directives.js +137 -0
  102. package/dist/src/workflow/repair.d.ts +8 -0
  103. package/dist/src/workflow/repair.js +99 -0
  104. package/dist/src/workflow/state-machine.d.ts +86 -0
  105. package/dist/src/workflow/state-machine.js +216 -0
  106. package/dist/src/workflow/story-helpers.d.ts +9 -0
  107. package/dist/src/workflow/story-helpers.js +13 -0
  108. package/dist/state/db.d.ts +1 -0
  109. package/dist/state/db.js +9 -0
  110. package/dist/state/repo-lock.d.ts +3 -0
  111. package/dist/state/repo-lock.js +29 -0
  112. package/dist/test/integration/db-transactions.test.d.ts +1 -0
  113. package/dist/test/integration/db-transactions.test.js +126 -0
  114. package/dist/test/integration/injection-metrics.test.d.ts +1 -0
  115. package/dist/test/integration/injection-metrics.test.js +129 -0
  116. package/dist/tools/health.d.ts +8 -0
  117. package/dist/tools/health.js +119 -0
  118. package/dist/tools/index.js +9 -0
  119. package/dist/tools/metrics.d.ts +7 -0
  120. package/dist/tools/metrics.js +61 -0
  121. package/dist/tools/reset.d.ts +8 -0
  122. package/dist/tools/reset.js +92 -0
  123. package/dist/tools/workflow.js +178 -168
  124. package/dist/ui/inject.js +21 -9
  125. package/package.json +6 -4
  126. package/src/astro/workflow-runner.ts +16 -0
  127. package/src/config/schema.ts +1 -0
  128. package/src/hooks/inject-provider.ts +94 -14
  129. package/src/index.ts +7 -0
  130. package/src/shared/metrics.ts +148 -0
  131. package/src/state/db.ts +10 -1
  132. package/src/state/schema.ts +8 -1
  133. package/src/tools/health.ts +99 -0
  134. package/src/tools/index.ts +12 -3
  135. package/src/tools/init.ts +7 -6
  136. package/src/tools/metrics.ts +71 -0
  137. package/src/tools/repair.ts +8 -4
  138. package/src/tools/reset.ts +100 -0
  139. package/src/tools/stage.ts +1 -0
  140. package/src/tools/status.ts +2 -1
  141. package/src/tools/story.ts +1 -0
  142. package/src/tools/workflow.ts +2 -0
  143. package/src/ui/inject.ts +21 -9
  144. package/src/workflow/repair.ts +2 -2
@@ -9,6 +9,7 @@ import { nowISO } from "../shared/time";
9
9
  import { newEventId } from "../state/ids";
10
10
  import { debug } from "../shared/log";
11
11
  import { createToastManager } from "../ui/toasts";
12
+ import { acquireRepoLock } from "../state/repo-lock";
12
13
  // Agent name mapping for case-sensitive resolution
13
14
  export const STAGE_TO_AGENT_MAP = {
14
15
  frame: "Frame",
@@ -155,191 +156,200 @@ export function createAstroWorkflowProceedTool(opts) {
155
156
  max_steps: tool.schema.number().int().positive().default(config.workflow.default_max_steps),
156
157
  },
157
158
  execute: async ({ mode, max_steps }) => {
158
- const sessionId = ctx.sessionID;
159
- const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
160
- const actions = [];
161
- const warnings = [];
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);
166
- for (let i = 0; i < steps; i++) {
167
- const next = decideNextAction(db, config);
168
- if (next.kind === "idle") {
169
- actions.push("idle: no approved stories");
170
- break;
171
- }
172
- if (next.kind === "start_run") {
173
- // SINGLE tx boundary: caller owns tx, state-machine is pure.
174
- const { run_id } = withTx(db, () => createRunForStory(db, config, next.story_key));
175
- actions.push(`started run ${run_id} for story ${next.story_key}`);
176
- if (mode === "step")
177
- break;
178
- continue;
179
- }
180
- if (next.kind === "complete_run") {
181
- withTx(db, () => completeRun(db, next.run_id, emit));
182
- actions.push(`completed run ${next.run_id}`);
183
- if (mode === "step")
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");
184
175
  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
- if (mode === "step")
192
- break;
193
- continue;
194
- }
195
- if (next.kind === "delegate_stage") {
196
- const active = getActiveRun(db);
197
- if (!active)
198
- throw new Error("Invariant: delegate_stage but no active run.");
199
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(active.run_id);
200
- const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key);
201
- let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
202
- const agentExists = (name) => {
203
- if (agents && agents[name])
204
- return true;
205
- const knownStageAgents = ["Frame", "Plan", "Spec", "Implement", "Review", "Verify", "Close", "General", "Astro", "general"];
206
- if (knownStageAgents.includes(name))
207
- return true;
208
- return false;
209
- };
210
- if (!agentExists(agentName)) {
211
- const originalAgent = agentName;
212
- console.warn(`[Astrocode] Agent ${agentName} not found. Falling back to orchestrator.`);
213
- agentName = config.agents?.orchestrator_name || "Astro";
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")
189
+ 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
+ };
214
215
  if (!agentExists(agentName)) {
215
- console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
216
- agentName = "General";
216
+ const originalAgent = agentName;
217
+ console.warn(`[Astrocode] Agent ${agentName} not found. Falling back to orchestrator.`);
218
+ agentName = config.agents?.orchestrator_name || "Astro";
217
219
  if (!agentExists(agentName)) {
218
- throw new Error(`Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`);
220
+ console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
221
+ agentName = "General";
222
+ if (!agentExists(agentName)) {
223
+ throw new Error(`Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`);
224
+ }
219
225
  }
220
226
  }
221
- }
222
- // NOTE: startStage owns its own tx (state-machine.ts).
223
- withTx(db, () => {
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}`,
231
- });
232
- const stageDirective = buildStageDirective({
233
- config,
234
- stage_key: next.stage_key,
235
- run_id: active.run_id,
236
- story_key: run.story_key,
237
- story_title: story?.title ?? "(missing)",
238
- stage_agent_name: agentName,
239
- stage_goal: stageGoal(next.stage_key, config),
240
- stage_constraints: stageConstraints(next.stage_key, config),
241
- context_snapshot_md: context,
242
- }).body;
243
- const delegatePrompt = buildDelegationPrompt({
244
- stageDirective,
245
- run_id: active.run_id,
246
- stage_key: next.stage_key,
247
- stage_agent_name: agentName,
248
- });
249
- // Record continuation (best-effort; no tx wrapper needed but safe either way)
250
- const h = directiveHash(delegatePrompt);
251
- const now = nowISO();
252
- if (sessionId) {
253
- // This assumes continuations table exists in vNext schema.
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);
255
- }
256
- // Visible injection so user can see state (awaited)
257
- if (sessionId) {
258
- await injectChatPrompt({ ctx, sessionId, text: delegatePrompt, agent: "Astro" });
259
- const continueMessage = [
260
- `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_COMPLETION]`,
261
- ``,
262
- `Stage \`${next.stage_key}\` delegated to \`${agentName}\`.`,
263
- ``,
264
- `When \`${agentName}\` completes, call:`,
265
- `astro_stage_complete(run_id="${active.run_id}", stage_key="${next.stage_key}", output_text="[paste subagent output here]")`,
266
- ``,
267
- `This advances the workflow.`,
268
- ].join("\n");
269
- await injectChatPrompt({ ctx, sessionId, text: continueMessage, agent: "Astro" });
270
- }
271
- actions.push(`delegated stage ${next.stage_key} via ${agentName}`);
272
- // Stop here; subagent needs to run.
273
- break;
274
- }
275
- if (next.kind === "await_stage_completion") {
276
- actions.push(`await stage completion: ${next.stage_key}`);
277
- if (sessionId) {
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
+ });
278
231
  const context = buildContextSnapshot({
279
232
  db,
280
233
  config,
281
- run_id: next.run_id,
282
- next_action: `complete stage ${next.stage_key}`,
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,
283
253
  });
284
- const prompt = [
285
- `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_OUTPUT]`,
286
- ``,
287
- `Run \`${next.run_id}\` is waiting for stage \`${next.stage_key}\` output.`,
288
- `If you have the subagent output, call astro_stage_complete with output_text=the FULL output.`,
289
- ``,
290
- `Context snapshot:`,
291
- context,
292
- ].join("\n").trim();
293
- const h = directiveHash(prompt);
254
+ // Record continuation (best-effort; no tx wrapper needed but safe either way)
255
+ const h = directiveHash(delegatePrompt);
294
256
  const now = nowISO();
295
- 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);
296
- await injectChatPrompt({ ctx, sessionId, text: prompt, agent: "Astro" });
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) {
283
+ const context = buildContextSnapshot({
284
+ db,
285
+ config,
286
+ run_id: next.run_id,
287
+ next_action: `complete stage ${next.stage_key}`,
288
+ });
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);
299
+ 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" });
302
+ }
303
+ break;
297
304
  }
305
+ actions.push(`unhandled next action: ${next.kind}`);
298
306
  break;
299
307
  }
300
- actions.push(`unhandled next action: ${next.kind}`);
301
- break;
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
- });
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
+ });
326
+ }
321
327
  }
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}`);
322
346
  }
323
- actions.push(`ui: flushed ${uiEvents.length} event(s)`);
347
+ return lines.join("\n").trim();
324
348
  }
325
- // Housekeeping event
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());
327
- const active = getActiveRun(db);
328
- const lines = [];
329
- lines.push(`# astro_workflow_proceed`);
330
- lines.push(`- mode: ${mode}`);
331
- lines.push(`- steps requested: ${max_steps} (cap=${steps})`);
332
- if (active)
333
- lines.push(`- active run: \`${active.run_id}\` (stage=${active.current_stage_key ?? "?"})`);
334
- lines.push(``, `## Actions`);
335
- for (const a of actions)
336
- lines.push(`- ${a}`);
337
- if (warnings.length > 0) {
338
- lines.push(``, `## Warnings`);
339
- for (const w of warnings)
340
- lines.push(`⚠️ ${w}`);
349
+ finally {
350
+ // Always release the lock
351
+ repoLock.release();
341
352
  }
342
- return lines.join("\n").trim();
343
353
  },
344
354
  });
345
355
  }
package/dist/ui/inject.js CHANGED
@@ -1,3 +1,5 @@
1
+ // src/ui/inject.ts
2
+ import { recordInjection, recordError } from "../shared/metrics";
1
3
  const MAX_ATTEMPTS = 4;
2
4
  const RETRY_DELAYS_MS = [250, 500, 1000, 2000]; // attempt 1..4
3
5
  // Per-session queues so one stuck session doesn't block others
@@ -17,15 +19,24 @@ function getPromptInvoker(ctx) {
17
19
  async function tryInjectOnce(item) {
18
20
  const { ctx, sessionId, text, agent = "Astro" } = item;
19
21
  const prefixedText = `[${agent}]\n\n${text}`;
20
- const { session, prompt } = getPromptInvoker(ctx);
21
- // IMPORTANT: force correct `this` binding
22
- await prompt.call(session, {
23
- path: { id: sessionId },
24
- body: {
25
- parts: [{ type: "text", text: prefixedText }],
26
- agent,
27
- },
28
- });
22
+ const injectionRecorder = recordInjection({ sessionId, attempts: item.attempts + 1, agent });
23
+ const injectionStart = injectionRecorder.start();
24
+ try {
25
+ const { session, prompt } = getPromptInvoker(ctx);
26
+ // IMPORTANT: force correct `this` binding
27
+ await prompt.call(session, {
28
+ path: { id: sessionId },
29
+ body: {
30
+ parts: [{ type: "text", text: prefixedText }],
31
+ agent,
32
+ },
33
+ });
34
+ injectionRecorder.end(injectionStart, true);
35
+ }
36
+ catch (error) {
37
+ injectionRecorder.end(injectionStart, false);
38
+ throw error;
39
+ }
29
40
  }
30
41
  async function runSessionQueue(sessionId) {
31
42
  if (running.has(sessionId))
@@ -48,6 +59,7 @@ async function runSessionQueue(sessionId) {
48
59
  const delay = RETRY_DELAYS_MS[Math.min(item.attempts - 1, RETRY_DELAYS_MS.length - 1)] ?? 2000;
49
60
  if (item.attempts >= MAX_ATTEMPTS) {
50
61
  console.warn(`[Astrocode] Injection failed permanently after ${item.attempts} attempts: ${msg}`);
62
+ recordError("injection_failure", `Injection failed after ${item.attempts} attempts: ${msg}`);
51
63
  item.reject(err);
52
64
  continue;
53
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astrocode-workflow",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,12 +13,13 @@
13
13
  "scripts": {
14
14
  "build": "tsc -p tsconfig.json",
15
15
  "typecheck": "tsc -p tsconfig.json --noEmit",
16
- "clean": "rm -rf dist"
16
+ "clean": "rm -rf dist",
17
+ "test": "vitest",
18
+ "test:run": "vitest run"
17
19
  },
18
20
  "dependencies": {
19
21
  "@opencode-ai/plugin": "^1.1.19",
20
22
  "@opencode-ai/sdk": "^1.1.19",
21
- "astrocode-workflow": "^0.1.51",
22
23
  "jsonc-parser": "^3.2.0",
23
24
  "zod": "4.1.8"
24
25
  },
@@ -28,6 +29,7 @@
28
29
  "devDependencies": {
29
30
  "@types/better-sqlite3": "^7.6.12",
30
31
  "@types/node": "^20.12.12",
31
- "typescript": "^5.6.3"
32
+ "typescript": "^5.6.3",
33
+ "vitest": "^1.6.0"
32
34
  }
33
35
  }
@@ -0,0 +1,16 @@
1
+ // src/astro/workflow-runner.ts
2
+
3
+ /**
4
+ * Executes the workflow loop.
5
+ * Everything that mutates the repo (tool calls, steps) runs inside this scope.
6
+ *
7
+ * Replace the internals with your actual astro/opencode driver loop.
8
+ */
9
+ export async function runAstroWorkflow(opts: {
10
+ proceedOneStep: () => Promise<{ done: boolean }>;
11
+ }): Promise<void> {
12
+ while (true) {
13
+ const { done } = await opts.proceedOneStep();
14
+ if (done) return;
15
+ }
16
+ }
@@ -203,6 +203,7 @@ const InjectSchema = z
203
203
  scope_allowlist: z.array(z.string()).default(["repo", "global"]),
204
204
  type_allowlist: z.array(z.string()).default(["note", "policy"]),
205
205
  max_per_turn: z.number().int().positive().default(5),
206
+ auto_approve_queued_stories: z.boolean().default(false).describe("Auto-approve highest priority queued story after workflow tools"),
206
207
  })
207
208
  .partial()
208
209
  .default({});
@@ -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;
@@ -24,19 +29,30 @@ export function createInjectProvider(opts: {
24
29
  const { db } = runtime;
25
30
 
26
31
  // Cache to avoid re-injecting the same injects repeatedly
32
+ // Map of inject_id -> last injected timestamp
27
33
  const injectedCache = new Map<string, number>();
28
34
 
29
35
  function shouldSkipInject(injectId: string, nowMs: number): boolean {
30
36
  const lastInjected = injectedCache.get(injectId);
31
37
  if (!lastInjected) return false;
32
38
 
33
- // Skip if injected within the last 5 minutes (configurable?)
34
- const cooldownMs = 5 * 60 * 1000;
39
+ // REDUCED cooldown from 5 minutes to 1 minute
40
+ // This allows injects to appear more frequently during workflow
41
+ const cooldownMs = 1 * 60 * 1000;
35
42
  return nowMs - lastInjected < cooldownMs;
36
43
  }
37
44
 
38
45
  function markInjected(injectId: string, nowMs: number) {
39
46
  injectedCache.set(injectId, nowMs);
47
+
48
+ // Clean up old entries to prevent memory leak
49
+ // Remove entries older than 10 minutes
50
+ const tenMinutesAgo = nowMs - (10 * 60 * 1000);
51
+ for (const [id, timestamp] of injectedCache.entries()) {
52
+ if (timestamp < tenMinutesAgo) {
53
+ injectedCache.delete(id);
54
+ }
55
+ }
40
56
  }
41
57
 
42
58
  function getInjectionDiagnostics(nowIso: string, scopeAllowlist: string[], typeAllowlist: string[]): any {
@@ -89,7 +105,7 @@ export function createInjectProvider(opts: {
89
105
  };
90
106
  }
91
107
 
92
- async function injectEligibleInjects(sessionId: string) {
108
+ async function injectEligibleInjects(sessionId: string, context?: string) {
93
109
  const now = nowISO();
94
110
  const nowMs = Date.now();
95
111
 
@@ -115,7 +131,7 @@ export function createInjectProvider(opts: {
115
131
  // Log when no injects are eligible
116
132
  if (EMIT_TELEMETRY) {
117
133
  // eslint-disable-next-line no-console
118
- console.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=0 skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:0}`);
134
+ console.log(`[Astrocode:inject] ${now} context=${context ?? 'unknown'} selected=0 injected=0 skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:0}`);
119
135
  }
120
136
  return;
121
137
  }
@@ -130,21 +146,68 @@ export function createInjectProvider(opts: {
130
146
  // Format as injection message
131
147
  const formattedText = `[Inject: ${inject.title}]\n\n${inject.body_md}`;
132
148
 
149
+ try {
150
+ await injectChatPrompt({
151
+ ctx,
152
+ sessionId,
153
+ text: formattedText,
154
+ agent: "Astrocode"
155
+ });
156
+
157
+ injected++;
158
+ markInjected(inject.inject_id, nowMs);
159
+ } catch (err) {
160
+ // Log injection failures but don't crash
161
+ // eslint-disable-next-line no-console
162
+ console.error(`[Astrocode:inject] Failed to inject ${inject.inject_id}:`, err);
163
+ }
164
+ }
165
+
166
+ // Log diagnostic summary
167
+ if (EMIT_TELEMETRY || injected > 0) {
168
+ // eslint-disable-next-line no-console
169
+ console.log(`[Astrocode:inject] ${now} context=${context ?? 'unknown'} selected=${diagnostics.selected_eligible} injected=${injected} skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:${skippedDeduped}}`);
170
+ }
171
+ }
172
+
173
+ // Workflow-related tools that should trigger inject + auto-approval
174
+ const WORKFLOW_TOOLS = new Set([
175
+ 'astro_workflow_proceed',
176
+ 'astro_story_queue',
177
+ 'astro_story_approve',
178
+ 'astro_stage_start',
179
+ 'astro_stage_complete',
180
+ 'astro_stage_fail',
181
+ 'astro_run_abort',
182
+ ]);
183
+
184
+ // Auto-approve queued stories if enabled
185
+ async function maybeAutoApprove(sessionId: string) {
186
+ if (!config.inject?.auto_approve_queued_stories) return;
187
+
188
+ try {
189
+ // Get all queued stories
190
+ const queued = db.prepare("SELECT story_key, title FROM stories WHERE state='queued' ORDER BY priority DESC, created_at ASC").all() as Array<{ story_key: string; title: string }>;
191
+
192
+ if (queued.length === 0) return;
193
+
194
+ // Auto-approve the highest priority queued story
195
+ const story = queued[0];
196
+ db.prepare("UPDATE stories SET state='approved', updated_at=? WHERE story_key=?").run(nowISO(), story.story_key);
197
+
198
+ // eslint-disable-next-line no-console
199
+ console.log(`[Astrocode:inject] Auto-approved story ${story.story_key}: ${story.title}`);
200
+
201
+ // Inject a notification about the auto-approval
133
202
  await injectChatPrompt({
134
203
  ctx,
135
204
  sessionId,
136
- text: formattedText,
205
+ text: `✅ Auto-approved story ${story.story_key}: ${story.title}`,
137
206
  agent: "Astrocode"
138
207
  });
139
-
140
- injected++;
141
- markInjected(inject.inject_id, nowMs);
142
- }
143
-
144
- // Log diagnostic summary
145
- if (EMIT_TELEMETRY) {
208
+ } catch (err) {
146
209
  // eslint-disable-next-line no-console
147
- console.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=${injected} skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:${skippedDeduped}}`);
210
+ console.error(`[Astrocode:inject] Auto-approval failed:`, err);
148
211
  }
149
212
  }
150
213
 
@@ -154,7 +217,24 @@ export function createInjectProvider(opts: {
154
217
  if (!config.inject?.enabled) return;
155
218
 
156
219
  // Inject eligible injects before processing the user's message
157
- await injectEligibleInjects(input.sessionID);
220
+ await injectEligibleInjects(input.sessionID, 'chat_message');
221
+ },
222
+
223
+ async onToolAfter(input: ToolExecuteAfterInput) {
224
+ if (!config.inject?.enabled) return;
225
+
226
+ // Only inject after workflow-related tools
227
+ if (!WORKFLOW_TOOLS.has(input.tool)) return;
228
+
229
+ // Extract sessionID (same pattern as continuation enforcer)
230
+ const sessionId = input.sessionID ?? (ctx as any).sessionID;
231
+ if (!sessionId) return;
232
+
233
+ // Auto-approve queued stories if enabled
234
+ await maybeAutoApprove(sessionId);
235
+
236
+ // Inject eligible injects after workflow tool execution
237
+ await injectEligibleInjects(sessionId, `tool_after:${input.tool}`);
158
238
  },
159
239
  };
160
240
  }