astrocode-workflow 0.3.1 → 0.3.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 (134) 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/config/config-handler.d.ts +4 -0
  13. package/dist/src/config/config-handler.js +46 -0
  14. package/dist/src/config/defaults.d.ts +3 -0
  15. package/dist/src/config/defaults.js +3 -0
  16. package/dist/src/config/loader.d.ts +11 -0
  17. package/dist/src/config/loader.js +82 -0
  18. package/dist/src/config/schema.d.ts +194 -0
  19. package/dist/src/config/schema.js +223 -0
  20. package/dist/src/hooks/continuation-enforcer.d.ts +34 -0
  21. package/dist/src/hooks/continuation-enforcer.js +190 -0
  22. package/dist/src/hooks/inject-provider.d.ts +22 -0
  23. package/dist/src/hooks/inject-provider.js +120 -0
  24. package/dist/src/hooks/tool-output-truncator.d.ts +25 -0
  25. package/dist/src/hooks/tool-output-truncator.js +57 -0
  26. package/dist/src/index.d.ts +3 -0
  27. package/dist/src/index.js +308 -0
  28. package/dist/src/shared/deep-merge.d.ts +8 -0
  29. package/dist/src/shared/deep-merge.js +25 -0
  30. package/dist/src/shared/hash.d.ts +1 -0
  31. package/dist/src/shared/hash.js +4 -0
  32. package/dist/src/shared/log.d.ts +7 -0
  33. package/dist/src/shared/log.js +24 -0
  34. package/dist/src/shared/metrics.d.ts +66 -0
  35. package/dist/src/shared/metrics.js +112 -0
  36. package/dist/src/shared/model-tuning.d.ts +9 -0
  37. package/dist/src/shared/model-tuning.js +28 -0
  38. package/dist/src/shared/paths.d.ts +19 -0
  39. package/dist/src/shared/paths.js +64 -0
  40. package/dist/src/shared/text.d.ts +4 -0
  41. package/dist/src/shared/text.js +19 -0
  42. package/dist/src/shared/time.d.ts +1 -0
  43. package/dist/src/shared/time.js +3 -0
  44. package/dist/src/state/adapters/index.d.ts +41 -0
  45. package/dist/src/state/adapters/index.js +115 -0
  46. package/dist/src/state/db.d.ts +16 -0
  47. package/dist/src/state/db.js +225 -0
  48. package/dist/src/state/ids.d.ts +8 -0
  49. package/dist/src/state/ids.js +25 -0
  50. package/dist/src/state/repo-lock.d.ts +3 -0
  51. package/dist/src/state/repo-lock.js +29 -0
  52. package/dist/src/state/schema.d.ts +2 -0
  53. package/dist/src/state/schema.js +251 -0
  54. package/dist/src/state/types.d.ts +71 -0
  55. package/dist/src/state/types.js +1 -0
  56. package/dist/src/tools/artifacts.d.ts +18 -0
  57. package/dist/src/tools/artifacts.js +71 -0
  58. package/dist/src/tools/health.d.ts +8 -0
  59. package/dist/src/tools/health.js +119 -0
  60. package/dist/src/tools/index.d.ts +20 -0
  61. package/dist/src/tools/index.js +94 -0
  62. package/dist/src/tools/init.d.ts +17 -0
  63. package/dist/src/tools/init.js +96 -0
  64. package/dist/src/tools/injects.d.ts +53 -0
  65. package/dist/src/tools/injects.js +325 -0
  66. package/dist/src/tools/metrics.d.ts +7 -0
  67. package/dist/src/tools/metrics.js +61 -0
  68. package/dist/src/tools/repair.d.ts +8 -0
  69. package/dist/src/tools/repair.js +25 -0
  70. package/dist/src/tools/reset.d.ts +8 -0
  71. package/dist/src/tools/reset.js +92 -0
  72. package/dist/src/tools/run.d.ts +13 -0
  73. package/dist/src/tools/run.js +54 -0
  74. package/dist/src/tools/spec.d.ts +12 -0
  75. package/dist/src/tools/spec.js +44 -0
  76. package/dist/src/tools/stage.d.ts +23 -0
  77. package/dist/src/tools/stage.js +371 -0
  78. package/dist/src/tools/status.d.ts +8 -0
  79. package/dist/src/tools/status.js +125 -0
  80. package/dist/src/tools/story.d.ts +23 -0
  81. package/dist/src/tools/story.js +85 -0
  82. package/dist/src/tools/workflow.d.ts +13 -0
  83. package/dist/src/tools/workflow.js +355 -0
  84. package/dist/src/ui/inject.d.ts +12 -0
  85. package/dist/src/ui/inject.js +107 -0
  86. package/dist/src/ui/toasts.d.ts +13 -0
  87. package/dist/src/ui/toasts.js +39 -0
  88. package/dist/src/workflow/artifacts.d.ts +24 -0
  89. package/dist/src/workflow/artifacts.js +45 -0
  90. package/dist/src/workflow/baton.d.ts +72 -0
  91. package/dist/src/workflow/baton.js +166 -0
  92. package/dist/src/workflow/context.d.ts +20 -0
  93. package/dist/src/workflow/context.js +113 -0
  94. package/dist/src/workflow/directives.d.ts +39 -0
  95. package/dist/src/workflow/directives.js +137 -0
  96. package/dist/src/workflow/repair.d.ts +8 -0
  97. package/dist/src/workflow/repair.js +99 -0
  98. package/dist/src/workflow/state-machine.d.ts +86 -0
  99. package/dist/src/workflow/state-machine.js +216 -0
  100. package/dist/src/workflow/story-helpers.d.ts +9 -0
  101. package/dist/src/workflow/story-helpers.js +13 -0
  102. package/dist/state/db.d.ts +1 -0
  103. package/dist/state/db.js +9 -0
  104. package/dist/state/repo-lock.d.ts +3 -0
  105. package/dist/state/repo-lock.js +29 -0
  106. package/dist/test/integration/db-transactions.test.d.ts +1 -0
  107. package/dist/test/integration/db-transactions.test.js +126 -0
  108. package/dist/test/integration/injection-metrics.test.d.ts +1 -0
  109. package/dist/test/integration/injection-metrics.test.js +129 -0
  110. package/dist/tools/health.d.ts +8 -0
  111. package/dist/tools/health.js +119 -0
  112. package/dist/tools/index.js +9 -0
  113. package/dist/tools/metrics.d.ts +7 -0
  114. package/dist/tools/metrics.js +61 -0
  115. package/dist/tools/reset.d.ts +8 -0
  116. package/dist/tools/reset.js +92 -0
  117. package/dist/tools/workflow.js +178 -168
  118. package/dist/ui/inject.js +21 -9
  119. package/package.json +6 -3
  120. package/src/index.ts +8 -0
  121. package/src/shared/metrics.ts +148 -0
  122. package/src/state/db.ts +10 -1
  123. package/src/state/repo-lock.ts +158 -0
  124. package/src/tools/health.ts +128 -0
  125. package/src/tools/index.ts +12 -3
  126. package/src/tools/init.ts +26 -14
  127. package/src/tools/metrics.ts +71 -0
  128. package/src/tools/repair.ts +21 -8
  129. package/src/tools/reset.ts +100 -0
  130. package/src/tools/stage.ts +12 -0
  131. package/src/tools/status.ts +17 -3
  132. package/src/tools/story.ts +41 -15
  133. package/src/tools/workflow.ts +15 -1
  134. package/src/ui/inject.ts +21 -9
