@os-eco/overstory-cli 0.6.9 → 0.6.11
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/README.md +161 -265
- package/agents/builder.md +6 -15
- package/agents/lead.md +13 -6
- package/agents/merger.md +5 -13
- package/agents/reviewer.md +2 -9
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +105 -0
- package/src/agents/hooks-deployer.ts +26 -11
- package/src/agents/manifest.test.ts +1 -0
- package/src/agents/overlay.test.ts +235 -1
- package/src/agents/overlay.ts +107 -9
- package/src/commands/completions.test.ts +8 -20
- package/src/commands/completions.ts +7 -5
- package/src/commands/coordinator.ts +4 -4
- package/src/commands/doctor.ts +97 -48
- package/src/commands/ecosystem.ts +291 -0
- package/src/commands/feed.ts +2 -2
- package/src/commands/group.ts +4 -4
- package/src/commands/mail.test.ts +63 -1
- package/src/commands/mail.ts +18 -1
- package/src/commands/merge.ts +2 -2
- package/src/commands/monitor.ts +2 -2
- package/src/commands/sling.test.ts +174 -27
- package/src/commands/sling.ts +96 -12
- package/src/commands/status.ts +1 -1
- package/src/commands/supervisor.ts +4 -4
- package/src/commands/trace.ts +2 -2
- package/src/commands/upgrade.test.ts +46 -0
- package/src/commands/upgrade.ts +259 -0
- package/src/config.test.ts +22 -0
- package/src/config.ts +12 -0
- package/src/doctor/agents.test.ts +1 -0
- package/src/doctor/config-check.test.ts +1 -0
- package/src/doctor/consistency.test.ts +1 -0
- package/src/doctor/databases.test.ts +39 -0
- package/src/doctor/databases.ts +7 -10
- package/src/doctor/dependencies.test.ts +1 -0
- package/src/doctor/ecosystem.test.ts +308 -0
- package/src/doctor/ecosystem.ts +155 -0
- package/src/doctor/logs.test.ts +1 -0
- package/src/doctor/merge-queue.test.ts +99 -0
- package/src/doctor/merge-queue.ts +23 -0
- package/src/doctor/structure.test.ts +131 -1
- package/src/doctor/structure.ts +87 -1
- package/src/doctor/types.ts +5 -2
- package/src/doctor/version.test.ts +1 -0
- package/src/index.ts +29 -4
- package/src/types.ts +11 -0
- package/templates/overlay.md.tmpl +3 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the ecosystem doctor check module.
|
|
3
|
+
*
|
|
4
|
+
* We inject a mock spawner instead of using mock.module() to avoid cross-test
|
|
5
|
+
* leakage (see mulch record mx-56558b on why mock.module() is avoided).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type { OverstoryConfig } from "../types.ts";
|
|
10
|
+
import { makeCheckEcosystem, parseSemver } from "./ecosystem.ts";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Minimal config fixture
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const mockConfig: OverstoryConfig = {
|
|
17
|
+
project: {
|
|
18
|
+
name: "test-project",
|
|
19
|
+
root: "/tmp/test",
|
|
20
|
+
canonicalBranch: "main",
|
|
21
|
+
},
|
|
22
|
+
agents: {
|
|
23
|
+
manifestPath: "/tmp/.overstory/agent-manifest.json",
|
|
24
|
+
baseDir: "/tmp/.overstory/agents",
|
|
25
|
+
maxConcurrent: 5,
|
|
26
|
+
staggerDelayMs: 1000,
|
|
27
|
+
maxDepth: 2,
|
|
28
|
+
maxSessionsPerRun: 0,
|
|
29
|
+
maxAgentsPerLead: 5,
|
|
30
|
+
},
|
|
31
|
+
worktrees: {
|
|
32
|
+
baseDir: "/tmp/.overstory/worktrees",
|
|
33
|
+
},
|
|
34
|
+
taskTracker: {
|
|
35
|
+
backend: "auto",
|
|
36
|
+
enabled: false,
|
|
37
|
+
},
|
|
38
|
+
mulch: {
|
|
39
|
+
enabled: false,
|
|
40
|
+
domains: [],
|
|
41
|
+
primeFormat: "markdown",
|
|
42
|
+
},
|
|
43
|
+
merge: {
|
|
44
|
+
aiResolveEnabled: false,
|
|
45
|
+
reimagineEnabled: false,
|
|
46
|
+
},
|
|
47
|
+
providers: {
|
|
48
|
+
anthropic: { type: "native" },
|
|
49
|
+
},
|
|
50
|
+
watchdog: {
|
|
51
|
+
tier0Enabled: false,
|
|
52
|
+
tier0IntervalMs: 30000,
|
|
53
|
+
tier1Enabled: false,
|
|
54
|
+
tier2Enabled: false,
|
|
55
|
+
staleThresholdMs: 300000,
|
|
56
|
+
zombieThresholdMs: 600000,
|
|
57
|
+
nudgeIntervalMs: 60000,
|
|
58
|
+
},
|
|
59
|
+
models: {},
|
|
60
|
+
logging: {
|
|
61
|
+
verbose: false,
|
|
62
|
+
redactSecrets: true,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Mock spawner helpers
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
type SpawnResponse = { exitCode: number; stdout: string; stderr: string };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build a mock spawner that dispatches by binary name (first arg).
|
|
74
|
+
*/
|
|
75
|
+
function makeMockSpawner(responses: Record<string, SpawnResponse>) {
|
|
76
|
+
return async (args: string[]): Promise<SpawnResponse> => {
|
|
77
|
+
const bin = args[0] ?? "";
|
|
78
|
+
return (
|
|
79
|
+
responses[bin] ?? {
|
|
80
|
+
exitCode: 127,
|
|
81
|
+
stdout: "",
|
|
82
|
+
stderr: `${bin}: command not found`,
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// parseSemver unit tests
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
describe("parseSemver", () => {
|
|
93
|
+
test("extracts bare semver", () => {
|
|
94
|
+
expect(parseSemver("1.2.3")).toBe("1.2.3");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("extracts semver from prefixed output", () => {
|
|
98
|
+
expect(parseSemver("mulch v1.0.0")).toBe("1.0.0");
|
|
99
|
+
expect(parseSemver("seeds version 2.3.4")).toBe("2.3.4");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("extracts semver with prerelease", () => {
|
|
103
|
+
expect(parseSemver("1.2.3-alpha.1")).toBe("1.2.3-alpha.1");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("extracts semver with build metadata", () => {
|
|
107
|
+
expect(parseSemver("1.2.3+build.42")).toBe("1.2.3+build.42");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("returns null for non-semver strings", () => {
|
|
111
|
+
expect(parseSemver("not-a-version")).toBeNull();
|
|
112
|
+
expect(parseSemver("")).toBeNull();
|
|
113
|
+
expect(parseSemver("v2.0")).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("extracts first semver when multiple exist", () => {
|
|
117
|
+
expect(parseSemver("tool 1.0.0 requires node 18.0.0")).toBe("1.0.0");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// checkEcosystem integration tests
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
describe("checkEcosystem", () => {
|
|
126
|
+
test("returns exactly 3 checks (one per tool)", async () => {
|
|
127
|
+
const spawner = makeMockSpawner({
|
|
128
|
+
ml: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
129
|
+
sd: { exitCode: 0, stdout: "2.0.0\n", stderr: "" },
|
|
130
|
+
cn: { exitCode: 0, stdout: "3.0.0\n", stderr: "" },
|
|
131
|
+
});
|
|
132
|
+
const check = makeCheckEcosystem(spawner);
|
|
133
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
134
|
+
|
|
135
|
+
expect(results).toHaveLength(3);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("check names match tool names", async () => {
|
|
139
|
+
const spawner = makeMockSpawner({
|
|
140
|
+
ml: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
141
|
+
sd: { exitCode: 0, stdout: "2.0.0\n", stderr: "" },
|
|
142
|
+
cn: { exitCode: 0, stdout: "3.0.0\n", stderr: "" },
|
|
143
|
+
});
|
|
144
|
+
const check = makeCheckEcosystem(spawner);
|
|
145
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
146
|
+
|
|
147
|
+
const names = results.map((r) => r.name);
|
|
148
|
+
expect(names).toContain("mulch semver");
|
|
149
|
+
expect(names).toContain("seeds semver");
|
|
150
|
+
expect(names).toContain("canopy semver");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("all checks report category 'ecosystem'", async () => {
|
|
154
|
+
const spawner = makeMockSpawner({
|
|
155
|
+
ml: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
156
|
+
sd: { exitCode: 0, stdout: "2.0.0\n", stderr: "" },
|
|
157
|
+
cn: { exitCode: 0, stdout: "3.0.0\n", stderr: "" },
|
|
158
|
+
});
|
|
159
|
+
const check = makeCheckEcosystem(spawner);
|
|
160
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
161
|
+
|
|
162
|
+
for (const r of results) {
|
|
163
|
+
expect(r.category).toBe("ecosystem");
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("pass when all tools report valid semver", async () => {
|
|
168
|
+
const spawner = makeMockSpawner({
|
|
169
|
+
ml: { exitCode: 0, stdout: "mulch v1.2.3\n", stderr: "" },
|
|
170
|
+
sd: { exitCode: 0, stdout: "seeds 0.5.0\n", stderr: "" },
|
|
171
|
+
cn: { exitCode: 0, stdout: "0.1.0\n", stderr: "" },
|
|
172
|
+
});
|
|
173
|
+
const check = makeCheckEcosystem(spawner);
|
|
174
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
175
|
+
|
|
176
|
+
for (const r of results) {
|
|
177
|
+
expect(r.status).toBe("pass");
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("warn when a tool is not available (non-zero exit code)", async () => {
|
|
182
|
+
const spawner = makeMockSpawner({
|
|
183
|
+
ml: { exitCode: 127, stdout: "", stderr: "ml: command not found" },
|
|
184
|
+
sd: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
185
|
+
cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
186
|
+
});
|
|
187
|
+
const check = makeCheckEcosystem(spawner);
|
|
188
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
189
|
+
|
|
190
|
+
const mulch = results.find((r) => r.name === "mulch semver");
|
|
191
|
+
expect(mulch?.status).toBe("warn");
|
|
192
|
+
expect(mulch?.fixable).toBe(true);
|
|
193
|
+
expect(typeof mulch?.fix).toBe("function");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("warn when version output is not valid semver", async () => {
|
|
197
|
+
const spawner = makeMockSpawner({
|
|
198
|
+
ml: { exitCode: 0, stdout: "mulch dev-build\n", stderr: "" },
|
|
199
|
+
sd: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
200
|
+
cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
201
|
+
});
|
|
202
|
+
const check = makeCheckEcosystem(spawner);
|
|
203
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
204
|
+
|
|
205
|
+
const mulch = results.find((r) => r.name === "mulch semver");
|
|
206
|
+
expect(mulch?.status).toBe("warn");
|
|
207
|
+
expect(mulch?.message).toContain("not parseable semver");
|
|
208
|
+
expect(mulch?.fixable).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("passing checks include version in message", async () => {
|
|
212
|
+
const spawner = makeMockSpawner({
|
|
213
|
+
ml: { exitCode: 0, stdout: "1.2.3\n", stderr: "" },
|
|
214
|
+
sd: { exitCode: 0, stdout: "1.2.3\n", stderr: "" },
|
|
215
|
+
cn: { exitCode: 0, stdout: "1.2.3\n", stderr: "" },
|
|
216
|
+
});
|
|
217
|
+
const check = makeCheckEcosystem(spawner);
|
|
218
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
219
|
+
|
|
220
|
+
for (const r of results) {
|
|
221
|
+
expect(r.status).toBe("pass");
|
|
222
|
+
expect(r.message).toContain("1.2.3");
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("passing checks include raw output in details", async () => {
|
|
227
|
+
const spawner = makeMockSpawner({
|
|
228
|
+
ml: { exitCode: 0, stdout: "mulch v1.0.0\n", stderr: "" },
|
|
229
|
+
sd: { exitCode: 0, stdout: "seeds 1.0.0\n", stderr: "" },
|
|
230
|
+
cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
231
|
+
});
|
|
232
|
+
const check = makeCheckEcosystem(spawner);
|
|
233
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
234
|
+
|
|
235
|
+
const mulch = results.find((r) => r.name === "mulch semver");
|
|
236
|
+
expect(mulch?.details).toContain("mulch v1.0.0");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("unavailable tool details include install hint", async () => {
|
|
240
|
+
const spawner = makeMockSpawner({
|
|
241
|
+
ml: { exitCode: 127, stdout: "", stderr: "not found" },
|
|
242
|
+
sd: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
243
|
+
cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
244
|
+
});
|
|
245
|
+
const check = makeCheckEcosystem(spawner);
|
|
246
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
247
|
+
|
|
248
|
+
const mulch = results.find((r) => r.name === "mulch semver");
|
|
249
|
+
const hasHint = mulch?.details?.some((d) => d.includes("@os-eco/mulch-cli"));
|
|
250
|
+
expect(hasHint).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("all checks have required DoctorCheck fields", async () => {
|
|
254
|
+
const spawner = makeMockSpawner({
|
|
255
|
+
ml: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
256
|
+
sd: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
257
|
+
cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
258
|
+
});
|
|
259
|
+
const check = makeCheckEcosystem(spawner);
|
|
260
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
261
|
+
|
|
262
|
+
for (const r of results) {
|
|
263
|
+
expect(typeof r.name).toBe("string");
|
|
264
|
+
expect(r.name.length).toBeGreaterThan(0);
|
|
265
|
+
expect(r.category).toBe("ecosystem");
|
|
266
|
+
expect(["pass", "warn", "fail"]).toContain(r.status);
|
|
267
|
+
expect(typeof r.message).toBe("string");
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("failing checks are marked fixable and have a fix closure", async () => {
|
|
272
|
+
const spawner = makeMockSpawner({}); // all tools unavailable
|
|
273
|
+
const check = makeCheckEcosystem(spawner);
|
|
274
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
275
|
+
|
|
276
|
+
for (const r of results) {
|
|
277
|
+
expect(r.status).toBe("warn");
|
|
278
|
+
expect(r.fixable).toBe(true);
|
|
279
|
+
expect(typeof r.fix).toBe("function");
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("handles version in stderr when stdout is empty", async () => {
|
|
284
|
+
const spawner = makeMockSpawner({
|
|
285
|
+
ml: { exitCode: 0, stdout: "", stderr: "1.0.0" },
|
|
286
|
+
sd: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
287
|
+
cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
288
|
+
});
|
|
289
|
+
const check = makeCheckEcosystem(spawner);
|
|
290
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
291
|
+
|
|
292
|
+
const mulch = results.find((r) => r.name === "mulch semver");
|
|
293
|
+
expect(mulch?.status).toBe("pass");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("handles spawn exception gracefully", async () => {
|
|
297
|
+
const errorSpawner = async (_args: string[]) => {
|
|
298
|
+
throw new Error("spawn failed");
|
|
299
|
+
};
|
|
300
|
+
const check = makeCheckEcosystem(errorSpawner);
|
|
301
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
302
|
+
|
|
303
|
+
expect(results).toHaveLength(3);
|
|
304
|
+
for (const r of results) {
|
|
305
|
+
expect(r.status).toBe("warn");
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { DoctorCheck, DoctorCheckFn } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ecosystem health checks.
|
|
5
|
+
*
|
|
6
|
+
* Validates that os-eco CLI tools (ml, sd, cn) are on PATH and report valid
|
|
7
|
+
* semver versions. Intentionally does NOT duplicate the availability checks in
|
|
8
|
+
* dependencies.ts — those confirm the binaries exist. These checks focus on
|
|
9
|
+
* whether the reported version string is parseable semver, and whether the
|
|
10
|
+
* tools are mutually compatible.
|
|
11
|
+
*
|
|
12
|
+
* Fix closures reinstall the relevant package via `bun install -g <pkg>`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A single os-eco ecosystem tool. */
|
|
16
|
+
interface EcosystemTool {
|
|
17
|
+
/** Human-readable tool name. */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Primary binary to invoke for version check. */
|
|
20
|
+
bin: string;
|
|
21
|
+
/** npm package name for install / reinstall. */
|
|
22
|
+
pkg: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ECOSYSTEM_TOOLS: EcosystemTool[] = [
|
|
26
|
+
{ name: "mulch", bin: "ml", pkg: "@os-eco/mulch-cli" },
|
|
27
|
+
{ name: "seeds", bin: "sd", pkg: "@os-eco/seeds-cli" },
|
|
28
|
+
{ name: "canopy", bin: "cn", pkg: "@os-eco/canopy-cli" },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** Spawner abstraction — injected in tests, uses Bun.spawn in production. */
|
|
32
|
+
export type Spawner = (
|
|
33
|
+
args: string[],
|
|
34
|
+
) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
|
|
35
|
+
|
|
36
|
+
async function defaultSpawner(
|
|
37
|
+
args: string[],
|
|
38
|
+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
39
|
+
const proc = Bun.spawn(args, {
|
|
40
|
+
stdout: "pipe",
|
|
41
|
+
stderr: "pipe",
|
|
42
|
+
});
|
|
43
|
+
const exitCode = await proc.exited;
|
|
44
|
+
const stdout = await new Response(proc.stdout).text();
|
|
45
|
+
const stderr = await new Response(proc.stderr).text();
|
|
46
|
+
return { exitCode, stdout, stderr };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Loose semver extractor.
|
|
51
|
+
* Finds the first x.y.z (optionally x.y.z-pre or x.y.z+build) token in a string.
|
|
52
|
+
* Returns null when no valid semver token is found.
|
|
53
|
+
*/
|
|
54
|
+
export function parseSemver(output: string): string | null {
|
|
55
|
+
const match = /(\d+\.\d+\.\d+(?:[-.+][a-zA-Z0-9._-]*)?)/.exec(output);
|
|
56
|
+
return match?.[1] ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Internal result of probing a binary's version output. */
|
|
60
|
+
interface VersionProbeResult {
|
|
61
|
+
available: boolean;
|
|
62
|
+
version: string | null;
|
|
63
|
+
raw: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function probeVersion(bin: string, spawner: Spawner): Promise<VersionProbeResult> {
|
|
67
|
+
try {
|
|
68
|
+
const { exitCode, stdout, stderr } = await spawner([bin, "--version"]);
|
|
69
|
+
const raw = (stdout + stderr).trim();
|
|
70
|
+
if (exitCode !== 0) {
|
|
71
|
+
return { available: false, version: null, raw };
|
|
72
|
+
}
|
|
73
|
+
const version = parseSemver(raw);
|
|
74
|
+
return { available: true, version, raw };
|
|
75
|
+
} catch {
|
|
76
|
+
return { available: false, version: null, raw: "" };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build a DoctorCheck for a single ecosystem tool. */
|
|
81
|
+
function buildCheck(tool: EcosystemTool, probe: VersionProbeResult): DoctorCheck {
|
|
82
|
+
const { bin, pkg, name } = tool;
|
|
83
|
+
|
|
84
|
+
if (!probe.available) {
|
|
85
|
+
return {
|
|
86
|
+
name: `${name} semver`,
|
|
87
|
+
category: "ecosystem",
|
|
88
|
+
status: "warn",
|
|
89
|
+
message: `${bin} is not available — cannot verify version`,
|
|
90
|
+
details: [`Install: bun install -g ${pkg}`],
|
|
91
|
+
fixable: true,
|
|
92
|
+
fix: async () => {
|
|
93
|
+
const proc = Bun.spawn(["bun", "install", "-g", pkg], {
|
|
94
|
+
stdout: "inherit",
|
|
95
|
+
stderr: "inherit",
|
|
96
|
+
});
|
|
97
|
+
await proc.exited;
|
|
98
|
+
return [`Installed ${pkg}`];
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (probe.version === null) {
|
|
104
|
+
return {
|
|
105
|
+
name: `${name} semver`,
|
|
106
|
+
category: "ecosystem",
|
|
107
|
+
status: "warn",
|
|
108
|
+
message: `${bin} --version output is not parseable semver`,
|
|
109
|
+
details: [
|
|
110
|
+
`Raw output: ${probe.raw || "(empty)"}`,
|
|
111
|
+
"Expected format: x.y.z",
|
|
112
|
+
`Reinstall: bun install -g ${pkg}`,
|
|
113
|
+
],
|
|
114
|
+
fixable: true,
|
|
115
|
+
fix: async () => {
|
|
116
|
+
const proc = Bun.spawn(["bun", "install", "-g", pkg], {
|
|
117
|
+
stdout: "inherit",
|
|
118
|
+
stderr: "inherit",
|
|
119
|
+
});
|
|
120
|
+
await proc.exited;
|
|
121
|
+
return [`Reinstalled ${pkg}`];
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
name: `${name} semver`,
|
|
128
|
+
category: "ecosystem",
|
|
129
|
+
status: "pass",
|
|
130
|
+
message: `${name} v${probe.version} (valid semver)`,
|
|
131
|
+
details: [probe.raw],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Factory that creates a DoctorCheckFn with an injectable spawner.
|
|
137
|
+
* Used for testing without module-level mocks.
|
|
138
|
+
*/
|
|
139
|
+
export function makeCheckEcosystem(spawner: Spawner = defaultSpawner): DoctorCheckFn {
|
|
140
|
+
return async (_config, _overstoryDir): Promise<DoctorCheck[]> => {
|
|
141
|
+
const checks: DoctorCheck[] = [];
|
|
142
|
+
|
|
143
|
+
for (const tool of ECOSYSTEM_TOOLS) {
|
|
144
|
+
const probe = await probeVersion(tool.bin, spawner);
|
|
145
|
+
checks.push(buildCheck(tool, probe));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return checks;
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Ecosystem health check — validates semver version output for ml, sd, cn.
|
|
154
|
+
*/
|
|
155
|
+
export const checkEcosystem: DoctorCheckFn = makeCheckEcosystem();
|
package/src/doctor/logs.test.ts
CHANGED
|
@@ -23,6 +23,7 @@ describe("checkMergeQueue", () => {
|
|
|
23
23
|
staggerDelayMs: 100,
|
|
24
24
|
maxDepth: 2,
|
|
25
25
|
maxSessionsPerRun: 0,
|
|
26
|
+
maxAgentsPerLead: 5,
|
|
26
27
|
},
|
|
27
28
|
worktrees: { baseDir: "" },
|
|
28
29
|
taskTracker: { backend: "auto", enabled: true },
|
|
@@ -213,4 +214,102 @@ describe("checkMergeQueue", () => {
|
|
|
213
214
|
expect(duplicateCheck?.message).toContain("duplicate branch entries");
|
|
214
215
|
expect(duplicateCheck?.details?.[0]).toContain("feature/duplicate");
|
|
215
216
|
});
|
|
217
|
+
|
|
218
|
+
test("fix() deletes stale pending entries", () => {
|
|
219
|
+
const dbPath = join(tempDir, "merge-queue.db");
|
|
220
|
+
const queue = createMergeQueue(dbPath);
|
|
221
|
+
queue.close();
|
|
222
|
+
|
|
223
|
+
const staleDate = new Date();
|
|
224
|
+
staleDate.setDate(staleDate.getDate() - 2); // 2 days ago
|
|
225
|
+
|
|
226
|
+
const db = new Database(dbPath);
|
|
227
|
+
db.prepare(
|
|
228
|
+
"INSERT INTO merge_queue (branch_name, task_id, agent_name, files_modified, status, enqueued_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
229
|
+
).run(
|
|
230
|
+
"feature/stale-1",
|
|
231
|
+
"beads-abc",
|
|
232
|
+
"test-agent",
|
|
233
|
+
JSON.stringify(["src/test.ts"]),
|
|
234
|
+
"pending",
|
|
235
|
+
staleDate.toISOString(),
|
|
236
|
+
);
|
|
237
|
+
db.prepare(
|
|
238
|
+
"INSERT INTO merge_queue (branch_name, task_id, agent_name, files_modified, status, enqueued_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
239
|
+
).run(
|
|
240
|
+
"feature/stale-2",
|
|
241
|
+
"beads-def",
|
|
242
|
+
"test-agent",
|
|
243
|
+
JSON.stringify(["src/other.ts"]),
|
|
244
|
+
"merging",
|
|
245
|
+
staleDate.toISOString(),
|
|
246
|
+
);
|
|
247
|
+
db.close();
|
|
248
|
+
|
|
249
|
+
const checks = checkMergeQueue(mockConfig, tempDir) as DoctorCheck[];
|
|
250
|
+
|
|
251
|
+
const staleCheck = checks.find((c) => c?.name === "merge-queue.db staleness");
|
|
252
|
+
expect(staleCheck?.fix).toBeDefined();
|
|
253
|
+
|
|
254
|
+
const actions = staleCheck?.fix?.();
|
|
255
|
+
expect(Array.isArray(actions)).toBe(true);
|
|
256
|
+
const actionsArr = actions as string[];
|
|
257
|
+
expect(actionsArr.some((a) => a.includes("Deleted") && a.includes("stale"))).toBe(true);
|
|
258
|
+
|
|
259
|
+
// Verify entries were deleted
|
|
260
|
+
const verifyDb = new Database(dbPath);
|
|
261
|
+
const remaining = verifyDb
|
|
262
|
+
.prepare("SELECT COUNT(*) as count FROM merge_queue WHERE status IN ('pending', 'merging')")
|
|
263
|
+
.get() as { count: number };
|
|
264
|
+
verifyDb.close();
|
|
265
|
+
expect(remaining.count).toBe(0);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("fix() removes duplicate entries keeping newest", () => {
|
|
269
|
+
const dbPath = join(tempDir, "merge-queue.db");
|
|
270
|
+
const queue = createMergeQueue(dbPath);
|
|
271
|
+
queue.enqueue({
|
|
272
|
+
branchName: "feature/dup",
|
|
273
|
+
taskId: "beads-abc",
|
|
274
|
+
agentName: "agent-1",
|
|
275
|
+
filesModified: ["src/a.ts"],
|
|
276
|
+
});
|
|
277
|
+
queue.enqueue({
|
|
278
|
+
branchName: "feature/dup",
|
|
279
|
+
taskId: "beads-def",
|
|
280
|
+
agentName: "agent-2",
|
|
281
|
+
filesModified: ["src/b.ts"],
|
|
282
|
+
});
|
|
283
|
+
queue.enqueue({
|
|
284
|
+
branchName: "feature/other",
|
|
285
|
+
taskId: "beads-ghi",
|
|
286
|
+
agentName: "agent-3",
|
|
287
|
+
filesModified: ["src/c.ts"],
|
|
288
|
+
});
|
|
289
|
+
queue.close();
|
|
290
|
+
|
|
291
|
+
const checks = checkMergeQueue(mockConfig, tempDir) as DoctorCheck[];
|
|
292
|
+
|
|
293
|
+
const dupCheck = checks.find((c) => c?.name === "merge-queue.db duplicates");
|
|
294
|
+
expect(dupCheck?.fix).toBeDefined();
|
|
295
|
+
|
|
296
|
+
const actions = dupCheck?.fix?.();
|
|
297
|
+
expect(Array.isArray(actions)).toBe(true);
|
|
298
|
+
const actionsArr = actions as string[];
|
|
299
|
+
expect(actionsArr.some((a) => a.includes("Removed") && a.includes("duplicate"))).toBe(true);
|
|
300
|
+
|
|
301
|
+
// Verify only 2 entries remain (1 per branch)
|
|
302
|
+
const verifyDb = new Database(dbPath);
|
|
303
|
+
const remaining = verifyDb.prepare("SELECT COUNT(*) as count FROM merge_queue").get() as {
|
|
304
|
+
count: number;
|
|
305
|
+
};
|
|
306
|
+
// Check the newest entry for feature/dup is kept (highest id)
|
|
307
|
+
const dupEntries = verifyDb
|
|
308
|
+
.prepare("SELECT agent_name FROM merge_queue WHERE branch_name = 'feature/dup'")
|
|
309
|
+
.all() as Array<{ agent_name: string }>;
|
|
310
|
+
verifyDb.close();
|
|
311
|
+
expect(remaining.count).toBe(2);
|
|
312
|
+
expect(dupEntries).toHaveLength(1);
|
|
313
|
+
expect(dupEntries[0]?.agent_name).toBe("agent-2"); // newest entry kept
|
|
314
|
+
});
|
|
216
315
|
});
|
|
@@ -114,6 +114,18 @@ export const checkMergeQueue: DoctorCheckFn = (_config, overstoryDir): DoctorChe
|
|
|
114
114
|
message: `Found ${staleEntries.length} potentially stale queue entries`,
|
|
115
115
|
details: staleEntries,
|
|
116
116
|
fixable: true,
|
|
117
|
+
fix: () => {
|
|
118
|
+
const fixDb = new Database(dbPath);
|
|
119
|
+
fixDb.exec("PRAGMA busy_timeout=5000");
|
|
120
|
+
const staleThreshold = new Date(now.getTime() - staleThresholdMs).toISOString();
|
|
121
|
+
const result = fixDb
|
|
122
|
+
.prepare(
|
|
123
|
+
"DELETE FROM merge_queue WHERE status IN ('pending', 'merging') AND enqueued_at < ?",
|
|
124
|
+
)
|
|
125
|
+
.run(staleThreshold);
|
|
126
|
+
fixDb.close();
|
|
127
|
+
return [`Deleted ${result.changes} stale merge queue entries`];
|
|
128
|
+
},
|
|
117
129
|
});
|
|
118
130
|
}
|
|
119
131
|
|
|
@@ -134,6 +146,17 @@ export const checkMergeQueue: DoctorCheckFn = (_config, overstoryDir): DoctorChe
|
|
|
134
146
|
message: "Found duplicate branch entries in queue",
|
|
135
147
|
details: duplicates,
|
|
136
148
|
fixable: true,
|
|
149
|
+
fix: () => {
|
|
150
|
+
const fixDb = new Database(dbPath);
|
|
151
|
+
fixDb.exec("PRAGMA busy_timeout=5000");
|
|
152
|
+
const result = fixDb
|
|
153
|
+
.prepare(
|
|
154
|
+
"DELETE FROM merge_queue WHERE id NOT IN (SELECT MAX(id) FROM merge_queue GROUP BY branch_name)",
|
|
155
|
+
)
|
|
156
|
+
.run();
|
|
157
|
+
fixDb.close();
|
|
158
|
+
return [`Removed ${result.changes} duplicate merge queue entries`];
|
|
159
|
+
},
|
|
137
160
|
});
|
|
138
161
|
}
|
|
139
162
|
} finally {
|