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
@@ -1,10 +1,12 @@
1
1
  import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
2
+ import path from "node:path";
2
3
  import type { AstrocodeConfig } from "../config/schema";
3
4
  import type { SqliteDb } from "../state/db";
4
5
  import { withTx } from "../state/db";
5
6
  import { repairState, formatRepairReport } from "../workflow/repair";
6
7
  import { putArtifact } from "../workflow/artifacts";
7
8
  import { nowISO } from "../shared/time";
9
+ import { withRepoLock } from "../state/repo-lock";
8
10
 
9
11
  export function createAstroRepairTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
10
12
  const { ctx, config, db } = opts;
@@ -16,16 +18,27 @@ export function createAstroRepairTool(opts: { ctx: any; config: AstrocodeConfig;
16
18
  },
17
19
  execute: async ({ write_report_artifact }) => {
18
20
  const repoRoot = ctx.directory as string;
19
- const report = withTx(db, () => repairState(db, config));
20
- const md = formatRepairReport(report);
21
+ const lockPath = path.join(repoRoot, ".astro", "astro.lock");
22
+ const sessionId = (ctx as any).sessionID as string | undefined;
21
23
 
22
- if (write_report_artifact) {
23
- const rel = `.astro/repair/repair_${nowISO().replace(/[:.]/g, "-")}.md`;
24
- const a = putArtifact({ repoRoot, db, run_id: null, stage_key: null, type: "log", rel_path: rel, content: md, meta: { kind: "repair" } });
25
- return md + `\n\nReport saved: ${rel} (artifact=${a.artifact_id})`;
26
- }
24
+ return withRepoLock({
25
+ lockPath,
26
+ repoRoot,
27
+ sessionId,
28
+ owner: "astro_repair",
29
+ fn: async () => {
30
+ const report = withTx(db, () => repairState(db, config));
31
+ const md = formatRepairReport(report);
27
32
 
28
- return md;
33
+ if (write_report_artifact) {
34
+ const rel = `.astro/repair/repair_${nowISO().replace(/[:.]/g, "-")}.md`;
35
+ const a = putArtifact({ repoRoot, db, run_id: null, stage_key: null, type: "log", rel_path: rel, content: md, meta: { kind: "repair" } });
36
+ return md + `\n\nReport saved: ${rel} (artifact=${a.artifact_id})`;
37
+ }
38
+
39
+ return md;
40
+ },
41
+ });
29
42
  },
30
43
  });
31
44
  }