@@ -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.3.1",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,7 +13,9 @@
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",
@@ -28,6 +30,7 @@
28
30
  "devDependencies": {
29
31
  "@types/better-sqlite3": "^7.6.12",
30
32
  "@types/node": "^20.12.12",
31
- "typescript": "^5.6.3"
33
+ "typescript": "^5.6.3",
34
+ "vitest": "^1.6.0"
32
35
  }
33
36
  }
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import { createToastManager, type ToastOptions } from "./ui/toasts";
13
13
  import { createAstroAgents } from "./agents/registry";
14
14
  import type { AgentConfig } from "@opencode-ai/sdk";
15
15
  import { info, warn } from "./shared/log";
16
+ import { acquireRepoLock } from "./state/repo-lock";
16
17
 
17
18
  // Type definitions for plugin components
18
19
  type ConfigHandler = (config: Record<string, any>) => Promise<void>;
@@ -58,6 +59,10 @@ const Astrocode: Plugin = async (ctx) => {
58
59
  }
59
60
  const repoRoot = ctx.directory;
60
61
 
62
+ // Acquire exclusive repo lock to prevent multiple processes from corrupting the database
63
+ const lockPath = `${repoRoot}/.astro/astro.lock`;
64
+ const repoLock = acquireRepoLock(lockPath);
65
+
61
66
  // Always load config first - this provides defaults even in limited mode
62
67
  let pluginConfig: AstrocodeConfig;
