@hellcoder/companion 0.96.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/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { EventBus } from "./event-bus.js";
|
|
3
|
+
|
|
4
|
+
// Minimal event map for testing
|
|
5
|
+
interface TestEvents {
|
|
6
|
+
"test:foo": { value: number };
|
|
7
|
+
"test:bar": { name: string };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("EventBus", () => {
|
|
11
|
+
let bus: EventBus<TestEvents>;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
bus = new EventBus<TestEvents>();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// ── on / emit ──────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
it("calls handler when event is emitted", () => {
|
|
20
|
+
const handler = vi.fn();
|
|
21
|
+
bus.on("test:foo", handler);
|
|
22
|
+
bus.emit("test:foo", { value: 42 });
|
|
23
|
+
expect(handler).toHaveBeenCalledWith({ value: 42 });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("supports multiple handlers for the same event", () => {
|
|
27
|
+
const h1 = vi.fn();
|
|
28
|
+
const h2 = vi.fn();
|
|
29
|
+
bus.on("test:foo", h1);
|
|
30
|
+
bus.on("test:foo", h2);
|
|
31
|
+
bus.emit("test:foo", { value: 1 });
|
|
32
|
+
expect(h1).toHaveBeenCalledOnce();
|
|
33
|
+
expect(h2).toHaveBeenCalledOnce();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does not call handlers for different events", () => {
|
|
37
|
+
const handler = vi.fn();
|
|
38
|
+
bus.on("test:foo", handler);
|
|
39
|
+
bus.emit("test:bar", { name: "x" });
|
|
40
|
+
expect(handler).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("emit with no handlers is a no-op", () => {
|
|
44
|
+
// Should not throw
|
|
45
|
+
expect(() => bus.emit("test:foo", { value: 0 })).not.toThrow();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ── unsubscribe ────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
it("unsubscribe function returned by on() removes handler", () => {
|
|
51
|
+
const handler = vi.fn();
|
|
52
|
+
const unsub = bus.on("test:foo", handler);
|
|
53
|
+
unsub();
|
|
54
|
+
bus.emit("test:foo", { value: 1 });
|
|
55
|
+
expect(handler).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── off ────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
it("off() removes a specific handler", () => {
|
|
61
|
+
const h1 = vi.fn();
|
|
62
|
+
const h2 = vi.fn();
|
|
63
|
+
bus.on("test:foo", h1);
|
|
64
|
+
bus.on("test:foo", h2);
|
|
65
|
+
bus.off("test:foo", h1);
|
|
66
|
+
bus.emit("test:foo", { value: 1 });
|
|
67
|
+
expect(h1).not.toHaveBeenCalled();
|
|
68
|
+
expect(h2).toHaveBeenCalledOnce();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ── once ───────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
it("once() handler is called on first emit only", () => {
|
|
74
|
+
const handler = vi.fn();
|
|
75
|
+
bus.once("test:foo", handler);
|
|
76
|
+
bus.emit("test:foo", { value: 1 });
|
|
77
|
+
bus.emit("test:foo", { value: 2 });
|
|
78
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
79
|
+
expect(handler).toHaveBeenCalledWith({ value: 1 });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("once() unsubscribe function removes handler before it fires", () => {
|
|
83
|
+
const handler = vi.fn();
|
|
84
|
+
const unsub = bus.once("test:foo", handler);
|
|
85
|
+
unsub();
|
|
86
|
+
bus.emit("test:foo", { value: 1 });
|
|
87
|
+
expect(handler).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── error isolation ────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
it("catches synchronous handler errors without affecting other handlers", () => {
|
|
93
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
94
|
+
const badHandler = () => {
|
|
95
|
+
throw new Error("boom");
|
|
96
|
+
};
|
|
97
|
+
const goodHandler = vi.fn();
|
|
98
|
+
|
|
99
|
+
bus.on("test:foo", badHandler);
|
|
100
|
+
bus.on("test:foo", goodHandler);
|
|
101
|
+
bus.emit("test:foo", { value: 1 });
|
|
102
|
+
|
|
103
|
+
// Good handler still called despite bad handler throwing
|
|
104
|
+
expect(goodHandler).toHaveBeenCalledOnce();
|
|
105
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
106
|
+
expect.stringContaining("[event-bus] Handler error"),
|
|
107
|
+
expect.any(Error),
|
|
108
|
+
);
|
|
109
|
+
errorSpy.mockRestore();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("catches async handler errors", async () => {
|
|
113
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
114
|
+
const asyncHandler = async () => {
|
|
115
|
+
throw new Error("async boom");
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
bus.on("test:foo", asyncHandler);
|
|
119
|
+
bus.emit("test:foo", { value: 1 });
|
|
120
|
+
|
|
121
|
+
// Wait for the microtask that catches the promise rejection
|
|
122
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
123
|
+
|
|
124
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
125
|
+
expect.stringContaining("[event-bus] Async handler error"),
|
|
126
|
+
expect.any(Error),
|
|
127
|
+
);
|
|
128
|
+
errorSpy.mockRestore();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("catches once-handler errors without affecting other once-handlers", () => {
|
|
132
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
133
|
+
const badHandler = () => {
|
|
134
|
+
throw new Error("once boom");
|
|
135
|
+
};
|
|
136
|
+
const goodHandler = vi.fn();
|
|
137
|
+
|
|
138
|
+
bus.once("test:foo", badHandler);
|
|
139
|
+
bus.once("test:foo", goodHandler);
|
|
140
|
+
bus.emit("test:foo", { value: 1 });
|
|
141
|
+
|
|
142
|
+
expect(goodHandler).toHaveBeenCalledOnce();
|
|
143
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
144
|
+
expect.stringContaining("[event-bus] Once-handler error"),
|
|
145
|
+
expect.any(Error),
|
|
146
|
+
);
|
|
147
|
+
errorSpy.mockRestore();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── clear ──────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
it("clear() removes all handlers", () => {
|
|
153
|
+
const h1 = vi.fn();
|
|
154
|
+
const h2 = vi.fn();
|
|
155
|
+
bus.on("test:foo", h1);
|
|
156
|
+
bus.once("test:bar", h2);
|
|
157
|
+
bus.clear();
|
|
158
|
+
bus.emit("test:foo", { value: 1 });
|
|
159
|
+
bus.emit("test:bar", { name: "x" });
|
|
160
|
+
expect(h1).not.toHaveBeenCalled();
|
|
161
|
+
expect(h2).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── listenerCount ──────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
it("listenerCount() returns correct count", () => {
|
|
167
|
+
expect(bus.listenerCount("test:foo")).toBe(0);
|
|
168
|
+
bus.on("test:foo", vi.fn());
|
|
169
|
+
expect(bus.listenerCount("test:foo")).toBe(1);
|
|
170
|
+
bus.once("test:foo", vi.fn());
|
|
171
|
+
expect(bus.listenerCount("test:foo")).toBe(2);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("listenerCount() decreases after unsubscribe", () => {
|
|
175
|
+
const h = vi.fn();
|
|
176
|
+
const unsub = bus.on("test:foo", h);
|
|
177
|
+
expect(bus.listenerCount("test:foo")).toBe(1);
|
|
178
|
+
unsub();
|
|
179
|
+
expect(bus.listenerCount("test:foo")).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("listenerCount() decreases after once handler fires", () => {
|
|
183
|
+
bus.once("test:foo", vi.fn());
|
|
184
|
+
expect(bus.listenerCount("test:foo")).toBe(1);
|
|
185
|
+
bus.emit("test:foo", { value: 1 });
|
|
186
|
+
expect(bus.listenerCount("test:foo")).toBe(0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ── mixed on + once ────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
it("on and once handlers both fire, once removed after", () => {
|
|
192
|
+
const persistent = vi.fn();
|
|
193
|
+
const oneShot = vi.fn();
|
|
194
|
+
bus.on("test:foo", persistent);
|
|
195
|
+
bus.once("test:foo", oneShot);
|
|
196
|
+
|
|
197
|
+
bus.emit("test:foo", { value: 1 });
|
|
198
|
+
expect(persistent).toHaveBeenCalledOnce();
|
|
199
|
+
expect(oneShot).toHaveBeenCalledOnce();
|
|
200
|
+
|
|
201
|
+
bus.emit("test:foo", { value: 2 });
|
|
202
|
+
expect(persistent).toHaveBeenCalledTimes(2);
|
|
203
|
+
expect(oneShot).toHaveBeenCalledOnce(); // still only once
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ── snapshot safety ───────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
it("handlers added during emit are not called in the same dispatch", () => {
|
|
209
|
+
// Verifies that emit() snapshots the handler set before iterating,
|
|
210
|
+
// so a handler that subscribes a new handler during dispatch does not
|
|
211
|
+
// cause the new handler to fire in the same emit cycle.
|
|
212
|
+
const late = vi.fn();
|
|
213
|
+
const adder = () => {
|
|
214
|
+
bus.on("test:foo", late);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
bus.on("test:foo", adder);
|
|
218
|
+
bus.emit("test:foo", { value: 1 });
|
|
219
|
+
// late was added during dispatch but should NOT have been called
|
|
220
|
+
expect(late).not.toHaveBeenCalled();
|
|
221
|
+
|
|
222
|
+
// On the next emit, late should fire
|
|
223
|
+
bus.emit("test:foo", { value: 2 });
|
|
224
|
+
expect(late).toHaveBeenCalledWith({ value: 2 });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("handlers removed during emit still fire in the same dispatch", () => {
|
|
228
|
+
// Verifies snapshot: unsubscribing a handler mid-dispatch does not
|
|
229
|
+
// prevent it from running since the snapshot was taken before iteration.
|
|
230
|
+
const h1 = vi.fn();
|
|
231
|
+
const h2 = vi.fn();
|
|
232
|
+
let unsub2: () => void;
|
|
233
|
+
|
|
234
|
+
const remover = () => {
|
|
235
|
+
unsub2();
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
bus.on("test:foo", remover);
|
|
239
|
+
unsub2 = bus.on("test:foo", h2);
|
|
240
|
+
bus.emit("test:foo", { value: 1 });
|
|
241
|
+
// h2 was in the snapshot, so it still fires despite being removed mid-dispatch
|
|
242
|
+
expect(h2).toHaveBeenCalledWith({ value: 1 });
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Zero-dependency, strongly-typed internal event bus for the Companion server.
|
|
2
|
+
|
|
3
|
+
import type { CompanionEventMap } from "./event-bus-types.js";
|
|
4
|
+
|
|
5
|
+
type EventHandler<T> = (payload: T) => void | Promise<void>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generic typed event bus. Handlers are invoked synchronously; async handlers
|
|
9
|
+
* are fire-and-forget. Errors in handlers are caught and logged, never
|
|
10
|
+
* propagated to emitters.
|
|
11
|
+
*/
|
|
12
|
+
export class EventBus<
|
|
13
|
+
TMap extends Record<string, any> = CompanionEventMap,
|
|
14
|
+
> {
|
|
15
|
+
private handlers = new Map<keyof TMap, Set<EventHandler<any>>>();
|
|
16
|
+
private onceHandlers = new Map<keyof TMap, Set<EventHandler<any>>>();
|
|
17
|
+
|
|
18
|
+
/** Subscribe to an event. Returns an unsubscribe function. */
|
|
19
|
+
on<K extends keyof TMap>(
|
|
20
|
+
event: K,
|
|
21
|
+
handler: EventHandler<TMap[K]>,
|
|
22
|
+
): () => void {
|
|
23
|
+
if (!this.handlers.has(event)) {
|
|
24
|
+
this.handlers.set(event, new Set());
|
|
25
|
+
}
|
|
26
|
+
this.handlers.get(event)!.add(handler);
|
|
27
|
+
return () => {
|
|
28
|
+
this.handlers.get(event)?.delete(handler);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Subscribe to an event; auto-unsubscribe after the first invocation. */
|
|
33
|
+
once<K extends keyof TMap>(
|
|
34
|
+
event: K,
|
|
35
|
+
handler: EventHandler<TMap[K]>,
|
|
36
|
+
): () => void {
|
|
37
|
+
if (!this.onceHandlers.has(event)) {
|
|
38
|
+
this.onceHandlers.set(event, new Set());
|
|
39
|
+
}
|
|
40
|
+
this.onceHandlers.get(event)!.add(handler);
|
|
41
|
+
return () => {
|
|
42
|
+
this.onceHandlers.get(event)?.delete(handler);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Remove a specific handler for an event. */
|
|
47
|
+
off<K extends keyof TMap>(
|
|
48
|
+
event: K,
|
|
49
|
+
handler: EventHandler<TMap[K]>,
|
|
50
|
+
): void {
|
|
51
|
+
this.handlers.get(event)?.delete(handler);
|
|
52
|
+
this.onceHandlers.get(event)?.delete(handler);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Emit an event to all subscribed handlers.
|
|
57
|
+
* Errors are caught and logged — never propagated to the emitter.
|
|
58
|
+
*/
|
|
59
|
+
emit<K extends keyof TMap>(event: K, payload: TMap[K]): void {
|
|
60
|
+
const regular = this.handlers.get(event);
|
|
61
|
+
if (regular && regular.size > 0) {
|
|
62
|
+
const snapshot = [...regular];
|
|
63
|
+
for (const handler of snapshot) {
|
|
64
|
+
try {
|
|
65
|
+
const result = handler(payload);
|
|
66
|
+
if (result && typeof (result as Promise<void>).catch === "function") {
|
|
67
|
+
(result as Promise<void>).catch((err) => {
|
|
68
|
+
console.error(
|
|
69
|
+
`[event-bus] Async handler error for "${String(event)}":`,
|
|
70
|
+
err,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error(
|
|
76
|
+
`[event-bus] Handler error for "${String(event)}":`,
|
|
77
|
+
err,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const onces = this.onceHandlers.get(event);
|
|
84
|
+
if (onces && onces.size > 0) {
|
|
85
|
+
const snapshot = [...onces];
|
|
86
|
+
onces.clear();
|
|
87
|
+
for (const handler of snapshot) {
|
|
88
|
+
try {
|
|
89
|
+
const result = handler(payload);
|
|
90
|
+
if (result && typeof (result as Promise<void>).catch === "function") {
|
|
91
|
+
(result as Promise<void>).catch((err) => {
|
|
92
|
+
console.error(
|
|
93
|
+
`[event-bus] Async once-handler error for "${String(event)}":`,
|
|
94
|
+
err,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.error(
|
|
100
|
+
`[event-bus] Once-handler error for "${String(event)}":`,
|
|
101
|
+
err,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Remove all handlers (useful for testing or shutdown). */
|
|
109
|
+
clear(): void {
|
|
110
|
+
this.handlers.clear();
|
|
111
|
+
this.onceHandlers.clear();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Return the number of handlers registered for an event. */
|
|
115
|
+
listenerCount<K extends keyof TMap>(event: K): number {
|
|
116
|
+
return (
|
|
117
|
+
(this.handlers.get(event)?.size ?? 0) +
|
|
118
|
+
(this.onceHandlers.get(event)?.size ?? 0)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Singleton bus instance for the Companion server. */
|
|
124
|
+
export const companionBus = new EventBus<CompanionEventMap>();
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { ExecutionStore } from "./execution-store.js";
|
|
6
|
+
import type { AgentExecution } from "./agent-types.js";
|
|
7
|
+
|
|
8
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
let testDir: string;
|
|
11
|
+
|
|
12
|
+
/** Create a test execution fixture with sensible defaults. */
|
|
13
|
+
function makeExecution(overrides: Partial<AgentExecution> = {}): AgentExecution {
|
|
14
|
+
return {
|
|
15
|
+
sessionId: `sess-${Math.random().toString(36).slice(2, 8)}`,
|
|
16
|
+
agentId: "agent-1",
|
|
17
|
+
triggerType: "manual",
|
|
18
|
+
startedAt: Date.now(),
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
// Create a unique temp directory for each test
|
|
25
|
+
testDir = join(tmpdir(), `execution-store-test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
// Clean up temp directory after each test
|
|
30
|
+
if (existsSync(testDir)) {
|
|
31
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
describe("ExecutionStore", () => {
|
|
38
|
+
it("creates the storage directory on construction", () => {
|
|
39
|
+
// The constructor should create the directory if it doesn't exist
|
|
40
|
+
const store = new ExecutionStore(testDir);
|
|
41
|
+
expect(existsSync(testDir)).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("appends an execution to a daily JSONL file", () => {
|
|
45
|
+
const store = new ExecutionStore(testDir);
|
|
46
|
+
const exec = makeExecution({ startedAt: new Date("2026-03-04T12:00:00Z").getTime() });
|
|
47
|
+
|
|
48
|
+
store.append(exec);
|
|
49
|
+
|
|
50
|
+
// Verify the file exists and contains the execution
|
|
51
|
+
const files = readdirSync(testDir);
|
|
52
|
+
expect(files).toContain("executions-2026-03-04.jsonl");
|
|
53
|
+
|
|
54
|
+
const content = readFileSync(join(testDir, "executions-2026-03-04.jsonl"), "utf-8");
|
|
55
|
+
const lines = content.trim().split("\n");
|
|
56
|
+
expect(lines).toHaveLength(1);
|
|
57
|
+
expect(JSON.parse(lines[0]).sessionId).toBe(exec.sessionId);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("groups executions into daily files based on startedAt timestamp", () => {
|
|
61
|
+
const store = new ExecutionStore(testDir);
|
|
62
|
+
|
|
63
|
+
// Two executions on different days
|
|
64
|
+
store.append(makeExecution({ startedAt: new Date("2026-03-01T10:00:00Z").getTime() }));
|
|
65
|
+
store.append(makeExecution({ startedAt: new Date("2026-03-02T10:00:00Z").getTime() }));
|
|
66
|
+
|
|
67
|
+
const files = readdirSync(testDir).sort();
|
|
68
|
+
expect(files).toEqual(["executions-2026-03-01.jsonl", "executions-2026-03-02.jsonl"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("appends multiple executions to the same daily file", () => {
|
|
72
|
+
const store = new ExecutionStore(testDir);
|
|
73
|
+
const day = new Date("2026-03-04T00:00:00Z").getTime();
|
|
74
|
+
|
|
75
|
+
store.append(makeExecution({ startedAt: day + 1000 }));
|
|
76
|
+
store.append(makeExecution({ startedAt: day + 2000 }));
|
|
77
|
+
store.append(makeExecution({ startedAt: day + 3000 }));
|
|
78
|
+
|
|
79
|
+
const content = readFileSync(join(testDir, "executions-2026-03-04.jsonl"), "utf-8");
|
|
80
|
+
const lines = content.trim().split("\n");
|
|
81
|
+
expect(lines).toHaveLength(3);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("list()", () => {
|
|
85
|
+
it("returns all executions sorted by startedAt descending", () => {
|
|
86
|
+
const store = new ExecutionStore(testDir);
|
|
87
|
+
const exec1 = makeExecution({ startedAt: 1000, agentId: "a1" });
|
|
88
|
+
const exec2 = makeExecution({ startedAt: 3000, agentId: "a2" });
|
|
89
|
+
const exec3 = makeExecution({ startedAt: 2000, agentId: "a3" });
|
|
90
|
+
|
|
91
|
+
store.append(exec1);
|
|
92
|
+
store.append(exec2);
|
|
93
|
+
store.append(exec3);
|
|
94
|
+
|
|
95
|
+
const result = store.list();
|
|
96
|
+
// Most recent first
|
|
97
|
+
expect(result.executions.map((e) => e.agentId)).toEqual(["a2", "a3", "a1"]);
|
|
98
|
+
expect(result.total).toBe(3);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("filters by agentId", () => {
|
|
102
|
+
const store = new ExecutionStore(testDir);
|
|
103
|
+
store.append(makeExecution({ agentId: "agent-a", startedAt: 1000 }));
|
|
104
|
+
store.append(makeExecution({ agentId: "agent-b", startedAt: 2000 }));
|
|
105
|
+
store.append(makeExecution({ agentId: "agent-a", startedAt: 3000 }));
|
|
106
|
+
|
|
107
|
+
const result = store.list({ agentId: "agent-a" });
|
|
108
|
+
expect(result.executions).toHaveLength(2);
|
|
109
|
+
expect(result.total).toBe(2);
|
|
110
|
+
expect(result.executions.every((e) => e.agentId === "agent-a")).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("filters by triggerType", () => {
|
|
114
|
+
const store = new ExecutionStore(testDir);
|
|
115
|
+
store.append(makeExecution({ triggerType: "manual", startedAt: 1000 }));
|
|
116
|
+
store.append(makeExecution({ triggerType: "webhook", startedAt: 2000 }));
|
|
117
|
+
store.append(makeExecution({ triggerType: "linear", startedAt: 3000 }));
|
|
118
|
+
|
|
119
|
+
const result = store.list({ triggerType: "linear" });
|
|
120
|
+
expect(result.executions).toHaveLength(1);
|
|
121
|
+
expect(result.executions[0].triggerType).toBe("linear");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("filters by status: running (no completedAt)", () => {
|
|
125
|
+
const store = new ExecutionStore(testDir);
|
|
126
|
+
store.append(makeExecution({ startedAt: 1000 })); // running — no completedAt
|
|
127
|
+
store.append(makeExecution({ startedAt: 2000, completedAt: 3000, success: true }));
|
|
128
|
+
|
|
129
|
+
const result = store.list({ status: "running" });
|
|
130
|
+
expect(result.executions).toHaveLength(1);
|
|
131
|
+
expect(result.executions[0].completedAt).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("filters by status: success", () => {
|
|
135
|
+
const store = new ExecutionStore(testDir);
|
|
136
|
+
store.append(makeExecution({ startedAt: 1000, success: true }));
|
|
137
|
+
store.append(makeExecution({ startedAt: 2000, error: "fail" }));
|
|
138
|
+
|
|
139
|
+
const result = store.list({ status: "success" });
|
|
140
|
+
expect(result.executions).toHaveLength(1);
|
|
141
|
+
expect(result.executions[0].success).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("filters by status: error", () => {
|
|
145
|
+
const store = new ExecutionStore(testDir);
|
|
146
|
+
store.append(makeExecution({ startedAt: 1000, success: true }));
|
|
147
|
+
store.append(makeExecution({ startedAt: 2000, error: "something broke" }));
|
|
148
|
+
|
|
149
|
+
const result = store.list({ status: "error" });
|
|
150
|
+
expect(result.executions).toHaveLength(1);
|
|
151
|
+
expect(result.executions[0].error).toBe("something broke");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("paginates results with limit and offset", () => {
|
|
155
|
+
const store = new ExecutionStore(testDir);
|
|
156
|
+
for (let i = 0; i < 10; i++) {
|
|
157
|
+
store.append(makeExecution({ startedAt: i * 1000 }));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const page1 = store.list({ limit: 3, offset: 0 });
|
|
161
|
+
expect(page1.executions).toHaveLength(3);
|
|
162
|
+
expect(page1.total).toBe(10);
|
|
163
|
+
|
|
164
|
+
const page2 = store.list({ limit: 3, offset: 3 });
|
|
165
|
+
expect(page2.executions).toHaveLength(3);
|
|
166
|
+
|
|
167
|
+
// No overlap between pages
|
|
168
|
+
const ids1 = page1.executions.map((e) => e.sessionId);
|
|
169
|
+
const ids2 = page2.executions.map((e) => e.sessionId);
|
|
170
|
+
expect(ids1.some((id) => ids2.includes(id))).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("defaults to limit=50 and offset=0", () => {
|
|
174
|
+
const store = new ExecutionStore(testDir);
|
|
175
|
+
for (let i = 0; i < 60; i++) {
|
|
176
|
+
store.append(makeExecution({ startedAt: i }));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const result = store.list();
|
|
180
|
+
expect(result.executions).toHaveLength(50);
|
|
181
|
+
expect(result.total).toBe(60);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("update()", () => {
|
|
186
|
+
it("updates a cached execution by sessionId", () => {
|
|
187
|
+
const store = new ExecutionStore(testDir);
|
|
188
|
+
const exec = makeExecution({ sessionId: "update-me", startedAt: 1000 });
|
|
189
|
+
store.append(exec);
|
|
190
|
+
|
|
191
|
+
store.update("update-me", { completedAt: 5000, success: true });
|
|
192
|
+
|
|
193
|
+
const result = store.list();
|
|
194
|
+
const updated = result.executions.find((e) => e.sessionId === "update-me");
|
|
195
|
+
expect(updated?.completedAt).toBe(5000);
|
|
196
|
+
expect(updated?.success).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("does nothing if sessionId is not in cache", () => {
|
|
200
|
+
const store = new ExecutionStore(testDir);
|
|
201
|
+
store.append(makeExecution({ sessionId: "exists", startedAt: 1000 }));
|
|
202
|
+
|
|
203
|
+
// Should not throw
|
|
204
|
+
store.update("does-not-exist", { completedAt: 5000 });
|
|
205
|
+
|
|
206
|
+
const result = store.list();
|
|
207
|
+
expect(result.executions).toHaveLength(1);
|
|
208
|
+
expect(result.executions[0].sessionId).toBe("exists");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("persists updates to disk as a new JSONL line", () => {
|
|
212
|
+
// When update() is called, it should append the updated record to the daily file.
|
|
213
|
+
// This ensures updates survive server restarts.
|
|
214
|
+
const store = new ExecutionStore(testDir);
|
|
215
|
+
const exec = makeExecution({
|
|
216
|
+
sessionId: "persist-update",
|
|
217
|
+
startedAt: new Date("2026-03-04T12:00:00Z").getTime(),
|
|
218
|
+
});
|
|
219
|
+
store.append(exec);
|
|
220
|
+
|
|
221
|
+
store.update("persist-update", { completedAt: Date.now(), success: true });
|
|
222
|
+
|
|
223
|
+
// The daily file should now have 2 lines: original + updated
|
|
224
|
+
const content = readFileSync(join(testDir, "executions-2026-03-04.jsonl"), "utf-8");
|
|
225
|
+
const lines = content.trim().split("\n");
|
|
226
|
+
expect(lines).toHaveLength(2);
|
|
227
|
+
|
|
228
|
+
// The second line should have the updated fields
|
|
229
|
+
const updatedRecord = JSON.parse(lines[1]);
|
|
230
|
+
expect(updatedRecord.sessionId).toBe("persist-update");
|
|
231
|
+
expect(updatedRecord.success).toBe(true);
|
|
232
|
+
expect(updatedRecord.completedAt).toBeDefined();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("deduplicates by sessionId when reloading from disk", () => {
|
|
236
|
+
// After update() appends a second line with the same sessionId,
|
|
237
|
+
// a newly constructed store should dedup and only load the latest version.
|
|
238
|
+
const store = new ExecutionStore(testDir);
|
|
239
|
+
const exec = makeExecution({
|
|
240
|
+
sessionId: "dedup-test",
|
|
241
|
+
startedAt: new Date("2026-03-04T12:00:00Z").getTime(),
|
|
242
|
+
});
|
|
243
|
+
store.append(exec);
|
|
244
|
+
store.update("dedup-test", { completedAt: 99999, success: true });
|
|
245
|
+
|
|
246
|
+
// Create a new store that reloads from disk
|
|
247
|
+
const store2 = new ExecutionStore(testDir);
|
|
248
|
+
const result = store2.list();
|
|
249
|
+
|
|
250
|
+
// Should have exactly 1 execution (deduped), not 2
|
|
251
|
+
const matching = result.executions.filter((e) => e.sessionId === "dedup-test");
|
|
252
|
+
expect(matching).toHaveLength(1);
|
|
253
|
+
// Should have the updated fields from the most recent line
|
|
254
|
+
expect(matching[0].completedAt).toBe(99999);
|
|
255
|
+
expect(matching[0].success).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("disk persistence and reload", () => {
|
|
260
|
+
it("loads existing executions from disk on construction", () => {
|
|
261
|
+
// Seed the directory with a JSONL file
|
|
262
|
+
mkdirSync(testDir, { recursive: true });
|
|
263
|
+
const exec = makeExecution({ startedAt: 1234567890 });
|
|
264
|
+
writeFileSync(
|
|
265
|
+
join(testDir, "executions-2009-02-13.jsonl"),
|
|
266
|
+
JSON.stringify(exec) + "\n",
|
|
267
|
+
"utf-8",
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// Create a new store — it should load the seeded data
|
|
271
|
+
const store = new ExecutionStore(testDir);
|
|
272
|
+
const result = store.list();
|
|
273
|
+
expect(result.executions).toHaveLength(1);
|
|
274
|
+
expect(result.executions[0].sessionId).toBe(exec.sessionId);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("skips malformed JSONL lines without crashing", () => {
|
|
278
|
+
mkdirSync(testDir, { recursive: true });
|
|
279
|
+
const exec = makeExecution({ startedAt: 1000 });
|
|
280
|
+
const content = `not valid json\n${JSON.stringify(exec)}\nalso not json\n`;
|
|
281
|
+
writeFileSync(join(testDir, "executions-2026-01-01.jsonl"), content, "utf-8");
|
|
282
|
+
|
|
283
|
+
const store = new ExecutionStore(testDir);
|
|
284
|
+
const result = store.list();
|
|
285
|
+
// Only the valid line should be loaded
|
|
286
|
+
expect(result.executions).toHaveLength(1);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("limits in-memory cache to MAX_CACHE_SIZE", () => {
|
|
290
|
+
const store = new ExecutionStore(testDir);
|
|
291
|
+
|
|
292
|
+
// Append more than 200 executions
|
|
293
|
+
for (let i = 0; i < 210; i++) {
|
|
294
|
+
store.append(makeExecution({ startedAt: i }));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The cache should be capped at 200
|
|
298
|
+
const result = store.list({ limit: 300 });
|
|
299
|
+
expect(result.total).toBeLessThanOrEqual(200);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("exposes the storage directory path", () => {
|
|
304
|
+
const store = new ExecutionStore(testDir);
|
|
305
|
+
expect(store.directory).toBe(testDir);
|
|
306
|
+
});
|
|
307
|
+
});
|