@@ -0,0 +1,100 @@
1
+ // src/tools/reset.ts
2
+ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
3
+ import type { AstrocodeConfig } from "../config/schema";
4
+ import type { SqliteDb } from "../state/db";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+
8
+ export function createAstroResetTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
9
+ const { ctx, config, db } = opts;
10
+
11
+ return tool({
12
+ description: "Reset Astrocode database: safely delete all DB files and WAL/SHM after killing concurrent processes.",
13
+ args: {
14
+ confirm: tool.schema.string().default("").describe("Type 'RESET' to confirm destructive operation"),
15
+ },
16
+ execute: async ({ confirm }) => {
17
+ if (confirm !== "RESET") {
18
+ return [
19
+ "❌ Reset cancelled - confirmation required",
20
+ "",
21
+ "This operation will:",
22
+ "- Delete .astro/astro.db",
23
+ "- Delete .astro/astro.db-wal (if exists)",
24
+ "- Delete .astro/astro.db-shm (if exists)",
25
+ "- Lose all workflow data, stories, runs, artifacts",
26
+ "",
27
+ "To confirm: astro_reset(confirm=\"RESET\")",
28
+ ].join("\n");
29
+ }
30
+
31
+ const repoRoot = (ctx as any).directory || process.cwd();
32
+ const dbPath = config.db?.path || ".astro/astro.db";
33
+ const fullDbPath = path.resolve(repoRoot, dbPath);
34
+
35
+ const lines: string[] = [];
36
+ lines.push("🗑️ Astrocode Database Reset");
37
+ lines.push(`- Repo: ${repoRoot}`);
38
+ lines.push(`- Target: ${fullDbPath}`);
39
+
40
+ // Check for lock file
41
+ const lockPath = `${repoRoot}/.astro/astro.lock`;
42
+ if (fs.existsSync(lockPath)) {
43
+ try {
44
+ const lockContent = fs.readFileSync(lockPath, "utf8").trim();
45
+ const pid = parseInt(lockContent.split(" ")[0]);
46
+
47
+ lines.push(`- Lock file found for PID ${pid}`);
48
+
49
+ // Try to kill the process
50
+ try {
51
+ (process as any).kill(pid, 'SIGTERM');
52
+ lines.push(`- Sent SIGTERM to PID ${pid}, waiting 2s...`);
53
+ await new Promise(resolve => setTimeout(resolve, 2000));
54
+ } catch (e) {
55
+ lines.push(`- Could not kill PID ${pid}: ${String(e)}`);
56
+ }
57
+ } catch (e) {
58
+ lines.push(`- Error reading lock file: ${String(e)}`);
59
+ }
60
+ }
61
+
62
+ // Delete DB files
63
+ const filesToDelete = [
64
+ fullDbPath,
65
+ `${fullDbPath}-wal`,
66
+ `${fullDbPath}-shm`,
67
+ lockPath,
68
+ ];
69
+
70
+ let deletedCount = 0;
71
+ for (const filePath of filesToDelete) {
72
+ try {
73
+ if (fs.existsSync(filePath)) {
74
+ fs.unlinkSync(filePath);
75
+ lines.push(`- Deleted: ${path.relative(repoRoot, filePath)}`);
76
+ deletedCount++;
77
+ } else {
78
+ lines.push(`- Skipped: ${path.relative(repoRoot, filePath)} (not found)`);
79
+ }
80
+ } catch (e) {
81
+ lines.push(`- Failed to delete ${path.relative(repoRoot, filePath)}: ${String(e)}`);
82
+ }
83
+ }
84
+
85
+ lines.push(``);
86
+ if (deletedCount > 0) {
87
+ lines.push(`✅ Reset complete - ${deletedCount} files deleted`);
88
+ lines.push(``);
89
+ lines.push(`Next steps:`);
90
+ lines.push(`1. Run: astro_init`);
91
+ lines.push(`2. Run: astro_status`);
92
+ lines.push(`3. Import your stories and restart workflow`);
93
+ } else {
94
+ lines.push(`ℹ️ No files found to delete`);
95
+ }
96
+
97
+ return lines.join("\n");
98
+ },
99
+ });
100
+ }
@@ -13,6 +13,7 @@ import { getAstroPaths, ensureAstroDirs, toPosix } from "../shared/paths";
13
13
  import { failRun, getActiveRun, getStageRuns, startStage, completeRun } from "../workflow/state-machine";
14
14
  import { newEventId, newId } from "../state/ids";
15
15
  import { insertStory } from "../workflow/story-helpers";
16
+ import { withRepoLock } from "../state/repo-lock";
16
17
 
