@katyella/legio 0.1.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/CHANGELOG.md +422 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/agents/builder.md +141 -0
- package/agents/coordinator.md +351 -0
- package/agents/cto.md +196 -0
- package/agents/gateway.md +276 -0
- package/agents/lead.md +281 -0
- package/agents/merger.md +156 -0
- package/agents/monitor.md +212 -0
- package/agents/reviewer.md +142 -0
- package/agents/scout.md +131 -0
- package/agents/supervisor.md +416 -0
- package/bin/legio.mjs +38 -0
- package/package.json +77 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +102 -0
- package/src/agents/hooks-deployer.test.ts +1820 -0
- package/src/agents/hooks-deployer.ts +574 -0
- package/src/agents/identity.test.ts +614 -0
- package/src/agents/identity.ts +385 -0
- package/src/agents/lifecycle.test.ts +202 -0
- package/src/agents/lifecycle.ts +184 -0
- package/src/agents/manifest.test.ts +558 -0
- package/src/agents/manifest.ts +297 -0
- package/src/agents/overlay.test.ts +592 -0
- package/src/agents/overlay.ts +316 -0
- package/src/beads/client.test.ts +210 -0
- package/src/beads/client.ts +227 -0
- package/src/beads/molecules.test.ts +320 -0
- package/src/beads/molecules.ts +209 -0
- package/src/commands/agents.test.ts +325 -0
- package/src/commands/agents.ts +286 -0
- package/src/commands/clean.test.ts +730 -0
- package/src/commands/clean.ts +653 -0
- package/src/commands/completions.test.ts +346 -0
- package/src/commands/completions.ts +950 -0
- package/src/commands/coordinator.test.ts +1524 -0
- package/src/commands/coordinator.ts +880 -0
- package/src/commands/costs.test.ts +1015 -0
- package/src/commands/costs.ts +473 -0
- package/src/commands/dashboard.test.ts +94 -0
- package/src/commands/dashboard.ts +607 -0
- package/src/commands/doctor.test.ts +295 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/down.test.ts +308 -0
- package/src/commands/down.ts +124 -0
- package/src/commands/errors.test.ts +648 -0
- package/src/commands/errors.ts +255 -0
- package/src/commands/feed.test.ts +579 -0
- package/src/commands/feed.ts +368 -0
- package/src/commands/gateway.test.ts +698 -0
- package/src/commands/gateway.ts +419 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +539 -0
- package/src/commands/hooks.test.ts +292 -0
- package/src/commands/hooks.ts +210 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +622 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +455 -0
- package/src/commands/log.test.ts +1556 -0
- package/src/commands/log.ts +752 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +544 -0
- package/src/commands/mail.test.ts +1726 -0
- package/src/commands/mail.ts +926 -0
- package/src/commands/merge.test.ts +676 -0
- package/src/commands/merge.ts +374 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +150 -0
- package/src/commands/monitor.test.ts +151 -0
- package/src/commands/monitor.ts +394 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +373 -0
- package/src/commands/prime.test.ts +467 -0
- package/src/commands/prime.ts +386 -0
- package/src/commands/replay.test.ts +742 -0
- package/src/commands/replay.ts +367 -0
- package/src/commands/run.test.ts +443 -0
- package/src/commands/run.ts +365 -0
- package/src/commands/server.test.ts +626 -0
- package/src/commands/server.ts +298 -0
- package/src/commands/sling.test.ts +810 -0
- package/src/commands/sling.ts +700 -0
- package/src/commands/spec.test.ts +206 -0
- package/src/commands/spec.ts +171 -0
- package/src/commands/status.test.ts +276 -0
- package/src/commands/status.ts +339 -0
- package/src/commands/stop.test.ts +357 -0
- package/src/commands/stop.ts +119 -0
- package/src/commands/supervisor.test.ts +186 -0
- package/src/commands/supervisor.ts +544 -0
- package/src/commands/trace.test.ts +746 -0
- package/src/commands/trace.ts +332 -0
- package/src/commands/up.test.ts +597 -0
- package/src/commands/up.ts +275 -0
- package/src/commands/watch.test.ts +152 -0
- package/src/commands/watch.ts +238 -0
- package/src/commands/worktree.test.ts +648 -0
- package/src/commands/worktree.ts +266 -0
- package/src/config.test.ts +496 -0
- package/src/config.ts +616 -0
- package/src/doctor/agents.test.ts +448 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +184 -0
- package/src/doctor/config-check.ts +185 -0
- package/src/doctor/consistency.test.ts +645 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +284 -0
- package/src/doctor/databases.ts +211 -0
- package/src/doctor/dependencies.test.ts +150 -0
- package/src/doctor/dependencies.ts +179 -0
- package/src/doctor/logs.test.ts +244 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +210 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +285 -0
- package/src/doctor/structure.ts +195 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +130 -0
- package/src/doctor/version.ts +131 -0
- package/src/e2e/chat-flow.test.ts +346 -0
- package/src/e2e/init-sling-lifecycle.test.ts +288 -0
- package/src/errors.test.ts +21 -0
- package/src/errors.ts +246 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +344 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/global-setup.ts +14 -0
- package/src/index.ts +339 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +118 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +812 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +258 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +873 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +815 -0
- package/src/mail/store.ts +402 -0
- package/src/merge/queue.test.ts +449 -0
- package/src/merge/queue.ts +262 -0
- package/src/merge/resolver.test.ts +1453 -0
- package/src/merge/resolver.ts +759 -0
- package/src/metrics/store.test.ts +1167 -0
- package/src/metrics/store.ts +511 -0
- package/src/metrics/summary.test.ts +397 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +643 -0
- package/src/metrics/transcript.ts +351 -0
- package/src/mulch/client.test.ts +547 -0
- package/src/mulch/client.ts +416 -0
- package/src/server/audit-store.test.ts +384 -0
- package/src/server/audit-store.ts +257 -0
- package/src/server/headless.test.ts +180 -0
- package/src/server/headless.ts +151 -0
- package/src/server/index.test.ts +241 -0
- package/src/server/index.ts +317 -0
- package/src/server/public/app.js +187 -0
- package/src/server/public/apple-touch-icon.png +0 -0
- package/src/server/public/components/agent-badge.js +37 -0
- package/src/server/public/components/data-table.js +114 -0
- package/src/server/public/components/gateway-chat.js +256 -0
- package/src/server/public/components/issue-card.js +96 -0
- package/src/server/public/components/layout.js +88 -0
- package/src/server/public/components/message-bubble.js +120 -0
- package/src/server/public/components/stat-card.js +26 -0
- package/src/server/public/components/terminal-panel.js +140 -0
- package/src/server/public/favicon-16.png +0 -0
- package/src/server/public/favicon-32.png +0 -0
- package/src/server/public/favicon.ico +0 -0
- package/src/server/public/favicon.png +0 -0
- package/src/server/public/index.html +64 -0
- package/src/server/public/lib/api.js +35 -0
- package/src/server/public/lib/markdown.js +8 -0
- package/src/server/public/lib/preact-setup.js +8 -0
- package/src/server/public/lib/state.js +99 -0
- package/src/server/public/lib/utils.js +309 -0
- package/src/server/public/lib/ws.js +79 -0
- package/src/server/public/views/chat.js +983 -0
- package/src/server/public/views/costs.js +692 -0
- package/src/server/public/views/dashboard.js +781 -0
- package/src/server/public/views/gateway-chat.js +622 -0
- package/src/server/public/views/inspect.js +399 -0
- package/src/server/public/views/issues.js +470 -0
- package/src/server/public/views/setup.js +94 -0
- package/src/server/public/views/task-detail.js +422 -0
- package/src/server/routes.test.ts +3816 -0
- package/src/server/routes.ts +1964 -0
- package/src/server/websocket.test.ts +288 -0
- package/src/server/websocket.ts +196 -0
- package/src/sessions/compat.test.ts +109 -0
- package/src/sessions/compat.ts +17 -0
- package/src/sessions/store.test.ts +969 -0
- package/src/sessions/store.ts +480 -0
- package/src/test-helpers.test.ts +97 -0
- package/src/test-helpers.ts +143 -0
- package/src/types.ts +708 -0
- package/src/watchdog/daemon.test.ts +1233 -0
- package/src/watchdog/daemon.ts +533 -0
- package/src/watchdog/health.test.ts +371 -0
- package/src/watchdog/health.ts +248 -0
- package/src/watchdog/triage.test.ts +162 -0
- package/src/watchdog/triage.ts +193 -0
- package/src/worktree/manager.test.ts +444 -0
- package/src/worktree/manager.ts +224 -0
- package/src/worktree/tmux.test.ts +1238 -0
- package/src/worktree/tmux.ts +644 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +132 -0
- package/templates/overlay.md.tmpl +79 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import type { WebSocket } from "ws";
|
|
6
|
+
import { createWebSocketManager, type WebSocketData } from "./websocket.ts";
|
|
7
|
+
|
|
8
|
+
let tempDir: string;
|
|
9
|
+
let legioDir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
tempDir = await mkdtemp(join(tmpdir(), "ws-test-"));
|
|
13
|
+
legioDir = join(tempDir, ".legio");
|
|
14
|
+
await mkdir(legioDir, { recursive: true });
|
|
15
|
+
await writeFile(
|
|
16
|
+
join(legioDir, "config.yaml"),
|
|
17
|
+
"project:\n name: test\n canonicalBranch: main\nagents:\n maxDepth: 2\ncoordinator:\n model: sonnet\n",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("createWebSocketManager", () => {
|
|
26
|
+
it("returns a manager with the expected interface", () => {
|
|
27
|
+
const manager = createWebSocketManager(legioDir);
|
|
28
|
+
expect(typeof manager.addClient).toBe("function");
|
|
29
|
+
expect(typeof manager.removeClient).toBe("function");
|
|
30
|
+
expect(typeof manager.handleMessage).toBe("function");
|
|
31
|
+
expect(typeof manager.startPolling).toBe("function");
|
|
32
|
+
expect(typeof manager.stopPolling).toBe("function");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("startPolling and stopPolling do not throw", () => {
|
|
36
|
+
const manager = createWebSocketManager(legioDir);
|
|
37
|
+
expect(() => manager.startPolling()).not.toThrow();
|
|
38
|
+
expect(() => manager.stopPolling()).not.toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("stopPolling is idempotent", () => {
|
|
42
|
+
const manager = createWebSocketManager(legioDir);
|
|
43
|
+
expect(() => {
|
|
44
|
+
manager.stopPolling();
|
|
45
|
+
manager.stopPolling();
|
|
46
|
+
}).not.toThrow();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("gatherSnapshot returns valid shape with missing dbs", () => {
|
|
50
|
+
// Use a non-existent dir — all stores will fail to open and fall back to empty data
|
|
51
|
+
const manager = createWebSocketManager(join(tempDir, "no-such-dir"));
|
|
52
|
+
// We test gatherSnapshot indirectly via addClient's initial snapshot send
|
|
53
|
+
let received: string | null = null;
|
|
54
|
+
const fakeWs = {
|
|
55
|
+
send(msg: string) {
|
|
56
|
+
received = msg;
|
|
57
|
+
},
|
|
58
|
+
} as unknown as WebSocket;
|
|
59
|
+
// WebSocketData is unused at runtime but keeps test aligned with implementation
|
|
60
|
+
const _unused: WebSocketData = { connectedAt: "" };
|
|
61
|
+
|
|
62
|
+
manager.addClient(fakeWs);
|
|
63
|
+
|
|
64
|
+
expect(received).not.toBeNull();
|
|
65
|
+
if (received === null) throw new Error("expected a message");
|
|
66
|
+
const parsed = JSON.parse(received);
|
|
67
|
+
expect(parsed.type).toBe("snapshot");
|
|
68
|
+
expect(parsed.data).toBeDefined();
|
|
69
|
+
expect(Array.isArray(parsed.data.agents)).toBe(true);
|
|
70
|
+
expect(typeof parsed.data.mail.unreadCount).toBe("number");
|
|
71
|
+
expect(Array.isArray(parsed.data.mergeQueue)).toBe(true);
|
|
72
|
+
expect(typeof parsed.data.metricsSummary.totalSessions).toBe("number");
|
|
73
|
+
expect(parsed.timestamp).toBeDefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("addClient sends initial snapshot immediately", () => {
|
|
77
|
+
const manager = createWebSocketManager(legioDir);
|
|
78
|
+
let received: string | null = null;
|
|
79
|
+
|
|
80
|
+
const fakeWs = {
|
|
81
|
+
send(msg: string) {
|
|
82
|
+
received = msg;
|
|
83
|
+
},
|
|
84
|
+
} as unknown as WebSocket;
|
|
85
|
+
|
|
86
|
+
manager.addClient(fakeWs);
|
|
87
|
+
expect(received).not.toBeNull();
|
|
88
|
+
if (received === null) throw new Error("expected a message");
|
|
89
|
+
|
|
90
|
+
const snapshot = JSON.parse(received);
|
|
91
|
+
expect(snapshot.type).toBe("snapshot");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("removeClient stops further sends to that client", () => {
|
|
95
|
+
const manager = createWebSocketManager(legioDir);
|
|
96
|
+
let count = 0;
|
|
97
|
+
|
|
98
|
+
const fakeWs = {
|
|
99
|
+
send() {
|
|
100
|
+
count++;
|
|
101
|
+
},
|
|
102
|
+
} as unknown as WebSocket;
|
|
103
|
+
|
|
104
|
+
manager.addClient(fakeWs); // triggers 1 send (initial snapshot)
|
|
105
|
+
const countAfterAdd = count;
|
|
106
|
+
expect(countAfterAdd).toBe(1);
|
|
107
|
+
|
|
108
|
+
manager.removeClient(fakeWs);
|
|
109
|
+
|
|
110
|
+
// Trigger broadcast — should not reach removed client
|
|
111
|
+
const fakeWs2 = {
|
|
112
|
+
send() {
|
|
113
|
+
count++;
|
|
114
|
+
},
|
|
115
|
+
} as unknown as WebSocket;
|
|
116
|
+
manager.addClient(fakeWs2); // sends to ws2, not to ws1
|
|
117
|
+
|
|
118
|
+
// count should only increase by 1 (for fakeWs2 add), not for fakeWs
|
|
119
|
+
expect(count).toBe(2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("handleMessage with {type:'refresh'} sends a snapshot to that client", () => {
|
|
123
|
+
const manager = createWebSocketManager(legioDir);
|
|
124
|
+
const received: string[] = [];
|
|
125
|
+
|
|
126
|
+
const fakeWs = {
|
|
127
|
+
send(msg: string) {
|
|
128
|
+
received.push(msg);
|
|
129
|
+
},
|
|
130
|
+
} as unknown as WebSocket;
|
|
131
|
+
|
|
132
|
+
manager.addClient(fakeWs); // initial snapshot = received[0]
|
|
133
|
+
manager.handleMessage(fakeWs, Buffer.from(JSON.stringify({ type: "refresh" })));
|
|
134
|
+
|
|
135
|
+
expect(received.length).toBe(2);
|
|
136
|
+
const refreshed = JSON.parse(received[1] as string);
|
|
137
|
+
expect(refreshed.type).toBe("snapshot");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("handleMessage with unknown type does not throw", () => {
|
|
141
|
+
const manager = createWebSocketManager(legioDir);
|
|
142
|
+
const fakeWs = {
|
|
143
|
+
send() {},
|
|
144
|
+
} as unknown as WebSocket;
|
|
145
|
+
|
|
146
|
+
manager.addClient(fakeWs);
|
|
147
|
+
expect(() =>
|
|
148
|
+
manager.handleMessage(fakeWs, Buffer.from(JSON.stringify({ type: "unknown" }))),
|
|
149
|
+
).not.toThrow();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("handleMessage with invalid JSON does not throw", () => {
|
|
153
|
+
const manager = createWebSocketManager(legioDir);
|
|
154
|
+
const fakeWs = {
|
|
155
|
+
send() {},
|
|
156
|
+
} as unknown as WebSocket;
|
|
157
|
+
|
|
158
|
+
manager.addClient(fakeWs);
|
|
159
|
+
expect(() => manager.handleMessage(fakeWs, Buffer.from("not-json{{{"))).not.toThrow();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("broadcastEvent sends event to all connected clients", () => {
|
|
163
|
+
const manager = createWebSocketManager(legioDir);
|
|
164
|
+
const messagesA: string[] = [];
|
|
165
|
+
const messagesB: string[] = [];
|
|
166
|
+
|
|
167
|
+
const ws1 = {
|
|
168
|
+
send(msg: string) {
|
|
169
|
+
messagesA.push(msg);
|
|
170
|
+
},
|
|
171
|
+
} as unknown as WebSocket;
|
|
172
|
+
|
|
173
|
+
const ws2 = {
|
|
174
|
+
send(msg: string) {
|
|
175
|
+
messagesB.push(msg);
|
|
176
|
+
},
|
|
177
|
+
} as unknown as WebSocket;
|
|
178
|
+
|
|
179
|
+
manager.addClient(ws1); // initial snapshot
|
|
180
|
+
manager.addClient(ws2); // initial snapshot
|
|
181
|
+
|
|
182
|
+
manager.broadcastEvent({ type: "mail_new", data: { id: "msg-test-001" } });
|
|
183
|
+
|
|
184
|
+
// Each client received: 1 initial snapshot + 1 broadcastEvent
|
|
185
|
+
expect(messagesA.length).toBe(2);
|
|
186
|
+
expect(messagesB.length).toBe(2);
|
|
187
|
+
|
|
188
|
+
const evtA = JSON.parse(messagesA[1] as string);
|
|
189
|
+
expect(evtA.type).toBe("mail_new");
|
|
190
|
+
expect(evtA.timestamp).toBeDefined();
|
|
191
|
+
expect((evtA.data as { id: string }).id).toBe("msg-test-001");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("broadcastEvent with no clients does not throw", () => {
|
|
195
|
+
const manager = createWebSocketManager(legioDir);
|
|
196
|
+
expect(() => manager.broadcastEvent({ type: "mail_new" })).not.toThrow();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("broadcastEvent event format includes timestamp", () => {
|
|
200
|
+
const manager = createWebSocketManager(legioDir);
|
|
201
|
+
const received: string[] = [];
|
|
202
|
+
|
|
203
|
+
const fakeWs = {
|
|
204
|
+
send(msg: string) {
|
|
205
|
+
received.push(msg);
|
|
206
|
+
},
|
|
207
|
+
} as unknown as WebSocket;
|
|
208
|
+
|
|
209
|
+
manager.addClient(fakeWs); // initial snapshot (received[0])
|
|
210
|
+
manager.broadcastEvent({ type: "test_event", data: { foo: "bar" } });
|
|
211
|
+
|
|
212
|
+
expect(received.length).toBe(2);
|
|
213
|
+
const evt = JSON.parse(received[1] as string);
|
|
214
|
+
expect(evt.type).toBe("test_event");
|
|
215
|
+
expect(typeof evt.timestamp).toBe("string");
|
|
216
|
+
expect(() => new Date(evt.timestamp)).not.toThrow();
|
|
217
|
+
expect((evt.data as { foo: string }).foo).toBe("bar");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("multiple clients receive snapshot when added", () => {
|
|
221
|
+
const manager = createWebSocketManager(legioDir);
|
|
222
|
+
const messages: Record<string, string[]> = { a: [], b: [] };
|
|
223
|
+
|
|
224
|
+
const ws1 = {
|
|
225
|
+
send(msg: string) {
|
|
226
|
+
messages.a?.push(msg);
|
|
227
|
+
},
|
|
228
|
+
} as unknown as WebSocket;
|
|
229
|
+
|
|
230
|
+
const ws2 = {
|
|
231
|
+
send(msg: string) {
|
|
232
|
+
messages.b?.push(msg);
|
|
233
|
+
},
|
|
234
|
+
} as unknown as WebSocket;
|
|
235
|
+
|
|
236
|
+
manager.addClient(ws1);
|
|
237
|
+
manager.addClient(ws2);
|
|
238
|
+
|
|
239
|
+
expect(messages.a?.length).toBe(1);
|
|
240
|
+
expect(messages.b?.length).toBe(1);
|
|
241
|
+
|
|
242
|
+
const snap1 = JSON.parse(messages.a?.[0] ?? "{}");
|
|
243
|
+
const snap2 = JSON.parse(messages.b?.[0] ?? "{}");
|
|
244
|
+
expect(snap1.type).toBe("snapshot");
|
|
245
|
+
expect(snap2.type).toBe("snapshot");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("WebSocket integration (real server)", () => {
|
|
250
|
+
it("client receives initial snapshot on connect and can request refresh", async () => {
|
|
251
|
+
// Dynamic import to avoid top-level routes.ts dependency issues
|
|
252
|
+
const { createServer } = await import("./index.ts");
|
|
253
|
+
const { WebSocket: WsClient } = await import("ws");
|
|
254
|
+
|
|
255
|
+
const server = await createServer({ port: 0, host: "localhost", root: tempDir });
|
|
256
|
+
const port = server.port;
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const ws = new WsClient(`ws://localhost:${port}/ws`);
|
|
260
|
+
|
|
261
|
+
const messages: string[] = [];
|
|
262
|
+
await new Promise<void>((resolve, reject) => {
|
|
263
|
+
const timeout = setTimeout(() => reject(new Error("timeout")), 5000);
|
|
264
|
+
ws.onmessage = (e) => {
|
|
265
|
+
messages.push(e.data as string);
|
|
266
|
+
if (messages.length === 1) {
|
|
267
|
+
// Got initial snapshot, send refresh
|
|
268
|
+
ws.send(JSON.stringify({ type: "refresh" }));
|
|
269
|
+
} else {
|
|
270
|
+
clearTimeout(timeout);
|
|
271
|
+
resolve();
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
ws.onerror = () => reject(new Error("ws error"));
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(messages.length).toBeGreaterThanOrEqual(2);
|
|
278
|
+
const initial = JSON.parse(messages[0] as string);
|
|
279
|
+
expect(initial.type).toBe("snapshot");
|
|
280
|
+
const refreshed = JSON.parse(messages[1] as string);
|
|
281
|
+
expect(refreshed.type).toBe("snapshot");
|
|
282
|
+
|
|
283
|
+
ws.close();
|
|
284
|
+
} finally {
|
|
285
|
+
server.stop(true);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { RawData, WebSocket } from "ws";
|
|
3
|
+
import { createMailStore } from "../mail/store.ts";
|
|
4
|
+
import { createMergeQueue } from "../merge/queue.ts";
|
|
5
|
+
import { createMetricsStore } from "../metrics/store.ts";
|
|
6
|
+
import { openSessionStore } from "../sessions/compat.ts";
|
|
7
|
+
import { createRunStore } from "../sessions/store.ts";
|
|
8
|
+
export interface WebSocketData {
|
|
9
|
+
connectedAt: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Snapshot {
|
|
13
|
+
type: "snapshot";
|
|
14
|
+
data: {
|
|
15
|
+
agents: unknown[];
|
|
16
|
+
mail: { unreadCount: number; recent: unknown[] };
|
|
17
|
+
mergeQueue: unknown[];
|
|
18
|
+
metricsSummary: { totalSessions: number; avgDuration: number };
|
|
19
|
+
runs: { active: unknown | null };
|
|
20
|
+
};
|
|
21
|
+
timestamp: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WebSocketManager {
|
|
25
|
+
addClient(ws: WebSocket): void;
|
|
26
|
+
removeClient(ws: WebSocket): void;
|
|
27
|
+
handleMessage(ws: WebSocket, message: RawData): void;
|
|
28
|
+
startPolling(): void;
|
|
29
|
+
stopPolling(): void;
|
|
30
|
+
broadcastEvent(event: { type: string; data?: unknown }): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createWebSocketManager(legioDir: string): WebSocketManager {
|
|
34
|
+
const clients = new Set<WebSocket>();
|
|
35
|
+
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
|
36
|
+
let lastSnapshot = "";
|
|
37
|
+
|
|
38
|
+
function gatherSnapshot(): Snapshot {
|
|
39
|
+
const data: Snapshot["data"] = {
|
|
40
|
+
agents: [],
|
|
41
|
+
mail: { unreadCount: 0, recent: [] },
|
|
42
|
+
mergeQueue: [],
|
|
43
|
+
metricsSummary: { totalSessions: 0, avgDuration: 0 },
|
|
44
|
+
runs: { active: null },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Session store
|
|
48
|
+
try {
|
|
49
|
+
const { store } = openSessionStore(legioDir);
|
|
50
|
+
try {
|
|
51
|
+
data.agents = store.getActive();
|
|
52
|
+
} finally {
|
|
53
|
+
store.close();
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
/* db may not exist */
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Mail store
|
|
60
|
+
try {
|
|
61
|
+
const mailPath = join(legioDir, "mail.db");
|
|
62
|
+
const store = createMailStore(mailPath);
|
|
63
|
+
try {
|
|
64
|
+
const all = store.getAll();
|
|
65
|
+
data.mail.unreadCount = all.filter((m: { read: boolean }) => !m.read).length;
|
|
66
|
+
data.mail.recent = all.slice(0, 20);
|
|
67
|
+
} finally {
|
|
68
|
+
store.close();
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
/* db may not exist */
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Merge queue
|
|
75
|
+
try {
|
|
76
|
+
const queue = createMergeQueue(join(legioDir, "merge-queue.db"));
|
|
77
|
+
try {
|
|
78
|
+
data.mergeQueue = queue.list();
|
|
79
|
+
} finally {
|
|
80
|
+
queue.close();
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
/* db may not exist */
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Metrics
|
|
87
|
+
try {
|
|
88
|
+
const store = createMetricsStore(join(legioDir, "metrics.db"));
|
|
89
|
+
try {
|
|
90
|
+
const sessions = store.getRecentSessions(100);
|
|
91
|
+
data.metricsSummary.totalSessions = sessions.length;
|
|
92
|
+
data.metricsSummary.avgDuration = store.getAverageDuration();
|
|
93
|
+
} finally {
|
|
94
|
+
store.close();
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
/* db may not exist */
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Runs
|
|
101
|
+
try {
|
|
102
|
+
const store = createRunStore(join(legioDir, "sessions.db"));
|
|
103
|
+
try {
|
|
104
|
+
data.runs.active = store.getActiveRun();
|
|
105
|
+
} finally {
|
|
106
|
+
store.close();
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
/* db may not exist */
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
type: "snapshot",
|
|
114
|
+
data,
|
|
115
|
+
timestamp: new Date().toISOString(),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function broadcast(snapshot: Snapshot): void {
|
|
120
|
+
const msg = JSON.stringify(snapshot);
|
|
121
|
+
for (const client of clients) {
|
|
122
|
+
try {
|
|
123
|
+
client.send(msg);
|
|
124
|
+
} catch {
|
|
125
|
+
clients.delete(client);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
addClient(ws) {
|
|
132
|
+
clients.add(ws);
|
|
133
|
+
// Send initial snapshot immediately
|
|
134
|
+
const snapshot = gatherSnapshot();
|
|
135
|
+
try {
|
|
136
|
+
ws.send(JSON.stringify(snapshot));
|
|
137
|
+
} catch {
|
|
138
|
+
clients.delete(ws);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
removeClient(ws) {
|
|
143
|
+
clients.delete(ws);
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
handleMessage(ws, message) {
|
|
147
|
+
try {
|
|
148
|
+
const msgStr =
|
|
149
|
+
typeof message === "string"
|
|
150
|
+
? message
|
|
151
|
+
: Buffer.isBuffer(message)
|
|
152
|
+
? message.toString("utf8")
|
|
153
|
+
: Array.isArray(message)
|
|
154
|
+
? Buffer.concat(message as Buffer[]).toString("utf8")
|
|
155
|
+
: Buffer.from(message as ArrayBuffer).toString("utf8");
|
|
156
|
+
const parsed = JSON.parse(msgStr);
|
|
157
|
+
if (parsed && typeof parsed === "object" && "type" in parsed && parsed.type === "refresh") {
|
|
158
|
+
const snapshot = gatherSnapshot();
|
|
159
|
+
ws.send(JSON.stringify(snapshot));
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
// Ignore invalid messages
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
startPolling() {
|
|
167
|
+
pollInterval = setInterval(() => {
|
|
168
|
+
if (clients.size === 0) return; // Skip if no clients
|
|
169
|
+
const snapshot = gatherSnapshot();
|
|
170
|
+
const snapshotStr = JSON.stringify(snapshot.data);
|
|
171
|
+
if (snapshotStr !== lastSnapshot) {
|
|
172
|
+
lastSnapshot = snapshotStr;
|
|
173
|
+
broadcast(snapshot);
|
|
174
|
+
}
|
|
175
|
+
}, 2000);
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
stopPolling() {
|
|
179
|
+
if (pollInterval) {
|
|
180
|
+
clearInterval(pollInterval);
|
|
181
|
+
pollInterval = null;
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
broadcastEvent(event) {
|
|
186
|
+
const msg = JSON.stringify({ ...event, timestamp: new Date().toISOString() });
|
|
187
|
+
for (const client of clients) {
|
|
188
|
+
try {
|
|
189
|
+
client.send(msg);
|
|
190
|
+
} catch {
|
|
191
|
+
clients.delete(client);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the session compat shim.
|
|
3
|
+
*
|
|
4
|
+
* Uses real filesystem and better-sqlite3. No mocks.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
11
|
+
import { openSessionStore } from "./compat.ts";
|
|
12
|
+
|
|
13
|
+
let tempDir: string;
|
|
14
|
+
let legioDir: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
tempDir = await mkdtemp(join(tmpdir(), "legio-compat-test-"));
|
|
18
|
+
legioDir = join(tempDir, ".legio");
|
|
19
|
+
const { mkdir } = await import("node:fs/promises");
|
|
20
|
+
await mkdir(legioDir, { recursive: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("openSessionStore", () => {
|
|
28
|
+
test("creates empty DB when sessions.db does not exist", () => {
|
|
29
|
+
const { store, migrated } = openSessionStore(legioDir);
|
|
30
|
+
|
|
31
|
+
expect(migrated).toBe(false);
|
|
32
|
+
expect(store.getAll()).toEqual([]);
|
|
33
|
+
store.close();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("returns migrated:false always", () => {
|
|
37
|
+
const { migrated } = openSessionStore(legioDir);
|
|
38
|
+
expect(migrated).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returned store supports all SessionStore operations", () => {
|
|
42
|
+
const { store } = openSessionStore(legioDir);
|
|
43
|
+
|
|
44
|
+
const now = new Date().toISOString();
|
|
45
|
+
store.upsert({
|
|
46
|
+
id: "s-001",
|
|
47
|
+
agentName: "test-agent",
|
|
48
|
+
capability: "builder",
|
|
49
|
+
worktreePath: "/tmp/worktrees/test-agent",
|
|
50
|
+
branchName: "legio/test-agent/task-1",
|
|
51
|
+
beadId: "task-1",
|
|
52
|
+
tmuxSession: "legio-test-agent",
|
|
53
|
+
state: "working",
|
|
54
|
+
pid: 12345,
|
|
55
|
+
parentAgent: null,
|
|
56
|
+
depth: 0,
|
|
57
|
+
runId: null,
|
|
58
|
+
startedAt: now,
|
|
59
|
+
lastActivity: now,
|
|
60
|
+
escalationLevel: 0,
|
|
61
|
+
stalledSince: null,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const all = store.getAll();
|
|
65
|
+
expect(all).toHaveLength(1);
|
|
66
|
+
expect(all[0]?.agentName).toBe("test-agent");
|
|
67
|
+
|
|
68
|
+
const active = store.getActive();
|
|
69
|
+
expect(active).toHaveLength(1);
|
|
70
|
+
|
|
71
|
+
store.updateState("test-agent", "completed");
|
|
72
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
73
|
+
|
|
74
|
+
store.remove("test-agent");
|
|
75
|
+
expect(store.getByName("test-agent")).toBeNull();
|
|
76
|
+
|
|
77
|
+
store.close();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("second call opens existing DB", () => {
|
|
81
|
+
const { store: store1 } = openSessionStore(legioDir);
|
|
82
|
+
const now = new Date().toISOString();
|
|
83
|
+
store1.upsert({
|
|
84
|
+
id: "s-persist",
|
|
85
|
+
agentName: "persistent-agent",
|
|
86
|
+
capability: "scout",
|
|
87
|
+
worktreePath: "/tmp/worktrees/persistent-agent",
|
|
88
|
+
branchName: "legio/persistent-agent/task-2",
|
|
89
|
+
beadId: "task-2",
|
|
90
|
+
tmuxSession: "legio-persistent-agent",
|
|
91
|
+
state: "working",
|
|
92
|
+
pid: null,
|
|
93
|
+
parentAgent: null,
|
|
94
|
+
depth: 0,
|
|
95
|
+
runId: null,
|
|
96
|
+
startedAt: now,
|
|
97
|
+
lastActivity: now,
|
|
98
|
+
escalationLevel: 0,
|
|
99
|
+
stalledSince: null,
|
|
100
|
+
});
|
|
101
|
+
store1.close();
|
|
102
|
+
|
|
103
|
+
const { store: store2, migrated } = openSessionStore(legioDir);
|
|
104
|
+
expect(migrated).toBe(false);
|
|
105
|
+
expect(store2.getAll()).toHaveLength(1);
|
|
106
|
+
expect(store2.getByName("persistent-agent")?.id).toBe("s-persist");
|
|
107
|
+
store2.close();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { createSessionStore, type SessionStore } from "./store.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Open or create a SessionStore at the given .legio directory root.
|
|
6
|
+
*
|
|
7
|
+
* @param legioDir - Path to the .legio directory (e.g., /project/.legio)
|
|
8
|
+
* @returns An object with the SessionStore and whether a migration occurred.
|
|
9
|
+
*/
|
|
10
|
+
export function openSessionStore(legioDir: string): {
|
|
11
|
+
store: SessionStore;
|
|
12
|
+
migrated: boolean;
|
|
13
|
+
} {
|
|
14
|
+
const dbPath = join(legioDir, "sessions.db");
|
|
15
|
+
const store = createSessionStore(dbPath);
|
|
16
|
+
return { store, migrated: false };
|
|
17
|
+
}
|