opencode-swarm-plugin 0.59.5 → 0.60.0
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 +14 -1
- package/bin/commands/doctor.test.ts +622 -0
- package/bin/commands/doctor.ts +658 -0
- package/bin/commands/status.test.ts +506 -0
- package/bin/commands/status.ts +520 -0
- package/bin/commands/tree.ts +39 -3
- package/bin/swarm.ts +19 -3
- package/claude-plugin/dist/index.js +290 -47
- package/claude-plugin/dist/schemas/cell.d.ts +2 -0
- package/claude-plugin/dist/schemas/cell.d.ts.map +1 -1
- package/claude-plugin/dist/utils/git-commit-info.d.ts +10 -0
- package/claude-plugin/dist/utils/git-commit-info.d.ts.map +1 -0
- package/claude-plugin/dist/utils/tree-renderer.d.ts +69 -13
- package/claude-plugin/dist/utils/tree-renderer.d.ts.map +1 -1
- package/dist/bin/swarm.js +1883 -386
- package/dist/dashboard.d.ts.map +1 -1
- package/dist/hive.d.ts +8 -0
- package/dist/hive.d.ts.map +1 -1
- package/dist/hive.js +6 -4
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +62 -16
- package/dist/marketplace/index.js +290 -47
- package/dist/plugin.js +61 -15
- package/dist/replay-tools.d.ts +5 -1
- package/dist/replay-tools.d.ts.map +1 -1
- package/dist/schemas/cell.d.ts +2 -0
- package/dist/schemas/cell.d.ts.map +1 -1
- package/dist/skills.d.ts +4 -0
- package/dist/skills.d.ts.map +1 -1
- package/dist/storage.d.ts +7 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/swarm-orchestrate.d.ts +12 -0
- package/dist/swarm-orchestrate.d.ts.map +1 -1
- package/dist/swarm-prompts.js +61 -15
- package/dist/swarm.d.ts +6 -0
- package/dist/swarm.d.ts.map +1 -1
- package/dist/test-utils/msw-server.d.ts +21 -0
- package/dist/test-utils/msw-server.d.ts.map +1 -0
- package/dist/utils/git-commit-info.d.ts +10 -0
- package/dist/utils/git-commit-info.d.ts.map +1 -0
- package/dist/utils/tree-renderer.d.ts +69 -13
- package/dist/utils/tree-renderer.d.ts.map +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -16,15 +16,28 @@
|
|
|
16
16
|
╚══════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
## Prerequisites
|
|
20
|
+
|
|
21
|
+
**Bun is required.** The CLI uses Bun-specific APIs and won't run with Node.js alone.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Install Bun (if you don't have it)
|
|
25
|
+
curl -fsSL https://bun.sh/install | bash
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
See [bun.sh](https://bun.sh) for other installation methods (Homebrew, npm, etc.).
|
|
29
|
+
|
|
19
30
|
## Quickstart (<2 minutes)
|
|
20
31
|
|
|
21
32
|
### 1. Install
|
|
22
33
|
|
|
23
34
|
```bash
|
|
24
|
-
|
|
35
|
+
bun install -g opencode-swarm-plugin@latest
|
|
25
36
|
swarm setup
|
|
26
37
|
```
|
|
27
38
|
|
|
39
|
+
> **Note:** You can also use `npm install -g`, but Bun must be installed to run the CLI.
|
|
40
|
+
|
|
28
41
|
### Claude Code Plugin (Marketplace)
|
|
29
42
|
|
|
30
43
|
```text
|
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Tests for enhanced doctor command
|
|
3
|
+
*
|
|
4
|
+
* Tests each health check individually and the combined doctor runner.
|
|
5
|
+
* Uses in-memory libSQL database for isolation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test, beforeAll, beforeEach, afterAll } from "bun:test";
|
|
9
|
+
import { createInMemorySwarmMailLibSQL, createHiveAdapter } from "swarm-mail";
|
|
10
|
+
import type { SwarmMailAdapter } from "swarm-mail";
|
|
11
|
+
import type { DatabaseAdapter } from "swarm-mail";
|
|
12
|
+
import {
|
|
13
|
+
checkDbIntegrity,
|
|
14
|
+
checkOrphanedCells,
|
|
15
|
+
checkDependencyCycles,
|
|
16
|
+
checkStaleReservations,
|
|
17
|
+
checkZombieBlocked,
|
|
18
|
+
checkGhostWorkers,
|
|
19
|
+
detectCycles,
|
|
20
|
+
runDoctor,
|
|
21
|
+
formatDoctorReport,
|
|
22
|
+
parseDoctorArgs,
|
|
23
|
+
} from "./doctor.js";
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Test Helpers
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
async function createTestDb(testId: string): Promise<{
|
|
30
|
+
swarmMail: SwarmMailAdapter;
|
|
31
|
+
db: DatabaseAdapter;
|
|
32
|
+
projectPath: string;
|
|
33
|
+
}> {
|
|
34
|
+
const swarmMail = await createInMemorySwarmMailLibSQL(testId);
|
|
35
|
+
const db = await swarmMail.getDatabase();
|
|
36
|
+
const projectPath = `/tmp/test-doctor-${testId}`;
|
|
37
|
+
|
|
38
|
+
// Initialize hive schema
|
|
39
|
+
const adapter = createHiveAdapter(db, projectPath);
|
|
40
|
+
await adapter.runMigrations();
|
|
41
|
+
|
|
42
|
+
// Also run swarm-mail migrations (for reservations table)
|
|
43
|
+
await swarmMail.runMigrations();
|
|
44
|
+
|
|
45
|
+
return { swarmMail, db, projectPath };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// checkDbIntegrity
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
describe("checkDbIntegrity", () => {
|
|
53
|
+
let swarmMail: SwarmMailAdapter;
|
|
54
|
+
let db: DatabaseAdapter;
|
|
55
|
+
|
|
56
|
+
beforeAll(async () => {
|
|
57
|
+
const setup = await createTestDb("integrity");
|
|
58
|
+
swarmMail = setup.swarmMail;
|
|
59
|
+
db = setup.db;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterAll(async () => {
|
|
63
|
+
await swarmMail.close();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("passes on healthy database", async () => {
|
|
67
|
+
const result = await checkDbIntegrity(db);
|
|
68
|
+
expect(result.status).toBe("pass");
|
|
69
|
+
expect(result.message).toBe("OK");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// checkOrphanedCells
|
|
75
|
+
// ============================================================================
|
|
76
|
+
|
|
77
|
+
describe("checkOrphanedCells", () => {
|
|
78
|
+
let swarmMail: SwarmMailAdapter;
|
|
79
|
+
let db: DatabaseAdapter;
|
|
80
|
+
let projectPath: string;
|
|
81
|
+
|
|
82
|
+
beforeAll(async () => {
|
|
83
|
+
const setup = await createTestDb("orphans");
|
|
84
|
+
swarmMail = setup.swarmMail;
|
|
85
|
+
db = setup.db;
|
|
86
|
+
projectPath = setup.projectPath;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterAll(async () => {
|
|
90
|
+
await swarmMail.close();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("passes with no orphans", async () => {
|
|
94
|
+
const adapter = createHiveAdapter(db, projectPath);
|
|
95
|
+
|
|
96
|
+
// Create epic and child — valid reference
|
|
97
|
+
const epic = await adapter.createCell(projectPath, {
|
|
98
|
+
title: "Valid Epic",
|
|
99
|
+
type: "epic",
|
|
100
|
+
priority: 0,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await adapter.createCell(projectPath, {
|
|
104
|
+
title: "Valid Child",
|
|
105
|
+
type: "task",
|
|
106
|
+
priority: 1,
|
|
107
|
+
parent_id: epic.id,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const result = await checkOrphanedCells(db);
|
|
111
|
+
expect(result.status).toBe("pass");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("detects orphaned cells", async () => {
|
|
115
|
+
// Disable foreign key checks to insert orphan
|
|
116
|
+
await db.exec("PRAGMA foreign_keys = OFF");
|
|
117
|
+
await db.query(
|
|
118
|
+
`INSERT INTO beads (id, project_key, type, status, title, priority, parent_id, created_at, updated_at)
|
|
119
|
+
VALUES (?, ?, 'task', 'open', 'Orphan Cell', 1, 'nonexistent-parent', ?, ?)`,
|
|
120
|
+
["orphan-test-1", projectPath, Date.now(), Date.now()]
|
|
121
|
+
);
|
|
122
|
+
await db.exec("PRAGMA foreign_keys = ON");
|
|
123
|
+
|
|
124
|
+
const result = await checkOrphanedCells(db);
|
|
125
|
+
expect(result.status).toBe("fail");
|
|
126
|
+
expect(result.message).toContain("orphaned");
|
|
127
|
+
expect(result.fixable).toBe(true);
|
|
128
|
+
expect(result.details).toBeDefined();
|
|
129
|
+
expect(result.details!.length).toBeGreaterThan(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("fixes orphaned cells with --fix", async () => {
|
|
133
|
+
const result = await checkOrphanedCells(db, { fix: true });
|
|
134
|
+
expect(result.fixed).toBeGreaterThan(0);
|
|
135
|
+
|
|
136
|
+
// Verify fix — no more orphans
|
|
137
|
+
const verify = await checkOrphanedCells(db);
|
|
138
|
+
expect(verify.status).toBe("pass");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ============================================================================
|
|
143
|
+
// checkDependencyCycles
|
|
144
|
+
// ============================================================================
|
|
145
|
+
|
|
146
|
+
describe("checkDependencyCycles", () => {
|
|
147
|
+
let swarmMail: SwarmMailAdapter;
|
|
148
|
+
let db: DatabaseAdapter;
|
|
149
|
+
let projectPath: string;
|
|
150
|
+
|
|
151
|
+
beforeAll(async () => {
|
|
152
|
+
const setup = await createTestDb("cycles");
|
|
153
|
+
swarmMail = setup.swarmMail;
|
|
154
|
+
db = setup.db;
|
|
155
|
+
projectPath = setup.projectPath;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
afterAll(async () => {
|
|
159
|
+
await swarmMail.close();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("passes with no dependencies", async () => {
|
|
163
|
+
const result = await checkDependencyCycles(db);
|
|
164
|
+
expect(result.status).toBe("pass");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("passes with acyclic dependencies", async () => {
|
|
168
|
+
const adapter = createHiveAdapter(db, projectPath);
|
|
169
|
+
|
|
170
|
+
const cellA = await adapter.createCell(projectPath, {
|
|
171
|
+
title: "Cell A", type: "task", priority: 1,
|
|
172
|
+
});
|
|
173
|
+
const cellB = await adapter.createCell(projectPath, {
|
|
174
|
+
title: "Cell B", type: "task", priority: 2,
|
|
175
|
+
});
|
|
176
|
+
const cellC = await adapter.createCell(projectPath, {
|
|
177
|
+
title: "Cell C", type: "task", priority: 3,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// A → B → C (no cycle)
|
|
181
|
+
await adapter.addDependency(projectPath, cellA.id, cellB.id, "blocks");
|
|
182
|
+
await adapter.addDependency(projectPath, cellB.id, cellC.id, "blocks");
|
|
183
|
+
|
|
184
|
+
const result = await checkDependencyCycles(db);
|
|
185
|
+
expect(result.status).toBe("pass");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("detects circular dependencies", async () => {
|
|
189
|
+
const adapter = createHiveAdapter(db, projectPath);
|
|
190
|
+
|
|
191
|
+
const cellX = await adapter.createCell(projectPath, {
|
|
192
|
+
title: "Cycle X", type: "task", priority: 1,
|
|
193
|
+
});
|
|
194
|
+
const cellY = await adapter.createCell(projectPath, {
|
|
195
|
+
title: "Cycle Y", type: "task", priority: 2,
|
|
196
|
+
});
|
|
197
|
+
const cellZ = await adapter.createCell(projectPath, {
|
|
198
|
+
title: "Cycle Z", type: "task", priority: 3,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// The adapter prevents cycles, so we insert the cyclic dependency directly via SQL
|
|
202
|
+
// First create two valid deps via adapter
|
|
203
|
+
await adapter.addDependency(projectPath, cellX.id, cellY.id, "blocks");
|
|
204
|
+
await adapter.addDependency(projectPath, cellY.id, cellZ.id, "blocks");
|
|
205
|
+
|
|
206
|
+
// Now force the cycle-closing edge directly in SQL
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
await db.query(
|
|
209
|
+
`INSERT INTO bead_dependencies (cell_id, depends_on_id, relationship, created_at)
|
|
210
|
+
VALUES (?, ?, 'blocks', ?)`,
|
|
211
|
+
[cellZ.id, cellX.id, now]
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const result = await checkDependencyCycles(db);
|
|
215
|
+
expect(result.status).toBe("fail");
|
|
216
|
+
expect(result.message).toContain("cycle");
|
|
217
|
+
expect(result.fixable).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("reports cycle is not fixable", async () => {
|
|
221
|
+
// The cycle from previous test should still be present
|
|
222
|
+
const result = await checkDependencyCycles(db);
|
|
223
|
+
expect(result.status).toBe("fail");
|
|
224
|
+
expect(result.fixable).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ============================================================================
|
|
229
|
+
// detectCycles (unit test for the DFS algorithm)
|
|
230
|
+
// ============================================================================
|
|
231
|
+
|
|
232
|
+
describe("detectCycles", () => {
|
|
233
|
+
test("empty graph has no cycles", () => {
|
|
234
|
+
const graph = new Map<string, string[]>();
|
|
235
|
+
expect(detectCycles(graph)).toEqual([]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("linear graph has no cycles", () => {
|
|
239
|
+
const graph = new Map<string, string[]>([
|
|
240
|
+
["A", ["B"]],
|
|
241
|
+
["B", ["C"]],
|
|
242
|
+
]);
|
|
243
|
+
expect(detectCycles(graph)).toEqual([]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("self-loop is a cycle", () => {
|
|
247
|
+
const graph = new Map<string, string[]>([
|
|
248
|
+
["A", ["A"]],
|
|
249
|
+
]);
|
|
250
|
+
const cycles = detectCycles(graph);
|
|
251
|
+
expect(cycles.length).toBe(1);
|
|
252
|
+
// The cycle should contain A
|
|
253
|
+
expect(cycles[0]).toContain("A");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("two-node cycle", () => {
|
|
257
|
+
const graph = new Map<string, string[]>([
|
|
258
|
+
["A", ["B"]],
|
|
259
|
+
["B", ["A"]],
|
|
260
|
+
]);
|
|
261
|
+
const cycles = detectCycles(graph);
|
|
262
|
+
expect(cycles.length).toBeGreaterThanOrEqual(1);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("three-node cycle", () => {
|
|
266
|
+
const graph = new Map<string, string[]>([
|
|
267
|
+
["A", ["B"]],
|
|
268
|
+
["B", ["C"]],
|
|
269
|
+
["C", ["A"]],
|
|
270
|
+
]);
|
|
271
|
+
const cycles = detectCycles(graph);
|
|
272
|
+
expect(cycles.length).toBeGreaterThanOrEqual(1);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("diamond shape (no cycle)", () => {
|
|
276
|
+
const graph = new Map<string, string[]>([
|
|
277
|
+
["A", ["B", "C"]],
|
|
278
|
+
["B", ["D"]],
|
|
279
|
+
["C", ["D"]],
|
|
280
|
+
]);
|
|
281
|
+
expect(detectCycles(graph)).toEqual([]);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ============================================================================
|
|
286
|
+
// checkStaleReservations
|
|
287
|
+
// ============================================================================
|
|
288
|
+
|
|
289
|
+
describe("checkStaleReservations", () => {
|
|
290
|
+
let swarmMail: SwarmMailAdapter;
|
|
291
|
+
let db: DatabaseAdapter;
|
|
292
|
+
let projectPath: string;
|
|
293
|
+
|
|
294
|
+
beforeAll(async () => {
|
|
295
|
+
const setup = await createTestDb("reservations");
|
|
296
|
+
swarmMail = setup.swarmMail;
|
|
297
|
+
db = setup.db;
|
|
298
|
+
projectPath = setup.projectPath;
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
afterAll(async () => {
|
|
302
|
+
await swarmMail.close();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("passes with no reservations", async () => {
|
|
306
|
+
const result = await checkStaleReservations(db);
|
|
307
|
+
expect(result.status).toBe("pass");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("detects stale reservations", async () => {
|
|
311
|
+
const pastTime = Date.now() - 3600_000; // 1 hour ago
|
|
312
|
+
|
|
313
|
+
// Insert an expired reservation directly
|
|
314
|
+
await db.query(
|
|
315
|
+
`INSERT INTO reservations (project_key, agent_name, path_pattern, exclusive, created_at, expires_at)
|
|
316
|
+
VALUES (?, 'test-agent', 'src/foo.ts', 1, ?, ?)`,
|
|
317
|
+
[projectPath, pastTime - 60000, pastTime]
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const result = await checkStaleReservations(db);
|
|
321
|
+
expect(result.status).toBe("fail");
|
|
322
|
+
expect(result.message).toContain("stale");
|
|
323
|
+
expect(result.fixable).toBe(true);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("fixes stale reservations with --fix", async () => {
|
|
327
|
+
const result = await checkStaleReservations(db, { fix: true });
|
|
328
|
+
expect(result.fixed).toBeGreaterThan(0);
|
|
329
|
+
|
|
330
|
+
// Verify fix — no more stale reservations
|
|
331
|
+
const verify = await checkStaleReservations(db);
|
|
332
|
+
expect(verify.status).toBe("pass");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("ignores active reservations", async () => {
|
|
336
|
+
const futureTime = Date.now() + 3600_000; // 1 hour from now
|
|
337
|
+
|
|
338
|
+
await db.query(
|
|
339
|
+
`INSERT INTO reservations (project_key, agent_name, path_pattern, exclusive, created_at, expires_at)
|
|
340
|
+
VALUES (?, 'active-agent', 'src/bar.ts', 1, ?, ?)`,
|
|
341
|
+
[projectPath, Date.now(), futureTime]
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const result = await checkStaleReservations(db);
|
|
345
|
+
expect(result.status).toBe("pass");
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// ============================================================================
|
|
350
|
+
// checkZombieBlocked
|
|
351
|
+
// ============================================================================
|
|
352
|
+
|
|
353
|
+
describe("checkZombieBlocked", () => {
|
|
354
|
+
let swarmMail: SwarmMailAdapter;
|
|
355
|
+
let db: DatabaseAdapter;
|
|
356
|
+
let projectPath: string;
|
|
357
|
+
|
|
358
|
+
beforeAll(async () => {
|
|
359
|
+
const setup = await createTestDb("zombies");
|
|
360
|
+
swarmMail = setup.swarmMail;
|
|
361
|
+
db = setup.db;
|
|
362
|
+
projectPath = setup.projectPath;
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
afterAll(async () => {
|
|
366
|
+
await swarmMail.close();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("passes with no blocked cells", async () => {
|
|
370
|
+
const result = await checkZombieBlocked(db);
|
|
371
|
+
expect(result.status).toBe("pass");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("detects zombie blocked cells", async () => {
|
|
375
|
+
const adapter = createHiveAdapter(db, projectPath);
|
|
376
|
+
|
|
377
|
+
// Create a blocker cell and close it
|
|
378
|
+
const blocker = await adapter.createCell(projectPath, {
|
|
379
|
+
title: "Blocker Task", type: "task", priority: 1,
|
|
380
|
+
});
|
|
381
|
+
await adapter.closeCell(projectPath, blocker.id, "Done");
|
|
382
|
+
|
|
383
|
+
// Create a cell that depends on the blocker
|
|
384
|
+
const blocked = await adapter.createCell(projectPath, {
|
|
385
|
+
title: "Zombie Blocked Task", type: "task", priority: 2,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Set it to blocked status
|
|
389
|
+
await adapter.changeCellStatus(projectPath, blocked.id, "blocked");
|
|
390
|
+
|
|
391
|
+
// Add dependency
|
|
392
|
+
await adapter.addDependency(projectPath, blocked.id, blocker.id, "blocks");
|
|
393
|
+
|
|
394
|
+
const result = await checkZombieBlocked(db);
|
|
395
|
+
expect(result.status).toBe("fail");
|
|
396
|
+
expect(result.message).toContain("should be unblocked");
|
|
397
|
+
expect(result.fixable).toBe(true);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("fixes zombie blocked cells with --fix", async () => {
|
|
401
|
+
const result = await checkZombieBlocked(db, { fix: true });
|
|
402
|
+
expect(result.fixed).toBeGreaterThan(0);
|
|
403
|
+
|
|
404
|
+
// Verify fix — no more zombies
|
|
405
|
+
const verify = await checkZombieBlocked(db);
|
|
406
|
+
expect(verify.status).toBe("pass");
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// ============================================================================
|
|
411
|
+
// checkGhostWorkers
|
|
412
|
+
// ============================================================================
|
|
413
|
+
|
|
414
|
+
describe("checkGhostWorkers", () => {
|
|
415
|
+
let swarmMail: SwarmMailAdapter;
|
|
416
|
+
let db: DatabaseAdapter;
|
|
417
|
+
let projectPath: string;
|
|
418
|
+
|
|
419
|
+
beforeAll(async () => {
|
|
420
|
+
const setup = await createTestDb("ghosts");
|
|
421
|
+
swarmMail = setup.swarmMail;
|
|
422
|
+
db = setup.db;
|
|
423
|
+
projectPath = setup.projectPath;
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
afterAll(async () => {
|
|
427
|
+
await swarmMail.close();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("passes with no in-progress cells", async () => {
|
|
431
|
+
const result = await checkGhostWorkers(db);
|
|
432
|
+
expect(result.status).toBe("pass");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("detects ghost in-progress cells with inactive agents", async () => {
|
|
436
|
+
const adapter = createHiveAdapter(db, projectPath);
|
|
437
|
+
|
|
438
|
+
// Create a cell and set it to in_progress
|
|
439
|
+
const cell = await adapter.createCell(projectPath, {
|
|
440
|
+
title: "Ghost Task", type: "task", priority: 1, assignee: "ghost-agent",
|
|
441
|
+
});
|
|
442
|
+
await adapter.changeCellStatus(projectPath, cell.id, "in_progress");
|
|
443
|
+
|
|
444
|
+
// Insert an agent with stale last_active_at
|
|
445
|
+
const staleTime = Date.now() - 2 * 60 * 60 * 1000; // 2 hours ago
|
|
446
|
+
await db.query(
|
|
447
|
+
`INSERT OR REPLACE INTO agents (project_key, name, registered_at, last_active_at)
|
|
448
|
+
VALUES (?, 'ghost-agent', ?, ?)`,
|
|
449
|
+
[projectPath, staleTime, staleTime]
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
// Use a very short cutoff to make sure it detects it
|
|
453
|
+
const result = await checkGhostWorkers(db, 1000); // 1 second cutoff
|
|
454
|
+
expect(result.status).toBe("warn");
|
|
455
|
+
expect(result.message).toContain("in-progress");
|
|
456
|
+
expect(result.fixable).toBe(false);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// ============================================================================
|
|
461
|
+
// runDoctor (combined)
|
|
462
|
+
// ============================================================================
|
|
463
|
+
|
|
464
|
+
describe("runDoctor", () => {
|
|
465
|
+
let swarmMail: SwarmMailAdapter;
|
|
466
|
+
let db: DatabaseAdapter;
|
|
467
|
+
|
|
468
|
+
beforeAll(async () => {
|
|
469
|
+
const setup = await createTestDb("combined");
|
|
470
|
+
swarmMail = setup.swarmMail;
|
|
471
|
+
db = setup.db;
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
afterAll(async () => {
|
|
475
|
+
await swarmMail.close();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("runs all checks and returns report", async () => {
|
|
479
|
+
const report = await runDoctor(db);
|
|
480
|
+
|
|
481
|
+
expect(report.checks.length).toBe(6);
|
|
482
|
+
expect(report.timestamp).toBeTruthy();
|
|
483
|
+
expect(report.passed).toBeGreaterThanOrEqual(0);
|
|
484
|
+
expect(report.passed + report.failed + report.warned).toBe(6);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("reports all passing on clean database", async () => {
|
|
488
|
+
const report = await runDoctor(db);
|
|
489
|
+
|
|
490
|
+
// On a fresh database, most checks should pass
|
|
491
|
+
expect(report.passed).toBeGreaterThanOrEqual(4);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("supports --fix option", async () => {
|
|
495
|
+
const report = await runDoctor(db, { fix: true });
|
|
496
|
+
expect(report.checks.length).toBe(6);
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// ============================================================================
|
|
501
|
+
// formatDoctorReport
|
|
502
|
+
// ============================================================================
|
|
503
|
+
|
|
504
|
+
describe("formatDoctorReport", () => {
|
|
505
|
+
test("formats passing report", () => {
|
|
506
|
+
const report = {
|
|
507
|
+
checks: [
|
|
508
|
+
{ name: "Database integrity", status: "pass" as const, message: "OK" },
|
|
509
|
+
{ name: "Cell references", status: "pass" as const, message: "OK" },
|
|
510
|
+
],
|
|
511
|
+
passed: 2,
|
|
512
|
+
failed: 0,
|
|
513
|
+
warned: 0,
|
|
514
|
+
fixed: 0,
|
|
515
|
+
timestamp: new Date().toISOString(),
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const output = formatDoctorReport(report);
|
|
519
|
+
expect(output).toContain("Swarm Doctor");
|
|
520
|
+
expect(output).toContain("Database integrity");
|
|
521
|
+
expect(output).toContain("2/2 checks passed");
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("formats failing report with fix suggestion", () => {
|
|
525
|
+
const report = {
|
|
526
|
+
checks: [
|
|
527
|
+
{ name: "Database integrity", status: "pass" as const, message: "OK" },
|
|
528
|
+
{
|
|
529
|
+
name: "Zombie blocked",
|
|
530
|
+
status: "fail" as const,
|
|
531
|
+
message: "2 cells should be unblocked",
|
|
532
|
+
fixable: true,
|
|
533
|
+
details: ['cell-1 ("Task 1")', 'cell-2 ("Task 2")'],
|
|
534
|
+
},
|
|
535
|
+
],
|
|
536
|
+
passed: 1,
|
|
537
|
+
failed: 1,
|
|
538
|
+
warned: 0,
|
|
539
|
+
fixed: 0,
|
|
540
|
+
timestamp: new Date().toISOString(),
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const output = formatDoctorReport(report);
|
|
544
|
+
expect(output).toContain("1/2 checks passed");
|
|
545
|
+
expect(output).toContain("1 issue(s) found");
|
|
546
|
+
expect(output).toContain("--fix");
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("formats report after fix", () => {
|
|
550
|
+
const report = {
|
|
551
|
+
checks: [
|
|
552
|
+
{
|
|
553
|
+
name: "Zombie blocked",
|
|
554
|
+
status: "warn" as const,
|
|
555
|
+
message: "Unblocked 2 zombie cell(s)",
|
|
556
|
+
fixed: 2,
|
|
557
|
+
},
|
|
558
|
+
],
|
|
559
|
+
passed: 0,
|
|
560
|
+
failed: 0,
|
|
561
|
+
warned: 1,
|
|
562
|
+
fixed: 2,
|
|
563
|
+
timestamp: new Date().toISOString(),
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const output = formatDoctorReport(report, { fix: true });
|
|
567
|
+
expect(output).toContain("2 item(s) fixed");
|
|
568
|
+
expect(output).not.toContain("Run with --fix");
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("truncates long detail lists", () => {
|
|
572
|
+
const details = Array.from({ length: 10 }, (_, i) => `detail-${i}`);
|
|
573
|
+
const report = {
|
|
574
|
+
checks: [
|
|
575
|
+
{
|
|
576
|
+
name: "Test check",
|
|
577
|
+
status: "fail" as const,
|
|
578
|
+
message: "10 issues",
|
|
579
|
+
details,
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
passed: 0,
|
|
583
|
+
failed: 1,
|
|
584
|
+
warned: 0,
|
|
585
|
+
fixed: 0,
|
|
586
|
+
timestamp: new Date().toISOString(),
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const output = formatDoctorReport(report);
|
|
590
|
+
expect(output).toContain("... and 5 more");
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// ============================================================================
|
|
595
|
+
// parseDoctorArgs
|
|
596
|
+
// ============================================================================
|
|
597
|
+
|
|
598
|
+
describe("parseDoctorArgs", () => {
|
|
599
|
+
test("parses --fix", () => {
|
|
600
|
+
const opts = parseDoctorArgs(["--fix"]);
|
|
601
|
+
expect(opts.fix).toBe(true);
|
|
602
|
+
expect(opts.json).toBeFalsy();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("parses --json", () => {
|
|
606
|
+
const opts = parseDoctorArgs(["--json"]);
|
|
607
|
+
expect(opts.json).toBe(true);
|
|
608
|
+
expect(opts.fix).toBeFalsy();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("parses both flags", () => {
|
|
612
|
+
const opts = parseDoctorArgs(["--fix", "--json"]);
|
|
613
|
+
expect(opts.fix).toBe(true);
|
|
614
|
+
expect(opts.json).toBe(true);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("returns defaults for no args", () => {
|
|
618
|
+
const opts = parseDoctorArgs([]);
|
|
619
|
+
expect(opts.fix).toBe(false);
|
|
620
|
+
expect(opts.json).toBe(false);
|
|
621
|
+
});
|
|
622
|
+
});
|