bosun 0.41.8 → 0.41.10
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/.env.example +1 -1
- package/README.md +23 -1
- package/agent/agent-event-bus.mjs +31 -2
- package/agent/agent-pool.mjs +275 -40
- package/agent/agent-prompts.mjs +9 -1
- package/agent/agent-supervisor.mjs +22 -0
- package/agent/autofix.mjs +1 -1
- package/agent/primary-agent.mjs +115 -5
- package/cli.mjs +3 -2
- package/config/config.mjs +47 -33
- package/config/context-shredding-config.mjs +1 -1
- package/config/repo-root.mjs +41 -33
- package/desktop/main.mjs +350 -25
- package/desktop/preload.cjs +8 -0
- package/desktop/preload.mjs +19 -0
- package/entrypoint.mjs +332 -0
- package/git/sdk-conflict-resolver.mjs +1 -1
- package/infra/health-status.mjs +72 -0
- package/infra/library-manager.mjs +58 -1
- package/infra/maintenance.mjs +1 -2
- package/infra/monitor.mjs +26 -8
- package/infra/session-tracker.mjs +30 -3
- package/package.json +12 -4
- package/server/bosun-mcp-server.mjs +1004 -0
- package/server/setup-web-server.mjs +288 -259
- package/server/ui-server.mjs +1323 -26
- package/shell/claude-shell.mjs +14 -1
- package/shell/codex-config.mjs +1 -1
- package/shell/codex-model-profiles.mjs +170 -30
- package/shell/codex-shell.mjs +63 -18
- package/shell/opencode-providers.mjs +20 -8
- package/task/task-executor.mjs +28 -0
- package/task/task-store.mjs +13 -4
- package/telegram/telegram-sentinel.mjs +54 -3
- package/tools/list-todos.mjs +7 -1
- package/ui/app.js +3 -2
- package/ui/components/agent-selector.js +127 -0
- package/ui/components/session-list.js +15 -10
- package/ui/demo-defaults.js +334 -336
- package/ui/modules/router.js +2 -0
- package/ui/modules/state.js +13 -5
- package/ui/tabs/chat.js +3 -0
- package/ui/tabs/library.js +284 -52
- package/ui/tabs/tasks.js +5 -13
- package/ui/tabs/workflows.js +766 -3
- package/workflow/workflow-engine.mjs +246 -5
- package/workflow/workflow-nodes/definitions.mjs +37 -0
- package/workflow/workflow-nodes.mjs +1014 -184
- package/workflow/workflow-templates.mjs +0 -5
- package/workflow-templates/_helpers.mjs +253 -0
- package/workflow-templates/agents.mjs +199 -226
- package/workflow-templates/github.mjs +106 -16
- package/workflow-templates/sub-workflows.mjs +233 -0
- package/workflow-templates/task-execution.mjs +125 -471
- package/workflow-templates/task-lifecycle.mjs +11 -48
- package/workspace/command-diagnostics.mjs +460 -0
- package/workspace/context-cache.mjs +396 -28
- package/workspace/worktree-manager.mjs +1 -1
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* NO → release-slot → log skipped
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
-
import { node, edge, resetLayout } from "./_helpers.mjs";
|
|
38
|
+
import { node, edge, resetLayout, agentPhase } from "./_helpers.mjs";
|
|
39
39
|
|
|
40
40
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
41
41
|
// Task Lifecycle — Full Task Execution Pipeline
|
|
@@ -175,46 +175,19 @@ export const TASK_LIFECYCLE_TEMPLATE = {
|
|
|
175
175
|
repositories: "{{repositories}}",
|
|
176
176
|
}, { x: 200, y: 1610 }),
|
|
177
177
|
// ── Execute agent (phase 1: planning) ───────────────────────────────
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
sdk: "{{resolvedSdk}}",
|
|
182
|
-
model: "{{resolvedModel}}",
|
|
183
|
-
agentProfile: "{{agentProfile}}",
|
|
184
|
-
cwd: "{{worktreePath}}",
|
|
185
|
-
timeoutMs: "{{taskTimeoutMs}}",
|
|
186
|
-
maxRetries: "{{maxRetries}}",
|
|
187
|
-
maxContinues: "{{maxContinues}}",
|
|
188
|
-
failOnError: false,
|
|
189
|
-
}, { x: 200, y: 1740 }),
|
|
178
|
+
agentPhase("run-agent-plan", "Agent Plan",
|
|
179
|
+
"{{_taskPrompt}}\n\nExecution phase: planning. Produce a concrete implementation plan and identify required tests. Do not make code changes in this phase.",
|
|
180
|
+
{}, { x: 200, y: 1740 }),
|
|
190
181
|
|
|
191
182
|
// ── Execute agent (phase 2: tests-first) ────────────────────────────
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
sdk: "{{resolvedSdk}}",
|
|
196
|
-
model: "{{resolvedModel}}",
|
|
197
|
-
agentProfile: "{{agentProfile}}",
|
|
198
|
-
cwd: "{{worktreePath}}",
|
|
199
|
-
timeoutMs: "{{taskTimeoutMs}}",
|
|
200
|
-
maxRetries: "{{maxRetries}}",
|
|
201
|
-
maxContinues: "{{maxContinues}}",
|
|
202
|
-
failOnError: false,
|
|
203
|
-
}, { x: 200, y: 1545 }),
|
|
183
|
+
agentPhase("run-agent-tests", "Agent Tests",
|
|
184
|
+
"{{_taskPrompt}}\n\nExecution phase: tests. Write or update tests first for the target behavior, then validate failures/pass criteria before implementation changes.",
|
|
185
|
+
{}, { x: 200, y: 1545 }),
|
|
204
186
|
|
|
205
187
|
// ── Execute agent (phase 3: implementation + verification) ──────────
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
sdk: "{{resolvedSdk}}",
|
|
210
|
-
model: "{{resolvedModel}}",
|
|
211
|
-
agentProfile: "{{agentProfile}}",
|
|
212
|
-
cwd: "{{worktreePath}}",
|
|
213
|
-
timeoutMs: "{{taskTimeoutMs}}",
|
|
214
|
-
maxRetries: "{{maxRetries}}",
|
|
215
|
-
maxContinues: "{{maxContinues}}",
|
|
216
|
-
failOnError: false,
|
|
217
|
-
}, { x: 200, y: 1610 }),
|
|
188
|
+
agentPhase("run-agent-implement", "Agent Implement",
|
|
189
|
+
"{{_taskPrompt}}\n\nExecution phase: implementation. Complete implementation after tests exist, run required verification (tests/lint/build), then commit, push, and create/update PR.",
|
|
190
|
+
{}, { x: 200, y: 1610 }),
|
|
218
191
|
|
|
219
192
|
// ── Check if claim was stolen during agent execution ─────────────────
|
|
220
193
|
node("claim-stolen", "condition.expression", "Claim Stolen?", {
|
|
@@ -681,17 +654,7 @@ export const VE_ORCHESTRATOR_LITE_TEMPLATE = {
|
|
|
681
654
|
}, { x: 300, y: 1480 }),
|
|
682
655
|
|
|
683
656
|
// ── Run agent ────────────────────────────────────────────────────────
|
|
684
|
-
|
|
685
|
-
prompt: "{{_taskPrompt}}",
|
|
686
|
-
taskId: "{{taskId}}",
|
|
687
|
-
sdk: "{{resolvedSdk}}",
|
|
688
|
-
model: "{{resolvedModel}}",
|
|
689
|
-
agentProfile: "{{agentProfile}}",
|
|
690
|
-
cwd: "{{worktreePath}}",
|
|
691
|
-
timeoutMs: "{{taskTimeoutMs}}",
|
|
692
|
-
maxRetries: "{{maxRetries}}",
|
|
693
|
-
failOnError: false,
|
|
694
|
-
}, { x: 300, y: 1610 }),
|
|
657
|
+
agentPhase("agent", "Run Agent", "{{_taskPrompt}}", {}, { x: 300, y: 1610 }),
|
|
695
658
|
|
|
696
659
|
// ── Detect commits ───────────────────────────────────────────────────
|
|
697
660
|
node("commits", "action.detect_new_commits", "Check Commits", {
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { resolve, dirname } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = dirname(__filename);
|
|
6
|
+
|
|
7
|
+
const STATE_FILE = process.env.BOSUN_COMMAND_DIAGNOSTICS_STATE_FILE
|
|
8
|
+
? resolve(process.env.BOSUN_COMMAND_DIAGNOSTICS_STATE_FILE)
|
|
9
|
+
: resolve(__dirname, "..", ".cache", "command-diagnostics", "state.json");
|
|
10
|
+
const CACHE_DIR = dirname(STATE_FILE);
|
|
11
|
+
const MAX_STATE_RECORDS = 120;
|
|
12
|
+
const MAX_SCAN_CHARS = 20_000;
|
|
13
|
+
const MAX_SCAN_LINES = 400;
|
|
14
|
+
|
|
15
|
+
let _fsPromises = null;
|
|
16
|
+
let _stateCache = null;
|
|
17
|
+
|
|
18
|
+
async function getFs() {
|
|
19
|
+
if (!_fsPromises) {
|
|
20
|
+
_fsPromises = await import("node:fs/promises");
|
|
21
|
+
}
|
|
22
|
+
return _fsPromises;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function ensureCacheDir() {
|
|
26
|
+
const fs = await getFs();
|
|
27
|
+
await fs.mkdir(CACHE_DIR, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function loadState() {
|
|
31
|
+
if (_stateCache) return _stateCache;
|
|
32
|
+
const fs = await getFs();
|
|
33
|
+
try {
|
|
34
|
+
const raw = await fs.readFile(STATE_FILE, "utf8");
|
|
35
|
+
const parsed = JSON.parse(raw);
|
|
36
|
+
_stateCache = parsed && typeof parsed === "object" ? parsed : { records: {} };
|
|
37
|
+
} catch {
|
|
38
|
+
_stateCache = { records: {} };
|
|
39
|
+
}
|
|
40
|
+
if (!_stateCache.records || typeof _stateCache.records !== "object") {
|
|
41
|
+
_stateCache.records = {};
|
|
42
|
+
}
|
|
43
|
+
return _stateCache;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function saveState(state) {
|
|
47
|
+
const fs = await getFs();
|
|
48
|
+
await ensureCacheDir();
|
|
49
|
+
await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2), "utf8");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function collapseWhitespace(value) {
|
|
53
|
+
return String(value || "").replace(/\s+/g, " ").trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function uniqueValues(values = []) {
|
|
57
|
+
return [...new Set(values.filter(Boolean).map((value) => String(value).trim()).filter(Boolean))];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function quoteArg(value) {
|
|
61
|
+
const raw = String(value || "");
|
|
62
|
+
if (!raw) return '""';
|
|
63
|
+
if (!/[\\s"'|&()\\\\]/.test(raw)) return raw;
|
|
64
|
+
return `"${raw.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getDiagnosticSample(value = "") {
|
|
68
|
+
return String(value || "").slice(0, MAX_SCAN_CHARS);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getDiagnosticLines(value = "") {
|
|
72
|
+
return getDiagnosticSample(value).split(/\r?\n/).slice(0, MAX_SCAN_LINES);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function trimTokenEdge(token = "") {
|
|
76
|
+
let start = 0;
|
|
77
|
+
let end = token.length;
|
|
78
|
+
while (start < end && "\"'`([{<".includes(token[start])) start += 1;
|
|
79
|
+
while (end > start && "\"'`)]}>,;.!?".includes(token[end - 1])) end -= 1;
|
|
80
|
+
return token.slice(start, end);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isLikelyFileRef(token = "") {
|
|
84
|
+
if (!token || token.length < 3) return false;
|
|
85
|
+
if (token.includes("://")) return false;
|
|
86
|
+
const hasPathSeparator = token.includes("/") || token.includes("\\");
|
|
87
|
+
const hasDrivePrefix = /^[A-Za-z]:/.test(token);
|
|
88
|
+
const hasRelativePrefix = token.startsWith("./") || token.startsWith("../") || token.startsWith("~/");
|
|
89
|
+
if (!hasPathSeparator && !hasDrivePrefix && !hasRelativePrefix) return false;
|
|
90
|
+
return token.includes(".");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function extractLeadingInteger(value = "") {
|
|
94
|
+
let digits = "";
|
|
95
|
+
for (const char of String(value || "")) {
|
|
96
|
+
if (char < "0" || char > "9") break;
|
|
97
|
+
digits += char;
|
|
98
|
+
}
|
|
99
|
+
return digits ? Number(digits) : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function findSummaryCount(text = "", label = "failed") {
|
|
103
|
+
const lowerLabel = String(label || "").toLowerCase();
|
|
104
|
+
for (const line of getDiagnosticLines(text)) {
|
|
105
|
+
const tokens = line.trim().split(/\s+/).filter(Boolean);
|
|
106
|
+
for (let index = 0; index < tokens.length - 1; index += 1) {
|
|
107
|
+
if (tokens[index + 1].toLowerCase().startsWith(lowerLabel)) {
|
|
108
|
+
const numeric = extractLeadingInteger(tokens[index].replace(/^=/, ""));
|
|
109
|
+
if (numeric !== null) return numeric;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeCommandSignature(command = "", args = []) {
|
|
117
|
+
const commandLine = collapseWhitespace(
|
|
118
|
+
[String(command || "").trim(), ...(Array.isArray(args) ? args.map((value) => String(value || "").trim()) : [])]
|
|
119
|
+
.filter(Boolean)
|
|
120
|
+
.join(" "),
|
|
121
|
+
);
|
|
122
|
+
return commandLine.toLowerCase();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolveCommandKind(commandLine = "", output = "") {
|
|
126
|
+
const lower = `${commandLine}\n${output}`.toLowerCase();
|
|
127
|
+
if (/\bdotnet\s+test\b/.test(lower)) return { family: "test", runner: "dotnet-test" };
|
|
128
|
+
if (/\b(?:python(?:3)?\s+-m\s+pytest|pytest)\b/.test(lower)) return { family: "test", runner: "pytest" };
|
|
129
|
+
if (/\bvitest\b/.test(lower)) return { family: "test", runner: "vitest" };
|
|
130
|
+
if (/\bjest\b/.test(lower)) return { family: "test", runner: "jest" };
|
|
131
|
+
if (/\bgo\s+test\b/.test(lower)) return { family: "test", runner: "go-test" };
|
|
132
|
+
const outputLines = getDiagnosticLines(output);
|
|
133
|
+
const hasVitestFailureLine = outputLines.some((line) => {
|
|
134
|
+
const trimmed = line.trimStart().toLowerCase();
|
|
135
|
+
return trimmed.startsWith("fail ") && (trimmed.includes(".test.") || trimmed.includes(".spec."));
|
|
136
|
+
});
|
|
137
|
+
const hasVitestSummary = lower.includes("test files") && lower.includes("failed");
|
|
138
|
+
if (hasVitestFailureLine || hasVitestSummary) {
|
|
139
|
+
return { family: "test", runner: "vitest" };
|
|
140
|
+
}
|
|
141
|
+
const hasPytestFailureLine = outputLines.some((line) => {
|
|
142
|
+
const trimmed = line.trimStart();
|
|
143
|
+
return trimmed.startsWith("FAILED ") && trimmed.includes("::") && trimmed.includes(" - ");
|
|
144
|
+
});
|
|
145
|
+
const hasPytestCollection = lower.includes("collected ") && lower.includes(" items");
|
|
146
|
+
if (hasPytestFailureLine || hasPytestCollection) {
|
|
147
|
+
return { family: "test", runner: "pytest" };
|
|
148
|
+
}
|
|
149
|
+
if (/\bgit\s+diff\b/.test(lower)) return { family: "git", runner: "git-diff" };
|
|
150
|
+
if (/\bgit\s+status\b/.test(lower)) return { family: "git", runner: "git-status" };
|
|
151
|
+
if (/\bgit\s+(show|log|grep|rebase|merge|pull|push)\b/.test(lower)) return { family: "git", runner: "git" };
|
|
152
|
+
if (/\b(test|build|compile|lint|typecheck|msbuild|tsc|cargo|mvn|gradle)\b/.test(lower)) return { family: "build", runner: "build" };
|
|
153
|
+
return { family: "generic", runner: "generic" };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function countRegex(text, regex) {
|
|
157
|
+
const matches = String(text || "").match(regex);
|
|
158
|
+
return Array.isArray(matches) ? matches.length : 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function extractFileRefs(text = "", limit = 10) {
|
|
162
|
+
const refs = [];
|
|
163
|
+
for (const line of getDiagnosticLines(text)) {
|
|
164
|
+
for (const token of line.split(/\s+/)) {
|
|
165
|
+
const trimmed = trimTokenEdge(token);
|
|
166
|
+
if (!isLikelyFileRef(trimmed)) continue;
|
|
167
|
+
refs.push(trimmed);
|
|
168
|
+
if (refs.length >= limit) {
|
|
169
|
+
return uniqueValues(refs);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return uniqueValues(refs);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseDotnetTest(text) {
|
|
177
|
+
const failedTargets = [];
|
|
178
|
+
for (const match of String(text).matchAll(/^Failed\s+([A-Za-z0-9_.`]+)\s+\[[^\]]+\]/gm)) {
|
|
179
|
+
failedTargets.push(match[1]);
|
|
180
|
+
}
|
|
181
|
+
const summaryMatch = text.match(/Failed!\s*-\s*Failed:\s*(\d+),\s*Passed:\s*(\d+),\s*Skipped:\s*(\d+),\s*Total:\s*(\d+)/i);
|
|
182
|
+
const summary = summaryMatch
|
|
183
|
+
? `${summaryMatch[1]} failed, ${summaryMatch[2]} passed, ${summaryMatch[3]} skipped (${summaryMatch[4]} total)`
|
|
184
|
+
: failedTargets.length
|
|
185
|
+
? `${failedTargets.length} failing .NET test${failedTargets.length === 1 ? "" : "s"}`
|
|
186
|
+
: "";
|
|
187
|
+
return {
|
|
188
|
+
failedTargets: uniqueValues(failedTargets),
|
|
189
|
+
summary,
|
|
190
|
+
rerunCommand: failedTargets.length
|
|
191
|
+
? `dotnet test --filter ${quoteArg(failedTargets.map((target) => `FullyQualifiedName~${target}`).join("|"))}`
|
|
192
|
+
: null,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function parsePytest(text) {
|
|
197
|
+
const failedTargets = [];
|
|
198
|
+
for (const line of getDiagnosticLines(text)) {
|
|
199
|
+
const trimmed = line.trimStart();
|
|
200
|
+
if (!trimmed.startsWith("FAILED ")) continue;
|
|
201
|
+
const payload = trimmed.slice("FAILED ".length).trim();
|
|
202
|
+
const separatorIndex = payload.indexOf(" - ");
|
|
203
|
+
failedTargets.push((separatorIndex === -1 ? payload : payload.slice(0, separatorIndex)).trim());
|
|
204
|
+
}
|
|
205
|
+
const failedCount = findSummaryCount(text, "failed") ?? failedTargets.length;
|
|
206
|
+
const summary = failedCount ? `${failedCount} failed pytest target${failedCount === 1 ? "" : "s"}` : "";
|
|
207
|
+
return {
|
|
208
|
+
failedTargets: uniqueValues(failedTargets),
|
|
209
|
+
summary,
|
|
210
|
+
rerunCommand: failedTargets.length
|
|
211
|
+
? `pytest ${failedTargets.slice(0, 8).map(quoteArg).join(" ")}`
|
|
212
|
+
: null,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function parseVitestLike(text, runner = "vitest") {
|
|
217
|
+
const failedTargets = [];
|
|
218
|
+
for (const line of getDiagnosticLines(text)) {
|
|
219
|
+
const trimmed = line.trimStart();
|
|
220
|
+
if (!trimmed.startsWith("FAIL ")) continue;
|
|
221
|
+
failedTargets.push(trimmed.slice("FAIL ".length).trim());
|
|
222
|
+
}
|
|
223
|
+
const fileRefs = extractFileRefs(text, 12).filter((value) => /\.(test|spec)\.[A-Za-z0-9]+(?::\d+)?$/i.test(value));
|
|
224
|
+
const effectiveTargets = uniqueValues([...failedTargets, ...fileRefs]);
|
|
225
|
+
const summaryMatch =
|
|
226
|
+
text.match(/Test Files\s+(\d+)\s+failed(?:\s*\|\s*(\d+)\s+passed)?/i) ||
|
|
227
|
+
text.match(/Tests?\s*:\s*(\d+)\s+failed/i);
|
|
228
|
+
const summary = summaryMatch
|
|
229
|
+
? summaryMatch[2]
|
|
230
|
+
? `${summaryMatch[1]} failed file${summaryMatch[1] === "1" ? "" : "s"}, ${summaryMatch[2]} passed`
|
|
231
|
+
: `${summaryMatch[1]} failed test target${summaryMatch[1] === "1" ? "" : "s"}`
|
|
232
|
+
: effectiveTargets.length
|
|
233
|
+
? `${effectiveTargets.length} failing ${runner} target${effectiveTargets.length === 1 ? "" : "s"}`
|
|
234
|
+
: "";
|
|
235
|
+
return {
|
|
236
|
+
failedTargets: effectiveTargets,
|
|
237
|
+
summary,
|
|
238
|
+
rerunCommand: effectiveTargets.length
|
|
239
|
+
? `${runner === "jest" ? "jest" : "vitest run"} ${effectiveTargets.slice(0, 8).map(quoteArg).join(" ")}`
|
|
240
|
+
: null,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function parseGoTest(text) {
|
|
245
|
+
const failedTests = [];
|
|
246
|
+
for (const match of String(text).matchAll(/^--- FAIL:\s+([^\s(]+)/gm)) {
|
|
247
|
+
failedTests.push(match[1]);
|
|
248
|
+
}
|
|
249
|
+
const failedPackages = [];
|
|
250
|
+
for (const match of String(text).matchAll(/^FAIL\t([^\s]+)\t/gm)) {
|
|
251
|
+
failedPackages.push(match[1]);
|
|
252
|
+
}
|
|
253
|
+
const summary = failedTests.length
|
|
254
|
+
? `${failedTests.length} failing Go test${failedTests.length === 1 ? "" : "s"}`
|
|
255
|
+
: failedPackages.length
|
|
256
|
+
? `${failedPackages.length} failing Go package${failedPackages.length === 1 ? "" : "s"}`
|
|
257
|
+
: "";
|
|
258
|
+
let rerunCommand = null;
|
|
259
|
+
if (failedTests.length) {
|
|
260
|
+
rerunCommand = `go test ./... -run ${quoteArg(`^(${uniqueValues(failedTests).slice(0, 8).join("|")})$`)}`;
|
|
261
|
+
} else if (failedPackages.length) {
|
|
262
|
+
rerunCommand = `go test ${uniqueValues(failedPackages).slice(0, 8).map(quoteArg).join(" ")}`;
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
failedTargets: uniqueValues([...failedTests, ...failedPackages]),
|
|
266
|
+
summary,
|
|
267
|
+
rerunCommand,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function parseGitOutput(text, runner, commandLine) {
|
|
272
|
+
const fileRefs = extractFileRefs(text, 12);
|
|
273
|
+
const diffFiles = countRegex(text, /^diff --git /gm);
|
|
274
|
+
const changedEntries = countRegex(text, /^(M|A|D|R|\?\?)\s+/gm);
|
|
275
|
+
const summary = runner === "git-diff"
|
|
276
|
+
? diffFiles
|
|
277
|
+
? `${diffFiles} diff file${diffFiles === 1 ? "" : "s"} in output`
|
|
278
|
+
: fileRefs.length
|
|
279
|
+
? `${fileRefs.length} referenced file${fileRefs.length === 1 ? "" : "s"} in diff output`
|
|
280
|
+
: ""
|
|
281
|
+
: runner === "git-status"
|
|
282
|
+
? changedEntries
|
|
283
|
+
? `${changedEntries} changed path${changedEntries === 1 ? "" : "s"} in status output`
|
|
284
|
+
: ""
|
|
285
|
+
: fileRefs.length
|
|
286
|
+
? `${fileRefs.length} referenced file${fileRefs.length === 1 ? "" : "s"} in git output`
|
|
287
|
+
: "";
|
|
288
|
+
let rerunCommand = null;
|
|
289
|
+
if (runner === "git-diff" && !/\s--stat\b/.test(commandLine)) {
|
|
290
|
+
rerunCommand = "git diff --stat";
|
|
291
|
+
} else if (runner === "git-status" && !/\s--short\b/.test(commandLine)) {
|
|
292
|
+
rerunCommand = "git status --short";
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
failedTargets: fileRefs,
|
|
296
|
+
summary,
|
|
297
|
+
rerunCommand,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function parseGeneric(text) {
|
|
302
|
+
const fileRefs = extractFileRefs(text, 12);
|
|
303
|
+
const errorCount = countRegex(text, /\b(error|failed|fatal|panic|traceback|exception)\b/gi);
|
|
304
|
+
return {
|
|
305
|
+
failedTargets: fileRefs,
|
|
306
|
+
summary: errorCount
|
|
307
|
+
? `${errorCount} error signal${errorCount === 1 ? "" : "s"} detected`
|
|
308
|
+
: fileRefs.length
|
|
309
|
+
? `${fileRefs.length} file anchor${fileRefs.length === 1 ? "" : "s"} detected`
|
|
310
|
+
: "",
|
|
311
|
+
rerunCommand: null,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function buildDelta(previousTargets = [], currentTargets = []) {
|
|
316
|
+
const prevSet = new Set(previousTargets);
|
|
317
|
+
const currSet = new Set(currentTargets);
|
|
318
|
+
const resolved = previousTargets.filter((value) => !currSet.has(value));
|
|
319
|
+
const remaining = currentTargets.filter((value) => prevSet.has(value));
|
|
320
|
+
const introduced = currentTargets.filter((value) => !prevSet.has(value));
|
|
321
|
+
return {
|
|
322
|
+
resolved: uniqueValues(resolved),
|
|
323
|
+
remaining: uniqueValues(remaining),
|
|
324
|
+
introduced: uniqueValues(introduced),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function deriveHint({ family, runner, text, exitCode, insufficientSignal }) {
|
|
329
|
+
const normalized = String(text || "").toLowerCase();
|
|
330
|
+
if (insufficientSignal) {
|
|
331
|
+
return "Signal coverage is low. Retrieve the full log or rerun with a narrower test/build command.";
|
|
332
|
+
}
|
|
333
|
+
if (/econnrefused|connection refused|service unavailable|timed out|timeout/.test(normalized)) {
|
|
334
|
+
return "Check dependent services or network reachability before rerunning.";
|
|
335
|
+
}
|
|
336
|
+
if (/permission denied|eacces|access is denied/.test(normalized)) {
|
|
337
|
+
return "Fix permissions or sandbox access before rerunning.";
|
|
338
|
+
}
|
|
339
|
+
if (/fixture|setup failed|conftest|beforeall|collection failed/.test(normalized)) {
|
|
340
|
+
return "Fix shared setup or fixture failures before rerunning the whole suite.";
|
|
341
|
+
}
|
|
342
|
+
if (family === "git" && exitCode !== 0) {
|
|
343
|
+
return "Narrow the git view first, then inspect the full log if the failure is still unclear.";
|
|
344
|
+
}
|
|
345
|
+
if (runner === "dotnet-test" && /cs\d+|msb\d+|nu\d+/i.test(text)) {
|
|
346
|
+
return "Resolve the reported build or package diagnostics before rerunning tests.";
|
|
347
|
+
}
|
|
348
|
+
return "";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function pruneRecords(records) {
|
|
352
|
+
const entries = Object.entries(records || {}).sort((left, right) =>
|
|
353
|
+
String(right[1]?.updatedAt || "").localeCompare(String(left[1]?.updatedAt || "")),
|
|
354
|
+
);
|
|
355
|
+
return Object.fromEntries(entries.slice(0, MAX_STATE_RECORDS));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function renderCommandDiagnosticFooter(diagnostic = null) {
|
|
359
|
+
if (!diagnostic || typeof diagnostic !== "object") return "";
|
|
360
|
+
const lines = [];
|
|
361
|
+
if (diagnostic.summary) lines.push(`Summary: ${diagnostic.summary}`);
|
|
362
|
+
if (diagnostic.deltaSummary) lines.push(`Delta: ${diagnostic.deltaSummary}`);
|
|
363
|
+
if (diagnostic.suggestedRerun) lines.push(`Suggested rerun: ${diagnostic.suggestedRerun}`);
|
|
364
|
+
if (diagnostic.hint) lines.push(`Hint: ${diagnostic.hint}`);
|
|
365
|
+
if (diagnostic.insufficientSignal) lines.push("Signal coverage: low");
|
|
366
|
+
return lines.length ? `Diagnostics:\n${lines.join("\n")}` : "";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export async function analyzeCommandDiagnostic(payload = {}) {
|
|
370
|
+
const command = String(payload.command || "").trim();
|
|
371
|
+
const args = Array.isArray(payload.args) ? payload.args.map((value) => String(value || "")) : [];
|
|
372
|
+
const output = String(payload.output || payload.stdout || "");
|
|
373
|
+
const stderr = String(payload.stderr || "");
|
|
374
|
+
const text = [output.trim(), stderr.trim() && stderr.trim() !== output.trim() ? `[stderr]\n${stderr.trim()}` : ""]
|
|
375
|
+
.filter(Boolean)
|
|
376
|
+
.join("\n\n")
|
|
377
|
+
.trim();
|
|
378
|
+
if (!command || !text) return null;
|
|
379
|
+
|
|
380
|
+
const commandLine = collapseWhitespace([command, ...args].join(" "));
|
|
381
|
+
const exitCode = Number.isFinite(Number(payload.exitCode)) ? Number(payload.exitCode) : 0;
|
|
382
|
+
const { family, runner } = resolveCommandKind(commandLine, text);
|
|
383
|
+
|
|
384
|
+
let parsed;
|
|
385
|
+
switch (runner) {
|
|
386
|
+
case "dotnet-test":
|
|
387
|
+
parsed = parseDotnetTest(text);
|
|
388
|
+
break;
|
|
389
|
+
case "pytest":
|
|
390
|
+
parsed = parsePytest(text);
|
|
391
|
+
break;
|
|
392
|
+
case "vitest":
|
|
393
|
+
case "jest":
|
|
394
|
+
parsed = parseVitestLike(text, runner);
|
|
395
|
+
break;
|
|
396
|
+
case "go-test":
|
|
397
|
+
parsed = parseGoTest(text);
|
|
398
|
+
break;
|
|
399
|
+
case "git":
|
|
400
|
+
case "git-diff":
|
|
401
|
+
case "git-status":
|
|
402
|
+
parsed = parseGitOutput(text, runner, commandLine);
|
|
403
|
+
break;
|
|
404
|
+
default:
|
|
405
|
+
parsed = parseGeneric(text);
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const failedTargets = uniqueValues(parsed.failedTargets || []);
|
|
410
|
+
const fileAnchors = extractFileRefs(text, 10);
|
|
411
|
+
const insufficientSignal =
|
|
412
|
+
exitCode !== 0 &&
|
|
413
|
+
failedTargets.length === 0 &&
|
|
414
|
+
fileAnchors.length === 0 &&
|
|
415
|
+
!parsed.summary &&
|
|
416
|
+
text.length >= 1200;
|
|
417
|
+
|
|
418
|
+
const state = await loadState();
|
|
419
|
+
const commandKey = normalizeCommandSignature(command, args);
|
|
420
|
+
const previous = state.records[commandKey] || null;
|
|
421
|
+
const delta = previous ? buildDelta(previous.failedTargets || [], failedTargets) : null;
|
|
422
|
+
|
|
423
|
+
const deltaParts = [];
|
|
424
|
+
if (delta) {
|
|
425
|
+
if (delta.resolved.length) deltaParts.push(`${delta.resolved.length} resolved`);
|
|
426
|
+
if (delta.remaining.length) deltaParts.push(`${delta.remaining.length} still failing`);
|
|
427
|
+
if (delta.introduced.length) deltaParts.push(`${delta.introduced.length} new`);
|
|
428
|
+
}
|
|
429
|
+
const deltaSummary = deltaParts.join(", ");
|
|
430
|
+
const suggestedRerun = parsed.rerunCommand || null;
|
|
431
|
+
const hint = deriveHint({ family, runner, text, exitCode, insufficientSignal });
|
|
432
|
+
|
|
433
|
+
state.records[commandKey] = {
|
|
434
|
+
updatedAt: new Date().toISOString(),
|
|
435
|
+
family,
|
|
436
|
+
runner,
|
|
437
|
+
commandLine,
|
|
438
|
+
exitCode,
|
|
439
|
+
failedTargets: failedTargets.slice(0, 40),
|
|
440
|
+
summary: parsed.summary || "",
|
|
441
|
+
};
|
|
442
|
+
state.records = pruneRecords(state.records);
|
|
443
|
+
await saveState(state);
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
family,
|
|
447
|
+
runner,
|
|
448
|
+
commandKey,
|
|
449
|
+
summary: parsed.summary || "",
|
|
450
|
+
failedTargets,
|
|
451
|
+
fileAnchors,
|
|
452
|
+
insufficientSignal,
|
|
453
|
+
deltaSummary,
|
|
454
|
+
resolvedTargets: delta?.resolved || [],
|
|
455
|
+
remainingTargets: delta?.remaining || [],
|
|
456
|
+
newTargets: delta?.introduced || [],
|
|
457
|
+
suggestedRerun,
|
|
458
|
+
hint,
|
|
459
|
+
};
|
|
460
|
+
}
|