17
18
  function nextStageKey(pipeline: StageKey[], current: StageKey): StageKey | null {
18
19
  const i = pipeline.indexOf(current);
@@ -128,6 +129,15 @@ export function createAstroStageCompleteTool(opts: { ctx: any; config: Astrocode
128
129
  },
129
130
  execute: async ({ run_id, stage_key, output_text, allow_new_stories, relation_reason }) => {
130
131
  const repoRoot = ctx.directory as string;
132
+ const lockPath = path.join(repoRoot, ".astro", "astro.lock");
133
+ const sessionId = (ctx as any).sessionID as string | undefined;
134
+
135
+ return withRepoLock({
136
+ lockPath,
137
+ repoRoot,
138
+ sessionId,
139
+ owner: "astro_stage_complete",
140
+ fn: async () => {
131
141
  const paths = getAstroPaths(repoRoot, config.db.path);
132
142
  ensureAstroDirs(paths);
133
143
 
@@ -391,6 +401,8 @@ Ensure JSON has required fields (stage_key, status) and valid syntax.`;
391
401
  lines.push(context);
392
402
 
393
403
  return lines.join("\n").trim();
404
+ },
405
+ });
394
406
  },
395
407
  });
396
408
  }
@@ -1,7 +1,9 @@
1
1
  import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
2
+ import path from "node:path";
2
3
  import type { AstrocodeConfig } from "../config/schema";
3
4
  import type { SqliteDb } from "../state/db";
4
5
  import { decideNextAction, getActiveRun, getStageRuns, getStory } from "../workflow/state-machine";
6
+ import { withRepoLock } from "../state/repo-lock";
5
7
 
6
8
  function statusIcon(status: string): string {
7
9
  switch (status) {
@@ -36,7 +38,7 @@ function stageIcon(status: string): string {
36
38
  }
37
39
 
38
40
  export function createAstroStatusTool(opts: { ctx: any; config: AstrocodeConfig; db?: SqliteDb | null }): ToolDefinition {
39
- const { config, db } = opts;
41
+ const { ctx, config, db } = opts;
40
42
 
41
43
  return tool({
42
44
  description: "Show a compact Astrocode status dashboard: active run/stage, pipeline, story board counts, and next action.",
@@ -55,7 +57,17 @@ export function createAstroStatusTool(opts: { ctx: any; config: AstrocodeConfig;
55
57
  ].join("\n");
56
58
  }
57
59
 
58
- try {
60
+ const repoRoot = ctx.directory as string;
61
+ const lockPath = path.join(repoRoot, ".astro", "astro.lock");
62
+ const sessionId = (ctx as any).sessionID as string | undefined;
63
+
64
+ return withRepoLock({
65
+ lockPath,
66
+ repoRoot,
67
+ sessionId,
68
+ owner: "astro_status",
69
+ fn: async () => {
70
+ try {
59
71
  const active = getActiveRun(db);
60
72
 
61
73
  const lines: string[] = [];
@@ -129,7 +141,9 @@ export function createAstroStatusTool(opts: { ctx: any; config: AstrocodeConfig;
129
141
  `⛔ Database error.`,
130
142
  `Error: ${msg}`,
131
143
  ].join("\n");
132
- }
144
+ }
145
+ },
146
+ });
133
147
  },
134
148
  });
135
149
  }
@@ -1,4 +1,5 @@
1
1
  import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
2
+ import path from "node:path";
2
3
  import type { AstrocodeConfig } from "../config/schema";
3
4
  import type { SqliteDb } from "../state/db";
4
5
  import { withTx } from "../state/db";
@@ -6,10 +7,11 @@ import { nowISO } from "../shared/time";
6
7
  import type { StoryState } from "../state/types";
7
8
 
8
9
  import { insertStory } from "../workflow/story-helpers";
10
+ import { withRepoLock } from "../state/repo-lock";
9
11
 
10
12
 
11
13
  export function createAstroStoryQueueTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
12
- const { db } = opts;
14
+ const { ctx, db } = opts;
13
15
 
14
16
  return tool({
15
17
  description: "Create a queued story (ticket) in Astrocode. Returns story_key.",
@@ -20,18 +22,30 @@ export function createAstroStoryQueueTool(opts: { ctx: any; config: AstrocodeCon
20
22
  priority: tool.schema.number().int().default(0),
21
23
  },
22
24
  execute: async ({ title, body_md, epic_key, priority }) => {
23
- const story_key = withTx(db, () => {
24
- const key = insertStory(db, { title, body_md, epic_key: epic_key ?? null, priority: priority ?? 0, state: 'queued' });
25
- return key;
25
+ const repoRoot = ctx.directory as string;
26
+ const lockPath = path.join(repoRoot, ".astro", "astro.lock");
27
+ const sessionId = (ctx as any).sessionID as string | undefined;
28
+
29
+ return withRepoLock({
30
+ lockPath,
31
+ repoRoot,
32
+ sessionId,
33
+ owner: "astro_story_queue",
34
+ fn: async () => {
35
+ const story_key = withTx(db, () => {
36
+ const key = insertStory(db, { title, body_md, epic_key: epic_key ?? null, priority: priority ?? 0, state: 'queued' });
37
+ return key;
38
+ });
39
+
40
+ return `✅ Queued story ${story_key}: ${title}`;
41
+ },
26
42
  });
27
-
28
- return `✅ Queued story ${story_key}: ${title}`;
29
43
  },
30
44
  });
31
45
  }
32
46
 
33
47
  export function createAstroStoryApproveTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
34
- const { db } = opts;
48
+ const { ctx, db } = opts;
35
49
 
36
50
  return tool({
37
51
  description: "Approve a story so it becomes eligible to run.",
@@ -39,14 +53,26 @@ export function createAstroStoryApproveTool(opts: { ctx: any; config: AstrocodeC
39
53
  story_key: tool.schema.string().min(1),
40
54
  },
41
55
  execute: async ({ story_key }) => {
42
- const now = nowISO();
43
- const row = db.prepare("SELECT story_key, state, title FROM stories WHERE story_key=?").get(story_key) as any;
44
- if (!row) throw new Error(`Story not found: ${story_key}`);
45
-
46
- if (row.state === "approved") return `ℹ️ Story ${story_key} already approved.`;
47
-
48
- db.prepare("UPDATE stories SET state='approved', approved_at=?, updated_at=? WHERE story_key=?").run(now, now, story_key);
49
- return `✅ Approved story ${story_key}: ${row.title}`;
56
+ const repoRoot = ctx.directory as string;
57
+ const lockPath = path.join(repoRoot, ".astro", "astro.lock");
58
+ const sessionId = (ctx as any).sessionID as string | undefined;
59
+
60
+ return withRepoLock({
61
+ lockPath,
62
+ repoRoot,
63
+ sessionId,
64
+ owner: "astro_story_approve",
65
+ fn: async () => {
66
+ const now = nowISO();
67
+ const row = db.prepare("SELECT story_key, state, title FROM stories WHERE story_key=?").get(story_key) as any;
68
+ if (!row) throw new Error(`Story not found: ${story_key}`);
69
+
70
+ if (row.state === "approved") return `ℹ️ Story ${story_key} already approved.`;
71
+
72
+ db.prepare("UPDATE stories SET state='approved', approved_at=?, updated_at=? WHERE story_key=?").run(now, now, story_key);
73
+ return `✅ Approved story ${story_key}: ${row.title}`;
74
+ },
75
+ });
50
76
  },
51
77
  });
52
78
  }
@@ -1,17 +1,30 @@
1
1
  // src/tools/workflow.ts
2
2
  import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
3
+ import path from "node:path";
3
4
  import type { AstrocodeConfig } from "../config/schema";
4
5
  import type { SqliteDb } from "../state/db";
6
+ import { withTx } from "../state/db";
5
7
  import type { StageKey } from "../state/types";
8
+ import type { UiEmitEvent } from "../workflow/state-machine";
6
9
  import { buildContextSnapshot } from "../workflow/context";
7
- import { decideNextAction, createRunForStory, startStage, completeRun, getActiveRun, EVENT_TYPES } from "../workflow/state-machine";
10
+ import {
11
+ decideNextAction,
12
+ createRunForStory,
13
+ startStage,
14
+ completeRun,
15
+ failRun,
16
+ getActiveRun,
17
+ EVENT_TYPES,
18
+ } from "../workflow/state-machine";
8
19
  import { buildStageDirective, directiveHash } from "../workflow/directives";
9
20
  import { injectChatPrompt } from "../ui/inject";
10
21
  import { nowISO } from "../shared/time";
11
22
  import { newEventId } from "../state/ids";
23
+ import { withRepoLock } from "../state/repo-lock";
12
24
  import { debug } from "../shared/log";
13
25
  import { createToastManager } from "../ui/toasts";
14
26
  import type { AgentConfig } from "@opencode-ai/sdk";
27
+ import { acquireRepoLock } from "../state/repo-lock";
15
28
 
16
29
  // Agent name mapping for case-sensitive resolution
17
30
  export const STAGE_TO_AGENT_MAP: Record<string, string> = {
@@ -118,10 +131,50 @@ function buildDelegationPrompt(opts: {
118
131
  ].join("\n").trim();
119
132
 
120
133
  debug(`Delegating stage ${stage_key} to agent ${stage_agent_name}`, { prompt_length: prompt.length });
121
-
122
134
  return prompt;
123
135
  }
124
136
 
137
+ function buildUiMessage(e: UiEmitEvent): { title: string; message: string; variant: "info" | "success" | "error"; chatText: string } {
138
+ switch (e.kind) {
139
+ case "stage_started": {
140
+ const agent = e.agent_name ? ` (${e.agent_name})` : "";
141
+ const title = "Astrocode";
142
+ const message = `Stage started: ${e.stage_key}${agent}`;
143
+ const chatText = [
144
+ `[SYSTEM DIRECTIVE: ASTROCODE — STAGE_STARTED]`,
145
+ ``,
146
+ `Run: ${e.run_id}`,
147
+ `Stage: ${e.stage_key}${agent}`,
148
+ ].join("\n");
149
+ return { title, message, variant: "info", chatText };
150
+ }
151
+ case "run_completed": {
152
+ const title = "Astrocode";
153
+ const message = `Run completed: ${e.run_id}`;
154
+ const chatText = [
155
+ `[SYSTEM DIRECTIVE: ASTROCODE — RUN_COMPLETED]`,
156
+ ``,
157
+ `Run: ${e.run_id}`,
158
+ `Story: ${e.story_key}`,
159
+ ].join("\n");
160
+ return { title, message, variant: "success", chatText };
161
+ }
162
+ case "run_failed": {
163
+ const title = "Astrocode";
164
+ const message = `Run failed: ${e.run_id} (${e.stage_key})`;
165
+ const chatText = [
166
+ `[SYSTEM DIRECTIVE: ASTROCODE — RUN_FAILED]`,
167
+ ``,
168
+ `Run: ${e.run_id}`,
169
+ `Story: ${e.story_key}`,
170
+ `Stage: ${e.stage_key}`,
171
+ `Error: ${e.error_text}`,
172
+ ].join("\n");
173
+ return { title, message, variant: "error", chatText };
174
+ }
175
+ }
176
+ }
177
+
125
178
  export function createAstroWorkflowProceedTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb; agents?: Record<string, AgentConfig> }): ToolDefinition {
126
179
  const { ctx, config, db, agents } = opts;
127
180
  const toasts = createToastManager({ ctx, throttleMs: config.ui.toasts.throttle_ms });
@@ -134,13 +187,26 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
134
187
  max_steps: tool.schema.number().int().positive().default(config.workflow.default_max_steps),
135
188
  },
136
189
  execute: async ({ mode, max_steps }) => {
190
+ const repoRoot = (ctx as any).directory as string;
191
+ const lockPath = path.join(repoRoot, ".astro", "astro.lock");
137
192
  const sessionId = (ctx as any).sessionID as string | undefined;
138
- const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
193
+
194
+ return withRepoLock({
195
+ lockPath,
196
+ repoRoot,
197
+ sessionId,
198
+ owner: "astro_workflow_proceed",
199
+ fn: async () => {
200
+ const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
139
201
 
140
202
  const actions: string[] = [];
141
203
  const warnings: string[] = [];
142
204
  const startedAt = nowISO();
143
205
 
206
+ // Collect UI events emitted inside state-machine functions, then flush AFTER tx.
207
+ const uiEvents: UiEmitEvent[] = [];
208
+ const emit = (e: UiEmitEvent) => uiEvents.push(e);
209
+
144
210
  for (let i = 0; i < steps; i++) {
145
211
  const next = decideNextAction(db, config);
146
212
 
@@ -150,60 +216,26 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
150
216
  }
151
217
 
152
218
  if (next.kind === "start_run") {
153
- // NOTE: createRunForStory owns its own tx (state-machine.ts).
154
- const { run_id } = createRunForStory(db, config, next.story_key);
219
+ // SINGLE tx boundary: caller owns tx, state-machine is pure.
220
+ const { run_id } = withTx(db, () => createRunForStory(db, config, next.story_key));
155
221
  actions.push(`started run ${run_id} for story ${next.story_key}`);
156
222
 
157
- if (config.ui.toasts.enabled && config.ui.toasts.show_run_started) {
158
- await toasts.show({ title: "Astrocode", message: `Run started (${run_id})`, variant: "success" });
159
- }
160
-
161
- if (sessionId) {
162
- await injectChatPrompt({
163
- ctx,
164
- sessionId,
165
- agent: "Astro",
166
- text: [
167
- `[SYSTEM DIRECTIVE: ASTROCODE — RUN_STARTED]`,
168
- ``,
169
- `Run started: \`${run_id}\``,
170
- `Story: \`${next.story_key}\``,
171
- ``,
172
- `Next: call **astro_workflow_proceed** again to delegate the first stage.`,
173
- ].join("\n"),
174
- });
175
- actions.push(`injected run started message for ${run_id}`);
176
- }
177
-
178
223
  if (mode === "step") break;
179
224
  continue;
180
225
  }
181
226
 
182
227
  if (next.kind === "complete_run") {
183
- // NOTE: completeRun owns its own tx (state-machine.ts).
184
- completeRun(db, next.run_id);
228
+ withTx(db, () => completeRun(db, next.run_id, emit));
185
229
  actions.push(`completed run ${next.run_id}`);
186
230
 
187
- if (config.ui.toasts.enabled && config.ui.toasts.show_run_completed) {
188
- await toasts.show({ title: "Astrocode", message: `Run completed (${next.run_id})`, variant: "success" });
189
- }
231
+ if (mode === "step") break;
232
+ continue;
233
+ }
190
234
 
191
- // explicit injection on completeRun (requested)
192
- if (sessionId) {
193
- await injectChatPrompt({
194
- ctx,
195
- sessionId,
196
- agent: "Astro",
197
- text: [
198
- `[SYSTEM DIRECTIVE: ASTROCODE — RUN_COMPLETED]`,
199
- ``,
200
- `Run \`${next.run_id}\` completed.`,
201
- ``,
202
- `Next: call **astro_workflow_proceed** (mode=step) to start the next approved story (if any).`,
203
- ].join("\n"),
204
- });
205
- actions.push(`injected run completed message for ${next.run_id}`);
206
- }
235
+ if (next.kind === "failed") {
236
+ // Ensure DB state reflects failure in one tx; emit UI event.
237
+ withTx(db, () => failRun(db, next.run_id, next.stage_key, next.error_text, emit));
238
+ actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
207
239
 
208
240
  if (mode === "step") break;
209
241
  continue;
@@ -241,30 +273,9 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
241
273
  }
242
274
 
243
275
  // NOTE: startStage owns its own tx (state-machine.ts).
244
- startStage(db, active.run_id, next.stage_key, { subagent_type: agentName });
245
-
246
- actions.push(`stage started: ${next.stage_key}`);
247
-
248
- if (config.ui.toasts.enabled && config.ui.toasts.show_stage_started) {
249
- await toasts.show({ title: "Astrocode", message: `Stage started: ${next.stage_key}`, variant: "info" });
250
- }
251
-
252
- // ✅ explicit injection on startStage (requested)
253
- if (sessionId) {
254
- await injectChatPrompt({
255
- ctx,
256
- sessionId,
257
- agent: "Astro",
258
- text: [
259
- `[SYSTEM DIRECTIVE: ASTROCODE — STAGE_STARTED]`,
260
- ``,
261
- `Run: \`${active.run_id}\``,
262
- `Stage: \`${next.stage_key}\``,
263
- `Delegated to: \`${agentName}\``,
264
- ].join("\n"),
265
- });
266
- actions.push(`injected stage started message for ${next.stage_key}`);
267
- }
276
+ withTx(db, () => {
277
+ startStage(db, active.run_id, next.stage_key, { subagent_type: agentName }, emit);
278
+ });
268
279
 
269
280
  const context = buildContextSnapshot({
270
281
  db,
@@ -292,20 +303,17 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
292
303
  stage_agent_name: agentName,
293
304
  });
294
305
 
295
- // Best-effort: continuations table may not exist on older DBs.
296
- try {
297
- const h = directiveHash(delegatePrompt);
298
- const now = nowISO();
299
- if (sessionId) {
300
- db.prepare(
301
- "INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'stage', ?, ?)"
302
- ).run(sessionId, active.run_id, h, `delegate ${next.stage_key}`, now);
303
- }
304
- } catch (e) {
305
- warnings.push(`continuations insert failed (non-fatal): ${String(e)}`);
306
+ // Record continuation (best-effort; no tx wrapper needed but safe either way)
307
+ const h = directiveHash(delegatePrompt);
308
+ const now = nowISO();
309
+ if (sessionId) {
310
+ // This assumes continuations table exists in vNext schema.
311
+ db.prepare(
312
+ "INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'stage', ?, ?)"
313
+ ).run(sessionId, active.run_id, h, `delegate ${next.stage_key}`, now);
306
314
  }
307
315
 
308
- // Visible injection so user can see state
316
+ // Visible injection so user can see state (awaited)
309
317
  if (sessionId) {
310
318
  await injectChatPrompt({ ctx, sessionId, text: delegatePrompt, agent: "Astro" });
311
319
 
@@ -317,7 +325,7 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
317
325
  `When \`${agentName}\` completes, call:`,
318
326
  `astro_stage_complete(run_id="${active.run_id}", stage_key="${next.stage_key}", output_text="[paste subagent output here]")`,
319
327
  ``,
320
- `Then run **astro_workflow_proceed** again.`,
328
+ `This advances the workflow.`,
321
329
  ].join("\n");
322
330
 
323
331
  await injectChatPrompt({ ctx, sessionId, text: continueMessage, agent: "Astro" });
@@ -350,15 +358,11 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
350
358
  context,
351
359
  ].join("\n").trim();
352
360
 
353
- try {
354
- const h = directiveHash(prompt);
355
- const now = nowISO();
356
- db.prepare(
357
- "INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'continue', ?, ?)"
358
- ).run(sessionId, next.run_id, h, `await ${next.stage_key}`, now);
359
- } catch (e) {
360
- warnings.push(`continuations insert failed (non-fatal): ${String(e)}`);
361
- }
361
+ const h = directiveHash(prompt);
362
+ const now = nowISO();
363
+ db.prepare(
364
+ "INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'continue', ?, ?)"
365
+ ).run(sessionId, next.run_id, h, `await ${next.stage_key}`, now);
362
366
 
363
367
  await injectChatPrompt({ ctx, sessionId, text: prompt, agent: "Astro" });
364
368
  }
