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.
- package/dist/index.js +6 -0
- package/dist/shared/metrics.d.ts +66 -0
- package/dist/shared/metrics.js +112 -0
- package/dist/src/agents/commands.d.ts +9 -0
- package/dist/src/agents/commands.js +121 -0
- package/dist/src/agents/prompts.d.ts +3 -0
- package/dist/src/agents/prompts.js +232 -0
- package/dist/src/agents/registry.d.ts +6 -0
- package/dist/src/agents/registry.js +242 -0
- package/dist/src/agents/types.d.ts +14 -0
- package/dist/src/agents/types.js +8 -0
- package/dist/src/config/config-handler.d.ts +4 -0
- package/dist/src/config/config-handler.js +46 -0
- package/dist/src/config/defaults.d.ts +3 -0
- package/dist/src/config/defaults.js +3 -0
- package/dist/src/config/loader.d.ts +11 -0
- package/dist/src/config/loader.js +82 -0
- package/dist/src/config/schema.d.ts +194 -0
- package/dist/src/config/schema.js +223 -0
- package/dist/src/hooks/continuation-enforcer.d.ts +34 -0
- package/dist/src/hooks/continuation-enforcer.js +190 -0
- package/dist/src/hooks/inject-provider.d.ts +22 -0
- package/dist/src/hooks/inject-provider.js +120 -0
- package/dist/src/hooks/tool-output-truncator.d.ts +25 -0
- package/dist/src/hooks/tool-output-truncator.js +57 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +308 -0
- package/dist/src/shared/deep-merge.d.ts +8 -0
- package/dist/src/shared/deep-merge.js +25 -0
- package/dist/src/shared/hash.d.ts +1 -0
- package/dist/src/shared/hash.js +4 -0
- package/dist/src/shared/log.d.ts +7 -0
- package/dist/src/shared/log.js +24 -0
- package/dist/src/shared/metrics.d.ts +66 -0
- package/dist/src/shared/metrics.js +112 -0
- package/dist/src/shared/model-tuning.d.ts +9 -0
- package/dist/src/shared/model-tuning.js +28 -0
- package/dist/src/shared/paths.d.ts +19 -0
- package/dist/src/shared/paths.js +64 -0
- package/dist/src/shared/text.d.ts +4 -0
- package/dist/src/shared/text.js +19 -0
- package/dist/src/shared/time.d.ts +1 -0
- package/dist/src/shared/time.js +3 -0
- package/dist/src/state/adapters/index.d.ts +41 -0
- package/dist/src/state/adapters/index.js +115 -0
- package/dist/src/state/db.d.ts +16 -0
- package/dist/src/state/db.js +225 -0
- package/dist/src/state/ids.d.ts +8 -0
- package/dist/src/state/ids.js +25 -0
- package/dist/src/state/repo-lock.d.ts +3 -0
- package/dist/src/state/repo-lock.js +29 -0
- package/dist/src/state/schema.d.ts +2 -0
- package/dist/src/state/schema.js +251 -0
- package/dist/src/state/types.d.ts +71 -0
- package/dist/src/state/types.js +1 -0
- package/dist/src/tools/artifacts.d.ts +18 -0
- package/dist/src/tools/artifacts.js +71 -0
- package/dist/src/tools/health.d.ts +8 -0
- package/dist/src/tools/health.js +119 -0
- package/dist/src/tools/index.d.ts +20 -0
- package/dist/src/tools/index.js +94 -0
- package/dist/src/tools/init.d.ts +17 -0
- package/dist/src/tools/init.js +96 -0
- package/dist/src/tools/injects.d.ts +53 -0
- package/dist/src/tools/injects.js +325 -0
- package/dist/src/tools/metrics.d.ts +7 -0
- package/dist/src/tools/metrics.js +61 -0
- package/dist/src/tools/repair.d.ts +8 -0
- package/dist/src/tools/repair.js +25 -0
- package/dist/src/tools/reset.d.ts +8 -0
- package/dist/src/tools/reset.js +92 -0
- package/dist/src/tools/run.d.ts +13 -0
- package/dist/src/tools/run.js +54 -0
- package/dist/src/tools/spec.d.ts +12 -0
- package/dist/src/tools/spec.js +44 -0
- package/dist/src/tools/stage.d.ts +23 -0
- package/dist/src/tools/stage.js +371 -0
- package/dist/src/tools/status.d.ts +8 -0
- package/dist/src/tools/status.js +125 -0
- package/dist/src/tools/story.d.ts +23 -0
- package/dist/src/tools/story.js +85 -0
- package/dist/src/tools/workflow.d.ts +13 -0
- package/dist/src/tools/workflow.js +355 -0
- package/dist/src/ui/inject.d.ts +12 -0
- package/dist/src/ui/inject.js +107 -0
- package/dist/src/ui/toasts.d.ts +13 -0
- package/dist/src/ui/toasts.js +39 -0
- package/dist/src/workflow/artifacts.d.ts +24 -0
- package/dist/src/workflow/artifacts.js +45 -0
- package/dist/src/workflow/baton.d.ts +72 -0
- package/dist/src/workflow/baton.js +166 -0
- package/dist/src/workflow/context.d.ts +20 -0
- package/dist/src/workflow/context.js +113 -0
- package/dist/src/workflow/directives.d.ts +39 -0
- package/dist/src/workflow/directives.js +137 -0
- package/dist/src/workflow/repair.d.ts +8 -0
- package/dist/src/workflow/repair.js +99 -0
- package/dist/src/workflow/state-machine.d.ts +86 -0
- package/dist/src/workflow/state-machine.js +216 -0
- package/dist/src/workflow/story-helpers.d.ts +9 -0
- package/dist/src/workflow/story-helpers.js +13 -0
- package/dist/state/db.d.ts +1 -0
- package/dist/state/db.js +9 -0
- package/dist/state/repo-lock.d.ts +3 -0
- package/dist/state/repo-lock.js +29 -0
- package/dist/test/integration/db-transactions.test.d.ts +1 -0
- package/dist/test/integration/db-transactions.test.js +126 -0
- package/dist/test/integration/injection-metrics.test.d.ts +1 -0
- package/dist/test/integration/injection-metrics.test.js +129 -0
- package/dist/tools/health.d.ts +8 -0
- package/dist/tools/health.js +119 -0
- package/dist/tools/index.js +9 -0
- package/dist/tools/metrics.d.ts +7 -0
- package/dist/tools/metrics.js +61 -0
- package/dist/tools/reset.d.ts +8 -0
- package/dist/tools/reset.js +92 -0
- package/dist/tools/workflow.js +210 -215
- package/dist/ui/inject.d.ts +6 -0
- package/dist/ui/inject.js +86 -67
- package/dist/workflow/state-machine.d.ts +32 -32
- package/dist/workflow/state-machine.js +85 -170
- package/package.json +6 -3
- package/src/index.ts +8 -0
- package/src/shared/metrics.ts +148 -0
- package/src/state/db.ts +10 -1
- package/src/state/repo-lock.ts +158 -0
- package/src/tools/health.ts +128 -0
- package/src/tools/index.ts +12 -3
- package/src/tools/init.ts +26 -14
- package/src/tools/metrics.ts +71 -0
- package/src/tools/repair.ts +21 -8
- package/src/tools/reset.ts +100 -0
- package/src/tools/stage.ts +12 -0
- package/src/tools/status.ts +17 -3
- package/src/tools/story.ts +41 -15
- package/src/tools/workflow.ts +123 -121
- package/src/ui/inject.ts +113 -79
- package/src/workflow/state-machine.ts +123 -227
- package/src/tools/workflow.ts.backup +0 -681
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ToolDefinition } from "@opencode-ai/plugin/tool";
|
|
2
|
+
import type { AstrocodeConfig } from "../config/schema";
|
|
3
|
+
import type { SqliteDb } from "../state/db";
|
|
4
|
+
export declare function createAstroStoryQueueTool(opts: {
|
|
5
|
+
ctx: any;
|
|
6
|
+
config: AstrocodeConfig;
|
|
7
|
+
db: SqliteDb;
|
|
8
|
+
}): ToolDefinition;
|
|
9
|
+
export declare function createAstroStoryApproveTool(opts: {
|
|
10
|
+
ctx: any;
|
|
11
|
+
config: AstrocodeConfig;
|
|
12
|
+
db: SqliteDb;
|
|
13
|
+
}): ToolDefinition;
|
|
14
|
+
export declare function createAstroStoryBoardTool(opts: {
|
|
15
|
+
ctx: any;
|
|
16
|
+
config: AstrocodeConfig;
|
|
17
|
+
db: SqliteDb;
|
|
18
|
+
}): ToolDefinition;
|
|
19
|
+
export declare function createAstroStorySetStateTool(opts: {
|
|
20
|
+
ctx: any;
|
|
21
|
+
config: AstrocodeConfig;
|
|
22
|
+
db: SqliteDb;
|
|
23
|
+
}): ToolDefinition;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin/tool";
|
|
2
|
+
import { withTx } from "../state/db";
|
|
3
|
+
import { nowISO } from "../shared/time";
|
|
4
|
+
import { insertStory } from "../workflow/story-helpers";
|
|
5
|
+
export function createAstroStoryQueueTool(opts) {
|
|
6
|
+
const { db } = opts;
|
|
7
|
+
return tool({
|
|
8
|
+
description: "Create a queued story (ticket) in Astrocode. Returns story_key.",
|
|
9
|
+
args: {
|
|
10
|
+
title: tool.schema.string().min(1),
|
|
11
|
+
body_md: tool.schema.string().default(""),
|
|
12
|
+
epic_key: tool.schema.string().optional(),
|
|
13
|
+
priority: tool.schema.number().int().default(0),
|
|
14
|
+
},
|
|
15
|
+
execute: async ({ title, body_md, epic_key, priority }) => {
|
|
16
|
+
const story_key = withTx(db, () => {
|
|
17
|
+
const key = insertStory(db, { title, body_md, epic_key: epic_key ?? null, priority: priority ?? 0, state: 'queued' });
|
|
18
|
+
return key;
|
|
19
|
+
});
|
|
20
|
+
return `✅ Queued story ${story_key}: ${title}`;
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export function createAstroStoryApproveTool(opts) {
|
|
25
|
+
const { db } = opts;
|
|
26
|
+
return tool({
|
|
27
|
+
description: "Approve a story so it becomes eligible to run.",
|
|
28
|
+
args: {
|
|
29
|
+
story_key: tool.schema.string().min(1),
|
|
30
|
+
},
|
|
31
|
+
execute: async ({ story_key }) => {
|
|
32
|
+
const now = nowISO();
|
|
33
|
+
const row = db.prepare("SELECT story_key, state, title FROM stories WHERE story_key=?").get(story_key);
|
|
34
|
+
if (!row)
|
|
35
|
+
throw new Error(`Story not found: ${story_key}`);
|
|
36
|
+
if (row.state === "approved")
|
|
37
|
+
return `ℹ️ Story ${story_key} already approved.`;
|
|
38
|
+
db.prepare("UPDATE stories SET state='approved', approved_at=?, updated_at=? WHERE story_key=?").run(now, now, story_key);
|
|
39
|
+
return `✅ Approved story ${story_key}: ${row.title}`;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function createAstroStoryBoardTool(opts) {
|
|
44
|
+
const { db } = opts;
|
|
45
|
+
return tool({
|
|
46
|
+
description: "Show stories grouped by state (a compact board).",
|
|
47
|
+
args: {
|
|
48
|
+
limit_per_state: tool.schema.number().int().positive().default(20),
|
|
49
|
+
},
|
|
50
|
+
execute: async ({ limit_per_state }) => {
|
|
51
|
+
const states = ["queued", "approved", "in_progress", "blocked", "done", "archived"];
|
|
52
|
+
const lines = [];
|
|
53
|
+
lines.push("# Story board");
|
|
54
|
+
for (const st of states) {
|
|
55
|
+
const rows = db
|
|
56
|
+
.prepare("SELECT story_key, title, priority, created_at FROM stories WHERE state=? ORDER BY priority DESC, created_at ASC LIMIT ?")
|
|
57
|
+
.all(st, limit_per_state);
|
|
58
|
+
lines.push("", `## ${st} (${rows.length})`);
|
|
59
|
+
for (const r of rows)
|
|
60
|
+
lines.push(`- \`${r.story_key}\` (p=${r.priority}) — ${r.title}`);
|
|
61
|
+
}
|
|
62
|
+
return lines.join("\n").trim();
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export function createAstroStorySetStateTool(opts) {
|
|
67
|
+
const { db } = opts;
|
|
68
|
+
return tool({
|
|
69
|
+
description: "Admin: set story state manually (queued|approved|in_progress|done|blocked|archived). Use carefully.",
|
|
70
|
+
args: {
|
|
71
|
+
story_key: tool.schema.string().min(1),
|
|
72
|
+
state: tool.schema.enum(["queued", "approved", "in_progress", "done", "blocked", "archived"]),
|
|
73
|
+
note: tool.schema.string().default(""),
|
|
74
|
+
},
|
|
75
|
+
execute: async ({ story_key, state, note }) => {
|
|
76
|
+
const now = nowISO();
|
|
77
|
+
const row = db.prepare("SELECT story_key, title, state FROM stories WHERE story_key=?").get(story_key);
|
|
78
|
+
if (!row)
|
|
79
|
+
throw new Error(`Story not found: ${story_key}`);
|
|
80
|
+
db.prepare("UPDATE stories SET state=?, updated_at=? WHERE story_key=?").run(state, now, story_key);
|
|
81
|
+
db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, 'story.state_set', ?, ?)").run(`evt_${Date.now()}_${Math.random().toString(16).slice(2)}`, JSON.stringify({ story_key, from: row.state, to: state, note }), now);
|
|
82
|
+
return `✅ Story ${story_key} state: ${row.state} → ${state}${note ? ` (${note})` : ""}`;
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ToolDefinition } from "@opencode-ai/plugin/tool";
|
|
2
|
+
import type { AstrocodeConfig } from "../config/schema";
|
|
3
|
+
import type { SqliteDb } from "../state/db";
|
|
4
|
+
import type { StageKey } from "../state/types";
|
|
5
|
+
import type { AgentConfig } from "@opencode-ai/sdk";
|
|
6
|
+
export declare const STAGE_TO_AGENT_MAP: Record<string, string>;
|
|
7
|
+
export declare function resolveAgentName(stageKey: StageKey, config: AstrocodeConfig, agents?: any, warnings?: string[]): string;
|
|
8
|
+
export declare function createAstroWorkflowProceedTool(opts: {
|
|
9
|
+
ctx: any;
|
|
10
|
+
config: AstrocodeConfig;
|
|
11
|
+
db: SqliteDb;
|
|
12
|
+
agents?: Record<string, AgentConfig>;
|
|
13
|
+
}): ToolDefinition;
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// src/tools/workflow.ts
|
|
2
|
+
import { tool } from "@opencode-ai/plugin/tool";
|
|
3
|
+
import { withTx } from "../state/db";
|
|
4
|
+
import { buildContextSnapshot } from "../workflow/context";
|
|
5
|
+
import { decideNextAction, createRunForStory, startStage, completeRun, failRun, getActiveRun, EVENT_TYPES, } from "../workflow/state-machine";
|
|
6
|
+
import { buildStageDirective, directiveHash } from "../workflow/directives";
|
|
7
|
+
import { injectChatPrompt } from "../ui/inject";
|
|
8
|
+
import { nowISO } from "../shared/time";
|
|
9
|
+
import { newEventId } from "../state/ids";
|
|
10
|
+
import { debug } from "../shared/log";
|
|
11
|
+
import { createToastManager } from "../ui/toasts";
|
|
12
|
+
import { acquireRepoLock } from "../state/repo-lock";
|
|
13
|
+
// Agent name mapping for case-sensitive resolution
|
|
14
|
+
export const STAGE_TO_AGENT_MAP = {
|
|
15
|
+
frame: "Frame",
|
|
16
|
+
plan: "Plan",
|
|
17
|
+
spec: "Spec",
|
|
18
|
+
implement: "Implement",
|
|
19
|
+
review: "Review",
|
|
20
|
+
verify: "Verify",
|
|
21
|
+
close: "Close"
|
|
22
|
+
};
|
|
23
|
+
export function resolveAgentName(stageKey, config, agents, warnings) {
|
|
24
|
+
// Use configurable agent names from config, fallback to hardcoded map, then General
|
|
25
|
+
const agentNames = config.agents?.stage_agent_names;
|
|
26
|
+
let candidate;
|
|
27
|
+
if (agentNames && agentNames[stageKey]) {
|
|
28
|
+
candidate = agentNames[stageKey];
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
candidate = STAGE_TO_AGENT_MAP[stageKey] || "General";
|
|
32
|
+
}
|
|
33
|
+
// Validate that the agent actually exists in the registry
|
|
34
|
+
if (agents && !agents[candidate]) {
|
|
35
|
+
const warning = `Agent "${candidate}" not found in registry for stage "${stageKey}". Falling back to General.`;
|
|
36
|
+
if (warnings)
|
|
37
|
+
warnings.push(warning);
|
|
38
|
+
else
|
|
39
|
+
console.warn(`[Astrocode] ${warning}`);
|
|
40
|
+
candidate = "General";
|
|
41
|
+
}
|
|
42
|
+
// Final guard: ensure General exists, fallback to built-in "general" if not
|
|
43
|
+
if (agents && !agents[candidate]) {
|
|
44
|
+
const finalWarning = `Critical: General agent not found in registry. Falling back to built-in "general".`;
|
|
45
|
+
if (warnings)
|
|
46
|
+
warnings.push(finalWarning);
|
|
47
|
+
else
|
|
48
|
+
console.warn(`[Astrocode] ${finalWarning}`);
|
|
49
|
+
return "general";
|
|
50
|
+
}
|
|
51
|
+
return candidate;
|
|
52
|
+
}
|
|
53
|
+
function stageGoal(stage, cfg) {
|
|
54
|
+
switch (stage) {
|
|
55
|
+
case "frame":
|
|
56
|
+
return "Define scope, constraints, and an unambiguous Definition of Done.";
|
|
57
|
+
case "plan":
|
|
58
|
+
return `Create 50-200 detailed implementation stories, each focused on a specific, implementable task. Break down every component into separate stories with clear acceptance criteria.`;
|
|
59
|
+
case "spec":
|
|
60
|
+
return "Produce minimal spec/contract: interfaces, invariants, acceptance checks.";
|
|
61
|
+
case "implement":
|
|
62
|
+
return "Implement the spec with minimal changes, referencing diffs and evidence as artifacts.";
|
|
63
|
+
case "review":
|
|
64
|
+
return "Review implementation for correctness, risks, and alignment with spec.";
|
|
65
|
+
case "verify":
|
|
66
|
+
return "Run verification commands and produce evidence artifacts.";
|
|
67
|
+
case "close":
|
|
68
|
+
return "Summarize outcome and confirm acceptance criteria, leaving clear breadcrumbs.";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function stageConstraints(stage, cfg) {
|
|
72
|
+
const common = [
|
|
73
|
+
"Do not narrate prompts.",
|
|
74
|
+
"Keep baton markdown short and structured.",
|
|
75
|
+
"If blocked: ask exactly ONE question and stop.",
|
|
76
|
+
];
|
|
77
|
+
if (stage === "plan") {
|
|
78
|
+
common.push(`Create 50-200 stories; each story must be implementable in 2-8 hours with clear acceptance criteria.`);
|
|
79
|
+
}
|
|
80
|
+
if (stage === "verify" && cfg.workflow.evidence_required.verify) {
|
|
81
|
+
common.push("Evidence required: ASTRO JSON must include evidence[] paths.");
|
|
82
|
+
}
|
|
83
|
+
return common;
|
|
84
|
+
}
|
|
85
|
+
function buildDelegationPrompt(opts) {
|
|
86
|
+
const { stageDirective, run_id, stage_key, stage_agent_name } = opts;
|
|
87
|
+
const stageUpper = stage_key.toUpperCase();
|
|
88
|
+
const prompt = [
|
|
89
|
+
`[SYSTEM DIRECTIVE: ASTROCODE — DELEGATE_STAGE_${stageUpper}]`,
|
|
90
|
+
``,
|
|
91
|
+
`Do this now, in order:`,
|
|
92
|
+
`1) Call the **task** tool to delegate to subagent \`${stage_agent_name}\`.`,
|
|
93
|
+
` - Pass this exact prompt text to the subagent (copy/paste):`,
|
|
94
|
+
``,
|
|
95
|
+
stageDirective,
|
|
96
|
+
``,
|
|
97
|
+
`2) When the subagent returns, immediately call **astro_stage_complete** with:`,
|
|
98
|
+
` - run_id = "${run_id}"`,
|
|
99
|
+
` - stage_key = "${stage_key}"`,
|
|
100
|
+
` - output_text = (the FULL subagent response text)`,
|
|
101
|
+
``,
|
|
102
|
+
`3) Then call **astro_workflow_proceed** again (mode=step).`,
|
|
103
|
+
``,
|
|
104
|
+
`Important: do NOT do any stage work yourself in orchestrator mode.`,
|
|
105
|
+
].join("\n").trim();
|
|
106
|
+
debug(`Delegating stage ${stage_key} to agent ${stage_agent_name}`, { prompt_length: prompt.length });
|
|
107
|
+
return prompt;
|
|
108
|
+
}
|
|
109
|
+
function buildUiMessage(e) {
|
|
110
|
+
switch (e.kind) {
|
|
111
|
+
case "stage_started": {
|
|
112
|
+
const agent = e.agent_name ? ` (${e.agent_name})` : "";
|
|
113
|
+
const title = "Astrocode";
|
|
114
|
+
const message = `Stage started: ${e.stage_key}${agent}`;
|
|
115
|
+
const chatText = [
|
|
116
|
+
`[SYSTEM DIRECTIVE: ASTROCODE — STAGE_STARTED]`,
|
|
117
|
+
``,
|
|
118
|
+
`Run: ${e.run_id}`,
|
|
119
|
+
`Stage: ${e.stage_key}${agent}`,
|
|
120
|
+
].join("\n");
|
|
121
|
+
return { title, message, variant: "info", chatText };
|
|
122
|
+
}
|
|
123
|
+
case "run_completed": {
|
|
124
|
+
const title = "Astrocode";
|
|
125
|
+
const message = `Run completed: ${e.run_id}`;
|
|
126
|
+
const chatText = [
|
|
127
|
+
`[SYSTEM DIRECTIVE: ASTROCODE — RUN_COMPLETED]`,
|
|
128
|
+
``,
|
|
129
|
+
`Run: ${e.run_id}`,
|
|
130
|
+
`Story: ${e.story_key}`,
|
|
131
|
+
].join("\n");
|
|
132
|
+
return { title, message, variant: "success", chatText };
|
|
133
|
+
}
|
|
134
|
+
case "run_failed": {
|
|
135
|
+
const title = "Astrocode";
|
|
136
|
+
const message = `Run failed: ${e.run_id} (${e.stage_key})`;
|
|
137
|
+
const chatText = [
|
|
138
|
+
`[SYSTEM DIRECTIVE: ASTROCODE — RUN_FAILED]`,
|
|
139
|
+
``,
|
|
140
|
+
`Run: ${e.run_id}`,
|
|
141
|
+
`Story: ${e.story_key}`,
|
|
142
|
+
`Stage: ${e.stage_key}`,
|
|
143
|
+
`Error: ${e.error_text}`,
|
|
144
|
+
].join("\n");
|
|
145
|
+
return { title, message, variant: "error", chatText };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export function createAstroWorkflowProceedTool(opts) {
|
|
150
|
+
const { ctx, config, db, agents } = opts;
|
|
151
|
+
const toasts = createToastManager({ ctx, throttleMs: config.ui.toasts.throttle_ms });
|
|
152
|
+
return tool({
|
|
153
|
+
description: "Deterministic harness: advances the DB-driven pipeline by one step (or loops bounded). Stops when LLM work is required (delegation/await).",
|
|
154
|
+
args: {
|
|
155
|
+
mode: tool.schema.enum(["step", "loop"]).default(config.workflow.default_mode),
|
|
156
|
+
max_steps: tool.schema.number().int().positive().default(config.workflow.default_max_steps),
|
|
157
|
+
},
|
|
158
|
+
execute: async ({ mode, max_steps }) => {
|
|
159
|
+
// Acquire repo lock to ensure no concurrent workflow operations
|
|
160
|
+
const lockPath = `${ctx.directory}/.astro/astro.lock`;
|
|
161
|
+
const repoLock = acquireRepoLock(lockPath);
|
|
162
|
+
try {
|
|
163
|
+
const sessionId = ctx.sessionID;
|
|
164
|
+
const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
|
|
165
|
+
const actions = [];
|
|
166
|
+
const warnings = [];
|
|
167
|
+
const startedAt = nowISO();
|
|
168
|
+
// Collect UI events emitted inside state-machine functions, then flush AFTER tx.
|
|
169
|
+
const uiEvents = [];
|
|
170
|
+
const emit = (e) => uiEvents.push(e);
|
|
171
|
+
for (let i = 0; i < steps; i++) {
|
|
172
|
+
const next = decideNextAction(db, config);
|
|
173
|
+
if (next.kind === "idle") {
|
|
174
|
+
actions.push("idle: no approved stories");
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
if (next.kind === "start_run") {
|
|
178
|
+
// SINGLE tx boundary: caller owns tx, state-machine is pure.
|
|
179
|
+
const { run_id } = withTx(db, () => createRunForStory(db, config, next.story_key));
|
|
180
|
+
actions.push(`started run ${run_id} for story ${next.story_key}`);
|
|
181
|
+
if (mode === "step")
|
|
182
|
+
break;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (next.kind === "complete_run") {
|
|
186
|
+
withTx(db, () => completeRun(db, next.run_id, emit));
|
|
187
|
+
actions.push(`completed run ${next.run_id}`);
|
|
188
|
+
if (mode === "step")
|
|
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
|
+
};
|
|
215
|
+
if (!agentExists(agentName)) {
|
|
216
|
+
const originalAgent = agentName;
|
|
217
|
+
console.warn(`[Astrocode] Agent ${agentName} not found. Falling back to orchestrator.`);
|
|
218
|
+
agentName = config.agents?.orchestrator_name || "Astro";
|
|
219
|
+
if (!agentExists(agentName)) {
|
|
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
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// NOTE: startStage owns its own tx (state-machine.ts).
|
|
228
|
+
withTx(db, () => {
|
|
229
|
+
startStage(db, active.run_id, next.stage_key, { subagent_type: agentName }, emit);
|
|
230
|
+
});
|
|
231
|
+
const context = buildContextSnapshot({
|
|
232
|
+
db,
|
|
233
|
+
config,
|
|
234
|
+
run_id: active.run_id,
|
|
235
|
+
next_action: `delegate stage ${next.stage_key}`,
|
|
236
|
+
});
|
|
237
|
+
const stageDirective = buildStageDirective({
|
|
238
|
+
config,
|
|
239
|
+
stage_key: next.stage_key,
|
|
240
|
+
run_id: active.run_id,
|
|
241
|
+
story_key: run.story_key,
|
|
242
|
+
story_title: story?.title ?? "(missing)",
|
|
243
|
+
stage_agent_name: agentName,
|
|
244
|
+
stage_goal: stageGoal(next.stage_key, config),
|
|
245
|
+
stage_constraints: stageConstraints(next.stage_key, config),
|
|
246
|
+
context_snapshot_md: context,
|
|
247
|
+
}).body;
|
|
248
|
+
const delegatePrompt = buildDelegationPrompt({
|
|
249
|
+
stageDirective,
|
|
250
|
+
run_id: active.run_id,
|
|
251
|
+
stage_key: next.stage_key,
|
|
252
|
+
stage_agent_name: agentName,
|
|
253
|
+
});
|
|
254
|
+
// Record continuation (best-effort; no tx wrapper needed but safe either way)
|
|
255
|
+
const h = directiveHash(delegatePrompt);
|
|
256
|
+
const now = nowISO();
|
|
257
|
+
if (sessionId) {
|
|
258
|
+
// This assumes continuations table exists in vNext schema.
|
|
259
|
+
db.prepare("INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'stage', ?, ?)").run(sessionId, active.run_id, h, `delegate ${next.stage_key}`, now);
|
|
260
|
+
}
|
|
261
|
+
// Visible injection so user can see state (awaited)
|
|
262
|
+
if (sessionId) {
|
|
263
|
+
await injectChatPrompt({ ctx, sessionId, text: delegatePrompt, agent: "Astro" });
|
|
264
|
+
const continueMessage = [
|
|
265
|
+
`[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_COMPLETION]`,
|
|
266
|
+
``,
|
|
267
|
+
`Stage \`${next.stage_key}\` delegated to \`${agentName}\`.`,
|
|
268
|
+
``,
|
|
269
|
+
`When \`${agentName}\` completes, call:`,
|
|
270
|
+
`astro_stage_complete(run_id="${active.run_id}", stage_key="${next.stage_key}", output_text="[paste subagent output here]")`,
|
|
271
|
+
``,
|
|
272
|
+
`This advances the workflow.`,
|
|
273
|
+
].join("\n");
|
|
274
|
+
await injectChatPrompt({ ctx, sessionId, text: continueMessage, agent: "Astro" });
|
|
275
|
+
}
|
|
276
|
+
actions.push(`delegated stage ${next.stage_key} via ${agentName}`);
|
|
277
|
+
// Stop here; subagent needs to run.
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
if (next.kind === "await_stage_completion") {
|
|
281
|
+
actions.push(`await stage completion: ${next.stage_key}`);
|
|
282
|
+
if (sessionId) {
|
|
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;
|
|
304
|
+
}
|
|
305
|
+
actions.push(`unhandled next action: ${next.kind}`);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
// Flush UI events (toast + prompt) AFTER state transitions
|
|
309
|
+
if (uiEvents.length > 0) {
|
|
310
|
+
for (const e of uiEvents) {
|
|
311
|
+
const msg = buildUiMessage(e);
|
|
312
|
+
if (config.ui.toasts.enabled) {
|
|
313
|
+
await toasts.show({
|
|
314
|
+
title: msg.title,
|
|
315
|
+
message: msg.message,
|
|
316
|
+
variant: msg.variant,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (ctx?.sessionID) {
|
|
320
|
+
await injectChatPrompt({
|
|
321
|
+
ctx,
|
|
322
|
+
sessionId: ctx.sessionID,
|
|
323
|
+
text: msg.chatText,
|
|
324
|
+
agent: "Astro",
|
|
325
|
+
});
|
|
326
|
+
}
|
|
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}`);
|
|
346
|
+
}
|
|
347
|
+
return lines.join("\n").trim();
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
// Always release the lock
|
|
351
|
+
repoLock.release();
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inject a visible prompt into the conversation.
|
|
3
|
+
* - Deterministic ordering per session
|
|
4
|
+
* - Correct SDK binding (prevents `this._client` undefined)
|
|
5
|
+
* - Awaitable: resolves when delivered, rejects after max retries
|
|
6
|
+
*/
|
|
7
|
+
export declare function injectChatPrompt(opts: {
|
|
8
|
+
ctx: any;
|
|
9
|
+
sessionId?: string;
|
|
10
|
+
text: string;
|
|
11
|
+
agent?: string;
|
|
12
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// src/ui/inject.ts
|
|
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();
|
|
8
|
+
function sleep(ms) {
|
|
9
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
10
|
+
}
|
|
11
|
+
function getPromptInvoker(ctx) {
|
|
12
|
+
const session = ctx?.client?.session;
|
|
13
|
+
const prompt = session?.prompt;
|
|
14
|
+
if (!session || typeof prompt !== "function") {
|
|
15
|
+
throw new Error("API not available (ctx.client.session.prompt)");
|
|
16
|
+
}
|
|
17
|
+
return { session, prompt };
|
|
18
|
+
}
|
|
19
|
+
async function tryInjectOnce(item) {
|
|
20
|
+
const { ctx, sessionId, text, agent = "Astro" } = item;
|
|
21
|
+
const prefixedText = `[${agent}]\n\n${text}`;
|
|
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
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function runSessionQueue(sessionId) {
|
|
42
|
+
if (running.has(sessionId))
|
|
43
|
+
return;
|
|
44
|
+
running.add(sessionId);
|
|
45
|
+
try {
|
|
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();
|
|
55
|
+
}
|
|
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;
|
|
65
|
+
}
|
|
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);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
running.delete(sessionId);
|
|
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
|
+
*/
|
|
85
|
+
export async function injectChatPrompt(opts) {
|
|
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);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type ToastVariant = "info" | "success" | "warning" | "error";
|
|
2
|
+
export type ToastOptions = {
|
|
3
|
+
title: string;
|
|
4
|
+
message: string;
|
|
5
|
+
variant?: ToastVariant;
|
|
6
|
+
durationMs?: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function createToastManager(opts: {
|
|
9
|
+
ctx: any;
|
|
10
|
+
throttleMs: number;
|
|
11
|
+
}): {
|
|
12
|
+
show: (toast: ToastOptions) => Promise<void>;
|
|
13
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function createToastManager(opts) {
|
|
2
|
+
const { ctx, throttleMs } = opts;
|
|
3
|
+
let lastAt = 0;
|
|
4
|
+
async function show(toast) {
|
|
5
|
+
const now = Date.now();
|
|
6
|
+
if (now - lastAt < throttleMs)
|
|
7
|
+
return;
|
|
8
|
+
lastAt = now;
|
|
9
|
+
const tui = ctx?.client?.tui;
|
|
10
|
+
if (tui?.showToast) {
|
|
11
|
+
try {
|
|
12
|
+
await tui.showToast({
|
|
13
|
+
title: toast.title,
|
|
14
|
+
message: toast.message,
|
|
15
|
+
variant: toast.variant ?? "info",
|
|
16
|
+
durationMs: toast.durationMs ?? 2500,
|
|
17
|
+
});
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// fall through
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Fallback: visible chat prompt
|
|
25
|
+
try {
|
|
26
|
+
const sessionId = ctx?.sessionID;
|
|
27
|
+
if (!sessionId)
|
|
28
|
+
return;
|
|
29
|
+
await ctx.client.session.prompt({
|
|
30
|
+
path: { id: sessionId },
|
|
31
|
+
body: { parts: [{ type: "text", text: `[ASTRO TOAST] ${toast.title}: ${toast.message}` }] },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// ignore
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { show };
|
|
39
|
+
}
|