astrocode-workflow 0.3.0 → 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 (139) 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 +210 -215
  118. package/dist/ui/inject.d.ts +6 -0
  119. package/dist/ui/inject.js +86 -67
  120. package/dist/workflow/state-machine.d.ts +32 -32
  121. package/dist/workflow/state-machine.js +85 -170
  122. package/package.json +6 -3
  123. package/src/index.ts +8 -0
  124. package/src/shared/metrics.ts +148 -0
  125. package/src/state/db.ts +10 -1
  126. package/src/state/repo-lock.ts +158 -0
  127. package/src/tools/health.ts +128 -0
  128. package/src/tools/index.ts +12 -3
  129. package/src/tools/init.ts +26 -14
  130. package/src/tools/metrics.ts +71 -0
  131. package/src/tools/repair.ts +21 -8
  132. package/src/tools/reset.ts +100 -0
  133. package/src/tools/stage.ts +12 -0
  134. package/src/tools/status.ts +17 -3
  135. package/src/tools/story.ts +41 -15
  136. package/src/tools/workflow.ts +123 -121
  137. package/src/ui/inject.ts +113 -79
  138. package/src/workflow/state-machine.ts +123 -227
  139. package/src/tools/workflow.ts.backup +0 -681
package/dist/ui/inject.js CHANGED
@@ -1,88 +1,107 @@
1
1
  // src/ui/inject.ts
2
- let isInjecting = false;
3
- const injectionQueue = [];
2
+ import { recordInjection, recordError } from "../shared/metrics";
3
+ const MAX_ATTEMPTS = 4;
4
+ const RETRY_DELAYS_MS = [250, 500, 1000, 2000]; // attempt 1..4
5
+ // Per-session queues so one stuck session doesn't block others
6
+ const queues = new Map();
7
+ const running = new Set();
4
8
  function sleep(ms) {
5
9
  return new Promise((r) => setTimeout(r, ms));
6
10
  }