@@ -366,44 +370,40 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
366
370
  break;
367
371
  }
368
372
 
369
- if (next.kind === "failed") {
370
- actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
373
+ actions.push(`unhandled next action: ${(next as any).kind}`);
374
+ break;
375
+ }
371
376
 
372
- if (sessionId) {
377
+ // Flush UI events (toast + prompt) AFTER state transitions
378
+ if (uiEvents.length > 0) {
379
+ for (const e of uiEvents) {
380
+ const msg = buildUiMessage(e);
381
+
382
+ if (config.ui.toasts.enabled) {
383
+ await toasts.show({
384
+ title: msg.title,
385
+ message: msg.message,
386
+ variant: msg.variant,
387
+ });
388
+ }
389
+
390
+ if ((ctx as any)?.sessionID) {
373
391
  await injectChatPrompt({
374
392
  ctx,
375
- sessionId,
393
+ sessionId: (ctx as any).sessionID,
394
+ text: msg.chatText,
376
395
  agent: "Astro",
377
- text: [
378
- `[SYSTEM DIRECTIVE: ASTROCODE — RUN_FAILED]`,
379
- ``,
380
- `Run \`${next.run_id}\` failed at stage \`${next.stage_key}\`.`,
381
- `Error: ${next.error_text}`,
382
- ].join("\n"),
383
396
  });
384
- actions.push(`injected run failed message for ${next.run_id}`);
385
397
  }
386
-
387
- break;
388
398
  }
389
399
 
390
- actions.push(`unhandled next action: ${(next as any).kind}`);
391
- break;
400
+ actions.push(`ui: flushed ${uiEvents.length} event(s)`);
392
401
  }
393
402
 
394
- // Housekeeping event (best-effort)
395
- try {
396
- db.prepare(
397
- "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, ?, ?, ?)"
398
- ).run(
399
- newEventId(),
400
- EVENT_TYPES.WORKFLOW_PROCEED,
401
- JSON.stringify({ started_at: startedAt, mode, max_steps: steps, actions }),
402
- nowISO()
403
- );
404
- } catch (e) {
405
- warnings.push(`workflow.proceed event insert failed (non-fatal): ${String(e)}`);
406
- }
403
+ // Housekeeping event
404
+ db.prepare(
405
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, ?, ?, ?)"
406
+ ).run(newEventId(), EVENT_TYPES.WORKFLOW_PROCEED, JSON.stringify({ started_at: startedAt, mode, max_steps: steps, actions }), nowISO());
407
407
 
408
408
  const active = getActiveRun(db);
409
409
  const lines: string[] = [];
@@ -420,6 +420,8 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
420
420
  }
421
421
 
422
422
  return lines.join("\n").trim();
423
+ },
424
+ });
423
425
  },
424
426
  });
425
427
  }