63
68
  try {
@@ -325,6 +330,9 @@ const Astrocode: Plugin = async (ctx) => {
325
330
 
326
331
  // Best-effort cleanup
327
332
  close: async () => {
333
+ // Release repo lock first (important for process termination)
334
+ repoLock.release();
335
+
328
336
  if (db && typeof db.close === "function") {
329
337
  try {
330
338
  db.close();
@@ -0,0 +1,148 @@
1
+ // src/shared/metrics.ts
2
+
3
+ export interface TransactionMetrics {
4
+ startTime: number;
5
+ duration: number;
6
+ success: boolean;
7
+ nestedDepth: number;
8
+ operation?: string;
9
+ }
10
+
11
+ export interface InjectionMetrics {
12
+ sessionId: string;
13
+ attempts: number;
14
+ duration: number;
15
+ success: boolean;
16
+ agent?: string;
17
+ }
18
+
19
+ export interface SystemMetrics {
20
+ transactions: TransactionMetrics[];
21
+ injections: InjectionMetrics[];
22
+ errors: Array<{ type: string; message: string; timestamp: number }>;
23
+ }
24
+
25
+ class MetricsCollector {
26
+ private transactions: TransactionMetrics[] = [];
27
+ private injections: InjectionMetrics[] = [];
28
+ private errors: Array<{ type: string; message: string; timestamp: number }> = [];
29
+ private maxEntries = 1000; // Keep last 1000 entries per type
30
+
31
+ recordTransaction(metrics: TransactionMetrics) {
32
+ this.transactions.push(metrics);
33
+ if (this.transactions.length > this.maxEntries) {
34
+ this.transactions.shift();
35
+ }
36
+ }
37
+
38
+ recordInjection(metrics: InjectionMetrics) {
39
+ this.injections.push(metrics);
40
+ if (this.injections.length > this.maxEntries) {
41
+ this.injections.shift();
42
+ }
43
+ }
44
+
45
+ recordError(type: string, message: string) {
46
+ this.errors.push({ type, message, timestamp: Date.now() });
47
+ if (this.errors.length > this.maxEntries) {
48
+ this.errors.shift();
49
+ }
50
+ }
51
+
52
+ getMetrics(): SystemMetrics {
53
+ return {
54
+ transactions: [...this.transactions],
55
+ injections: [...this.injections],
56
+ errors: [...this.errors],
57
+ };
58
+ }
59
+
60
+ getTransactionStats() {
61
+ const txs = this.transactions;
62
+ if (txs.length === 0) return null;
63
+
64
+ const successful = txs.filter(t => t.success);
65
+ const failed = txs.filter(t => !t.success);
66
+
67
+ return {
68
+ total: txs.length,
69
+ successful: successful.length,
70
+ failed: failed.length,
71
+ successRate: successful.length / txs.length,
72
+ avgDuration: txs.reduce((sum, t) => sum + t.duration, 0) / txs.length,
73
+ avgNestedDepth: txs.reduce((sum, t) => sum + t.nestedDepth, 0) / txs.length,
74
+ minDuration: Math.min(...txs.map(t => t.duration)),
75
+ maxDuration: Math.max(...txs.map(t => t.duration)),
76
+ };
77
+ }
78
+
79
+ getInjectionStats() {
80
+ const injections = this.injections;
81
+ if (injections.length === 0) return null;
82
+
83
+ const successful = injections.filter(i => i.success);
84
+ const failed = injections.filter(i => !i.success);
85
+
86
+ return {
87
+ total: injections.length,
88
+ successful: successful.length,
89
+ failed: failed.length,
90
+ successRate: successful.length / injections.length,
91
+ avgAttempts: injections.reduce((sum, i) => sum + i.attempts, 0) / injections.length,
92
+ avgDuration: injections.reduce((sum, i) => sum + i.duration, 0) / injections.length,
93
+ totalRetries: injections.reduce((sum, i) => sum + Math.max(0, i.attempts - 1), 0),
94
+ };
95
+ }
96
+
97
+ clear() {
98
+ this.transactions = [];
99
+ this.injections = [];
100
+ this.errors = [];
101
+ }
102
+ }
103
+
104
+ // Global singleton
105
+ export const metrics = new MetricsCollector();
106
+
107
+ // Convenience functions
108
+ export function recordTransaction(metricsData: Omit<TransactionMetrics, 'startTime' | 'duration' | 'success'>) {
109
+ return {
110
+ start() {
111
+ return {
112
+ ...metricsData,
113
+ startTime: Date.now(),
114
+ };
115
+ },
116
+ end(startData: ReturnType<ReturnType<typeof recordTransaction>['start']>, success: boolean) {
117
+ const duration = Date.now() - startData.startTime;
118
+ metrics.recordTransaction({
119
+ ...startData,
120
+ duration,
121
+ success,
122
+ });
123
+ },
124
+ };
125
+ }
126
+
127
+ export function recordInjection(metricsData: Omit<InjectionMetrics, 'duration' | 'success'>) {
128
+ return {
129
+ start() {
130
+ return {
131
+ ...metricsData,
132
+ startTime: Date.now(),
133
+ };
134
+ },
135
+ end(startData: any, success: boolean) {
136
+ const duration = Date.now() - startData.startTime;
137
+ metrics.recordInjection({
138
+ ...startData,
139
+ duration,
140
+ success,
141
+ });
142
+ },
143
+ };
144
+ }
145
+
146
+ export function recordError(type: string, message: string) {
147
+ metrics.recordError(type, message);
148
+ }