7
- function resolveSessionId(ctx, sessionId) {
8
- if (sessionId)
9
- return sessionId;
10
- const direct = ctx?.sessionID ?? ctx?.sessionId ?? ctx?.session?.id;
11
- if (typeof direct === "string" && direct.length > 0)
12
- return direct;
13
- return null;
14
- }
15
- async function tryInjectOnce(opts) {
16
- const { ctx, sessionId, text, agent } = opts;
17
- // Prefer explicit chat prompt API
18
- const promptApi = ctx?.client?.session?.prompt;
19
- if (!promptApi) {
11
+ function getPromptInvoker(ctx) {
12
+ const session = ctx?.client?.session;
13
+ const prompt = session?.prompt;
14
+ if (!session || typeof prompt !== "function") {
20
15
  throw new Error("API not available (ctx.client.session.prompt)");
21
16
  }
17
+ return { session, prompt };
18
+ }
19
+ async function tryInjectOnce(item) {
20
+ const { ctx, sessionId, text, agent = "Astro" } = item;
22
21
  const prefixedText = `[${agent}]\n\n${text}`;
23
- // Some hosts reject unknown fields; keep body minimal and stable.
24
- await promptApi({
25
- path: { id: sessionId },
26
- body: {
27
- parts: [{ type: "text", text: prefixedText }],
28
- },
29
- });
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
+ }
30
40
  }
31
- async function processQueue() {
32
- if (isInjecting)
41
+ async function runSessionQueue(sessionId) {
42
+ if (running.has(sessionId))
33
43
  return;
34
- if (injectionQueue.length === 0)
35
- return;
36
- isInjecting = true;
44
+ running.add(sessionId);
37
45
  try {
38
- while (injectionQueue.length > 0) {
39
- const item = injectionQueue.shift();
40
- if (!item)
41
- continue;
42
- const { ctx, text, agent = "Astro" } = item;
43
- const sessionId = resolveSessionId(ctx, item.sessionId);
44
- if (!sessionId) {
45
- // Drop on floor: we cannot recover without a session id.
46
- // Keep draining the queue so we don't stall.
47
- // eslint-disable-next-line no-console
48
- console.warn("[Astrocode] Injection skipped: no sessionId");
49
- continue;
46
+ // eslint-disable-next-line no-constant-condition
47
+ while (true) {
48
+ const q = queues.get(sessionId);
49
+ if (!q || q.length === 0)
50
+ break;
51
+ const item = q.shift();
52
+ try {
53
+ await tryInjectOnce(item);
54
+ item.resolve();
50
55
  }
51
- const maxAttempts = item.retry?.maxAttempts ?? 4;
52
- const baseDelayMs = item.retry?.baseDelayMs ?? 250;
53
- let lastErr = null;
54
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
55
- try {
56
- await tryInjectOnce({ ctx, sessionId, text, agent });
57
- lastErr = null;
58
- break;
59
- }
60
- catch (e) {
61
- lastErr = e;
62
- const delay = baseDelayMs * Math.pow(2, attempt - 1); // 250, 500, 1000, 2000
63
- // eslint-disable-next-line no-console
64
- console.warn(`[Astrocode] Injection attempt ${attempt}/${maxAttempts} failed: ${String(e)}; retrying in ${delay}ms`);
65
- await sleep(delay);
56
+ catch (err) {
57
+ item.attempts += 1;
58
+ const msg = err instanceof Error ? err.message : String(err);
59
+ const delay = RETRY_DELAYS_MS[Math.min(item.attempts - 1, RETRY_DELAYS_MS.length - 1)] ?? 2000;
60
+ if (item.attempts >= MAX_ATTEMPTS) {
61
+ console.warn(`[Astrocode] Injection failed permanently after ${item.attempts} attempts: ${msg}`);
62
+ recordError("injection_failure", `Injection failed after ${item.attempts} attempts: ${msg}`);
63
+ item.reject(err);
64
+ continue;
66
65
  }
67
- }
68
- if (lastErr) {
69
- // eslint-disable-next-line no-console
70
- console.warn(`[Astrocode] Injection failed permanently after ${maxAttempts} attempts: ${String(lastErr)}`);
66
+ console.warn(`[Astrocode] Injection attempt ${item.attempts}/${MAX_ATTEMPTS} failed: ${msg}; retrying in ${delay}ms`);
67
+ await sleep(delay);
68
+ // Requeue at front to preserve order (and avoid starving later messages)
69
+ const q2 = queues.get(sessionId) ?? [];
70
+ q2.unshift(item);
71
+ queues.set(sessionId, q2);
71
72
  }
72
73
  }
73
74
  }
74
75
  finally {
75
- isInjecting = false;
76
+ running.delete(sessionId);
76
77
  }
77
78
  }
79
+ /**
80
+ * Inject a visible prompt into the conversation.
81
+ * - Deterministic ordering per session
82
+ * - Correct SDK binding (prevents `this._client` undefined)
83
+ * - Awaitable: resolves when delivered, rejects after max retries
84
+ */
78
85
  export async function injectChatPrompt(opts) {
79
- injectionQueue.push({
80
- ctx: opts.ctx,
81
- sessionId: opts.sessionId,
82
- text: opts.text,
83
- agent: opts.agent ?? "Astro",
84
- retry: { maxAttempts: 4, baseDelayMs: 250 },
86
+ const sessionId = opts.sessionId ?? opts.ctx?.sessionID;
87
+ if (!sessionId) {
88
+ console.warn("[Astrocode] Skipping injection: No sessionId provided");
89
+ return;
90
+ }
91
+ return new Promise((resolve, reject) => {
92
+ const item = {
93
+ ctx: opts.ctx,
94
+ sessionId,
95
+ text: opts.text,
96
+ agent: opts.agent,
97
+ attempts: 0,
98
+ resolve,
99
+ reject,
100
+ };
101
+ const q = queues.get(sessionId) ?? [];
102
+ q.push(item);
103
+ queues.set(sessionId, q);
104
+ // Fire worker (don't await here; caller awaits the returned Promise)
105
+ void runSessionQueue(sessionId);
85
106
  });
86
- // Fire-and-forget; queue drain is serialized by isInjecting.
87
- void processQueue();
88
107
  }
@@ -1,7 +1,6 @@
1
1
  import type { AstrocodeConfig } from "../config/schema";
2
2
  import type { SqliteDb } from "../state/db";
3
3
  import type { RunRow, StageKey, StageRunRow, StoryRow } from "../state/types";
4
- import type { ToastOptions } from "../ui/toasts";
5
4
  export declare const EVENT_TYPES: {
6
5
  readonly RUN_STARTED: "run.started";
7
6
  readonly RUN_COMPLETED: "run.completed";
@@ -11,28 +10,39 @@ export declare const EVENT_TYPES: {
11
10
  readonly STAGE_STARTED: "stage.started";
12
11
  readonly WORKFLOW_PROCEED: "workflow.proceed";
13
12
  };
14
- /**
15
- * UI HOOKS
16
- * --------
17
- * This workflow module is DB-first. UI emission is optional and happens AFTER the DB tx commits.
18
- *
19
- * Contract:
20
- * - If you pass ui.ctx + ui.sessionId, we will inject a visible chat message deterministically.
21
- * - If you pass ui.toast, we will also toast (throttling is handled by the toast manager).
22
- */
23
- export type WorkflowUi = {
24
- ctx: any;
25
- sessionId: string;
26
- agentName?: string;
27
- toast?: (t: ToastOptions) => Promise<void>;
13
+ export type UiEmitEvent = {
14
+ kind: "stage_started";
15
+ run_id: string;
16
+ stage_key: StageKey;
17
+ agent_name?: string;
18
+ } | {
19
+ kind: "run_completed";
20
+ run_id: string;
21
+ story_key: string;
22
+ } | {
23
+ kind: "run_failed";
24
+ run_id: string;
25
+ story_key: string;
26
+ stage_key: StageKey;
27
+ error_text: string;
28
28
  };
29
+ export type UiEmit = (e: UiEmitEvent) => void;
29
30
  /**
30
31
  * PLANNING-FIRST REDESIGN
31
32
  * ----------------------
32
- * - Never mutate story title/body.
33
- * - Planning-first becomes a run-scoped inject stored in `injects` (scope = run:<run_id>).
34
- * - Trigger is deterministic via config.workflow.genesis_planning:
35
- * - "off" | "first_story_only" | "always"
33
+ * Never mutate story title/body.
34
+ *
35
+ * Deterministic trigger:
36
+ * - config.workflow.genesis_planning:
37
+ * - "off" => never attach directive
38
+ * - "first_story_only"=> only when story_key === "S-0001"
39
+ * - "always" => attach for every run
40
+ *
41
+ * Contract: DB is already initialized before workflow is used:
42
+ * - schema tables exist
43
+ * - repo_state singleton row (id=1) exists
44
+ *
45
+ * IMPORTANT: Do NOT call withTx() in here. The caller owns transaction boundaries.
36
46
  */
37
47
  export type NextAction = {
38
48
  kind: "idle";
@@ -67,20 +77,10 @@ export declare function decideNextAction(db: SqliteDb, config: AstrocodeConfig):
67
77
  export declare function createRunForStory(db: SqliteDb, config: AstrocodeConfig, storyKey: string): {
68
78
  run_id: string;
69
79
  };
70
- /**
71
- * STAGE MOVEMENT (START) — now async so UI injection is deterministic.
72
- */
73
80
  export declare function startStage(db: SqliteDb, runId: string, stageKey: StageKey, meta?: {
74
81
  subagent_type?: string;
75
82
  subagent_session_id?: string;
76
- ui?: WorkflowUi;
77
- }): Promise<void>;
78
- /**
79
- * STAGE CLOSED (RUN COMPLETED) — now async so UI injection is deterministic.
80
- */
81
- export declare function completeRun(db: SqliteDb, runId: string, ui?: WorkflowUi): Promise<void>;
82
- /**
83
- * STAGE CLOSED (RUN FAILED) — now async so UI injection is deterministic.
84
- */
85
- export declare function failRun(db: SqliteDb, runId: string, stageKey: StageKey, errorText: string, ui?: WorkflowUi): Promise<void>;
83
+ }, emit?: UiEmit): void;
84
+ export declare function completeRun(db: SqliteDb, runId: string, emit?: UiEmit): void;
85
+ export declare function failRun(db: SqliteDb, runId: string, stageKey: StageKey, errorText: string, emit?: UiEmit): void;
86
86
  export declare function abortRun(db: SqliteDb, runId: string, reason: string): void;
@@ -1,10 +1,8 @@
1
- import { withTx } from "../state/db";
2
1
  import { nowISO } from "../shared/time";
3
2
  import { newEventId, newRunId, newStageRunId } from "../state/ids";
4
3
  import { warn } from "../shared/log";
5
4
  import { sha256Hex } from "../shared/hash";
6
5
  import { SCHEMA_VERSION } from "../state/schema";
7
- import { injectChatPrompt } from "../ui/inject";
8
6
  export const EVENT_TYPES = {
9
7
  RUN_STARTED: "run.started",
10
8
  RUN_COMPLETED: "run.completed",
@@ -14,31 +12,6 @@ export const EVENT_TYPES = {
14
12
  STAGE_STARTED: "stage.started",
15
13
  WORKFLOW_PROCEED: "workflow.proceed",
16
14
  };
17
- async function emitUi(ui, text, toast) {
18
- if (!ui)
19
- return;
20
- // Prefer toast (if provided) AND also inject chat (for audit trail / visibility).
21
- // If you want toast-only, pass a toast function and omit ctx/sessionId.
22
- if (toast && ui.toast) {
23
- try {
24
- await ui.toast(toast);
25
- }
26
- catch {
27
- // non-fatal
28
- }
29
- }
30
- try {
31
- await injectChatPrompt({
32
- ctx: ui.ctx,
33
- sessionId: ui.sessionId,
34
- text,
35
- agent: ui.agentName ?? "Astro",
36
- });
37
- }
38
- catch {
39
- // non-fatal (workflow correctness is DB-based)
40
- }
41
- }
42
15
  function tableExists(db, tableName) {
43
16
  try {
44
17
  const row = db
@@ -90,7 +63,7 @@ export function decideNextAction(db, config) {
90
63
  if (current.status === "failed") {
91
64
  return { kind: "failed", run_id: activeRun.run_id, stage_key: current.stage_key, error_text: current.error_text ?? "stage failed" };
92
65
  }
93
- warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.status });
66
+ warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.stage_key });
94
67
  return { kind: "await_stage_completion", run_id: activeRun.run_id, stage_key: current.stage_key, stage_run_id: current.stage_run_id };
95
68
  }
96
69
  function getPipelineFromConfig(config) {
@@ -153,149 +126,91 @@ function attachRunPlanningDirective(db, runId, story, pipeline) {
153
126
  }
154
127
  }
155
128
  export function createRunForStory(db, config, storyKey) {
156
- return withTx(db, () => {
157
- const story = getStory(db, storyKey);
158
- if (!story)
159
- throw new Error(`Story not found: ${storyKey}`);
160
- if (story.state !== "approved")
161
- throw new Error(`Story must be approved to run: ${storyKey} (state=${story.state})`);
162
- const run_id = newRunId();
163
- const now = nowISO();
164
- const pipeline = getPipelineFromConfig(config);
165
- db.prepare("UPDATE stories SET state='in_progress', in_progress=1, locked_by_run_id=?, locked_at=?, updated_at=? WHERE story_key=?").run(run_id, now, now, storyKey);
166
- db.prepare("INSERT INTO runs (run_id, story_key, status, pipeline_stages_json, current_stage_key, created_at, started_at, updated_at) VALUES (?, ?, 'running', ?, ?, ?, ?, ?)").run(run_id, storyKey, JSON.stringify(pipeline), pipeline[0] ?? null, now, now, now);
167
- const insertStage = db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)");
168
- pipeline.forEach((stageKey, idx) => {
169
- insertStage.run(newStageRunId(), run_id, stageKey, idx, now, now);
170
- });
171
- db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), run_id, EVENT_TYPES.RUN_STARTED, JSON.stringify({ story_key: storyKey, pipeline }), now);
172
- if (shouldAttachPlanningDirective(config, story)) {
173
- attachRunPlanningDirective(db, run_id, story, pipeline);
174
- }
175
- db.prepare(`
176
- INSERT INTO repo_state (id, schema_version, created_at, updated_at, last_run_id, last_story_key, last_event_at)
177
- VALUES (1, ?, ?, ?, ?, ?, ?)
178
- ON CONFLICT(id) DO UPDATE SET
179
- last_run_id=excluded.last_run_id,
180
- last_story_key=excluded.last_story_key,
181
- last_event_at=excluded.last_event_at,
182
- updated_at=excluded.updated_at
183
- `).run(SCHEMA_VERSION, now, now, run_id, storyKey, now);
184
- return { run_id };
185
- });
186
- }
187
- /**
188
- * STAGE MOVEMENT (START) — now async so UI injection is deterministic.
189
- */
190
- export async function startStage(db, runId, stageKey, meta) {
191
- // Do DB work inside tx, capture what we need for UI outside.
192
- const payload = withTx(db, () => {
193
- const now = nowISO();
194
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
195
- if (!run)
196
- throw new Error(`Run not found: ${runId}`);
197
- if (run.status !== "running")
198
- throw new Error(`Run is not running: ${runId} (status=${run.status})`);
199
- const stage = db.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?").get(runId, stageKey);
200
- if (!stage)
201
- throw new Error(`Stage run not found: ${runId}/${stageKey}`);
202
- if (stage.status !== "pending")
203
- throw new Error(`Stage is not pending: ${stageKey} (status=${stage.status})`);
204
- db.prepare("UPDATE stage_runs SET status='running', started_at=?, updated_at=?, subagent_type=COALESCE(?, subagent_type), subagent_session_id=COALESCE(?, subagent_session_id) WHERE stage_run_id=?").run(now, now, meta?.subagent_type ?? null, meta?.subagent_session_id ?? null, stage.stage_run_id);
205
- db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
206
- db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), runId, stageKey, EVENT_TYPES.STAGE_STARTED, JSON.stringify({ subagent_type: meta?.subagent_type ?? null }), now);
207
- db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
208
- const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key);
209
- return {
210
- now,
211
- story_key: run.story_key,
212
- story_title: story?.title ?? "",
213
- };
214
- });
215
- // Deterministic UI emission AFTER commit (never inside tx).
216
- await emitUi(meta?.ui, [
217
- `🟦 Stage started`,
218
- `- Run: \`${runId}\``,
219
- `- Stage: \`${stageKey}\``,
220
- `- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
221
- ].join("\n"), {
222
- title: "Stage started",
223
- message: `${stageKey} (${payload.story_key})`,
224
- variant: "info",
225
- durationMs: 2500,
226
- });
227
- }
228
- /**
229
- * STAGE CLOSED (RUN COMPLETED) — now async so UI injection is deterministic.
230
- */
231
- export async function completeRun(db, runId, ui) {
232
- const payload = withTx(db, () => {
233
- const now = nowISO();
234
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
235
- if (!run)
236
- throw new Error(`Run not found: ${runId}`);
237
- if (run.status !== "running")
238
- throw new Error(`Run not running: ${runId} (status=${run.status})`);
239
- const stageRuns = getStageRuns(db, runId);
240
- const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
241
- if (incomplete)
242
- throw new Error(`Cannot complete run: stage ${incomplete.stage_key} is ${incomplete.status}`);
243
- db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
244
- db.prepare("UPDATE stories SET state='done', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
245
- db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_COMPLETED, JSON.stringify({ story_key: run.story_key }), now);
246
- db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
247
- const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key);
248
- return { now, story_key: run.story_key, story_title: story?.title ?? "" };
249
- });
250
- await emitUi(ui, [
251
- `✅ Run completed`,
252
- `- Run: \`${runId}\``,
253
- `- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
254
- ].join("\n"), {
255
- title: "Run completed",
256
- message: `${payload.story_key} — done`,
257
- variant: "success",
258
- durationMs: 3000,
259
- });
260
- }
261
- /**
262
- * STAGE CLOSED (RUN FAILED) — now async so UI injection is deterministic.
263
- */
264
- export async function failRun(db, runId, stageKey, errorText, ui) {
265
- const payload = withTx(db, () => {
266
- const now = nowISO();
267
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
268
- if (!run)
269
- throw new Error(`Run not found: ${runId}`);
270
- db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
271
- db.prepare("UPDATE stories SET state='blocked', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
272
- db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), runId, stageKey, EVENT_TYPES.RUN_FAILED, JSON.stringify({ error_text: errorText }), now);
273
- db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
274
- const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key);
275
- return { now, story_key: run.story_key, story_title: story?.title ?? "" };
276
- });
277
- await emitUi(ui, [
278
- `⛔ Run failed`,
279
- `- Run: \`${runId}\``,
280
- `- Stage: \`${stageKey}\``,
281
- `- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
282
- `- Error: ${errorText}`,
283
- ].join("\n"), {
284
- title: "Run failed",
285
- message: `${stageKey}: ${errorText}`,
286
- variant: "error",
287
- durationMs: 4500,
129
+ const story = getStory(db, storyKey);
130
+ if (!story)
131
+ throw new Error(`Story not found: ${storyKey}`);
132
+ if (story.state !== "approved")
133
+ throw new Error(`Story must be approved to run: ${storyKey} (state=${story.state})`);
134
+ const run_id = newRunId();
135
+ const now = nowISO();
136
+ const pipeline = getPipelineFromConfig(config);
137
+ db.prepare("UPDATE stories SET state='in_progress', in_progress=1, locked_by_run_id=?, locked_at=?, updated_at=? WHERE story_key=?").run(run_id, now, now, storyKey);
138
+ db.prepare("INSERT INTO runs (run_id, story_key, status, pipeline_stages_json, current_stage_key, created_at, started_at, updated_at) VALUES (?, ?, 'running', ?, ?, ?, ?, ?)").run(run_id, storyKey, JSON.stringify(pipeline), pipeline[0] ?? null, now, now, now);
139
+ const insertStage = db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)");
140
+ pipeline.forEach((stageKey, idx) => {
141
+ insertStage.run(newStageRunId(), run_id, stageKey, idx, now, now);
288
142
  });
143
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), run_id, EVENT_TYPES.RUN_STARTED, JSON.stringify({ story_key: storyKey, pipeline }), now);
144
+ if (shouldAttachPlanningDirective(config, story)) {
145
+ attachRunPlanningDirective(db, run_id, story, pipeline);
146
+ }
147
+ db.prepare(`
148
+ INSERT INTO repo_state (id, schema_version, created_at, updated_at, last_run_id, last_story_key, last_event_at)
149
+ VALUES (1, ?, ?, ?, ?, ?, ?)
150
+ ON CONFLICT(id) DO UPDATE SET
151
+ last_run_id=excluded.last_run_id,
152
+ last_story_key=excluded.last_story_key,
153
+ last_event_at=excluded.last_event_at,
154
+ updated_at=excluded.updated_at
155
+ `).run(SCHEMA_VERSION, now, now, now, run_id, storyKey, now);
156
+ return { run_id };
157
+ }
158
+ export function startStage(db, runId, stageKey, meta, emit) {
159
+ const now = nowISO();
160
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
161
+ if (!run)
162
+ throw new Error(`Run not found: ${runId}`);
163
+ if (run.status !== "running")
164
+ throw new Error(`Run is not running: ${runId} (status=${run.status})`);
165
+ const stage = db.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?").get(runId, stageKey);
166
+ if (!stage)
167
+ throw new Error(`Stage run not found: ${runId}/${stageKey}`);
168
+ if (stage.status !== "pending")
169
+ throw new Error(`Stage is not pending: ${stageKey} (status=${stage.status})`);
170
+ db.prepare("UPDATE stage_runs SET status='running', started_at=?, updated_at=?, subagent_type=COALESCE(?, subagent_type), subagent_session_id=COALESCE(?, subagent_session_id) WHERE stage_run_id=?").run(now, now, meta?.subagent_type ?? null, meta?.subagent_session_id ?? null, stage.stage_run_id);
171
+ db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
172
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), runId, stageKey, EVENT_TYPES.STAGE_STARTED, JSON.stringify({ subagent_type: meta?.subagent_type ?? null }), now);
173
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
174
+ // ✅ Explicit wiring point (requested): stage movement
175
+ emit?.({ kind: "stage_started", run_id: runId, stage_key: stageKey, agent_name: meta?.subagent_type });
176
+ }
177
+ export function completeRun(db, runId, emit) {
178
+ const now = nowISO();
179
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
180
+ if (!run)
181
+ throw new Error(`Run not found: ${runId}`);
182
+ if (run.status !== "running")
183
+ throw new Error(`Run not running: ${runId} (status=${run.status})`);
184
+ const stageRuns = getStageRuns(db, runId);
185
+ const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
186
+ if (incomplete)
187
+ throw new Error(`Cannot complete run: stage ${incomplete.stage_key} is ${incomplete.status}`);
188
+ db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
189
+ db.prepare("UPDATE stories SET state='done', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
190
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_COMPLETED, JSON.stringify({ story_key: run.story_key }), now);
191
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
192
+ // ✅ Explicit wiring point (requested): run closed success
193
+ emit?.({ kind: "run_completed", run_id: runId, story_key: run.story_key });
194
+ }
195
+ export function failRun(db, runId, stageKey, errorText, emit) {
196
+ const now = nowISO();
197
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
198
+ if (!run)
199
+ throw new Error(`Run not found: ${runId}`);
200
+ db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
201
+ db.prepare("UPDATE stories SET state='blocked', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
202
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), runId, stageKey, EVENT_TYPES.RUN_FAILED, JSON.stringify({ error_text: errorText }), now);
203
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
204
+ // ✅ Explicit wiring point (requested): run closed failure
205
+ emit?.({ kind: "run_failed", run_id: runId, story_key: run.story_key, stage_key: stageKey, error_text: errorText });
289
206
  }
290
207
  export function abortRun(db, runId, reason) {
291
- return withTx(db, () => {
292
- const now = nowISO();
293
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
294
- if (!run)
295
- throw new Error(`Run not found: ${runId}`);
296
- db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
297
- db.prepare("UPDATE stories SET state='approved', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
298
- db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_ABORTED, JSON.stringify({ reason }), now);
299
- db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
300
- });
208
+ const now = nowISO();
209
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
210
+ if (!run)
211
+ throw new Error(`Run not found: ${runId}`);
212
+ db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
213
+ db.prepare("UPDATE stories SET state='approved', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
214
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_ABORTED, JSON.stringify({ reason }), now);
215
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
301
216
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astrocode-workflow",
3
- "version": "0.3.0",
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();