@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.
Files changed (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,417 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { Hono } from "hono";
3
+ import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { randomBytes } from "node:crypto";
7
+
8
+ // ─── Test setup ──────────────────────────────────────────────────────────────
9
+
10
+ // Static suffix generated at module parse time so vi.mock factory can reference it
11
+ const TEST_SUFFIX = randomBytes(4).toString("hex");
12
+ const TEST_HOME = join(tmpdir(), `hub-routes-test-${TEST_SUFFIX}`);
13
+ const RECORDINGS_DIR = join(TEST_HOME, "auto-recordings");
14
+
15
+ // Mock COMPANION_HOME so HubStore writes to our temp directory
16
+ // vi.mock is hoisted, so we use the tmpdir + suffix pattern inline
17
+ vi.mock("../paths.js", () => {
18
+ const { tmpdir: td } = require("node:os");
19
+ const { join: jn } = require("node:path");
20
+ // Read the suffix from the env var we set before import
21
+ return { COMPANION_HOME: jn(td(), `hub-routes-test-${process.env.__HUB_ROUTES_TEST_SUFFIX}`) };
22
+ });
23
+
24
+ // Set the suffix in env before mock resolution
25
+ process.env.__HUB_ROUTES_TEST_SUFFIX = TEST_SUFFIX;
26
+
27
+ const { registerHubRoutes } = await import("./hub-routes.js");
28
+
29
+ // Mock WsBridge with minimal interface
30
+ function makeMockBridge() {
31
+ return {
32
+ attachBackendAdapter: vi.fn(),
33
+ getOrCreateSession: vi.fn(),
34
+ } as any;
35
+ }
36
+
37
+ function makeValidRecording(sessionId = "test-session"): string {
38
+ const header = JSON.stringify({
39
+ _header: true,
40
+ version: 1,
41
+ session_id: sessionId,
42
+ backend_type: "claude",
43
+ started_at: 1000000,
44
+ cwd: "/test/dir",
45
+ });
46
+ const entries = [
47
+ JSON.stringify({ ts: 1000000, dir: "out", raw: JSON.stringify({ type: "session_init", session: {} }), ch: "browser" }),
48
+ JSON.stringify({ ts: 1001000, dir: "out", raw: JSON.stringify({ type: "assistant", text: "Hello" }), ch: "browser" }),
49
+ JSON.stringify({ ts: 1002000, dir: "out", raw: JSON.stringify({ type: "result", subtype: "success" }), ch: "browser" }),
50
+ ];
51
+ return [header, ...entries].join("\n");
52
+ }
53
+
54
+ // ─── Tests ───────────────────────────────────────────────────────────────────
55
+
56
+ describe("hub-routes", () => {
57
+ let app: Hono;
58
+ let bridge: ReturnType<typeof makeMockBridge>;
59
+
60
+ beforeEach(() => {
61
+ if (existsSync(TEST_HOME)) rmSync(TEST_HOME, { recursive: true });
62
+ mkdirSync(join(TEST_HOME, "hub", "recordings"), { recursive: true });
63
+ mkdirSync(RECORDINGS_DIR, { recursive: true });
64
+
65
+ bridge = makeMockBridge();
66
+ app = new Hono();
67
+ registerHubRoutes(app, { wsBridge: bridge, recordingsDir: RECORDINGS_DIR });
68
+ });
69
+
70
+ afterEach(() => {
71
+ if (existsSync(TEST_HOME)) rmSync(TEST_HOME, { recursive: true });
72
+ });
73
+
74
+ // ── Recording CRUD ──────────────────────────────────────────────────
75
+
76
+ describe("GET /hub/recordings", () => {
77
+ it("returns empty list initially", async () => {
78
+ const res = await app.request("/hub/recordings");
79
+ expect(res.status).toBe(200);
80
+ expect(await res.json()).toEqual([]);
81
+ });
82
+ });
83
+
84
+ describe("POST /hub/recordings/upload", () => {
85
+ it("uploads valid JSONL content as plain text", async () => {
86
+ const content = makeValidRecording();
87
+ const res = await app.request("/hub/recordings/upload", {
88
+ method: "POST",
89
+ body: content,
90
+ headers: { "content-type": "text/plain" },
91
+ });
92
+ expect(res.status).toBe(201);
93
+ const meta = await res.json();
94
+ expect(meta.id).toBeTruthy();
95
+ expect(meta.sessionId).toBe("test-session");
96
+ });
97
+
98
+ it("rejects invalid content with 400", async () => {
99
+ const res = await app.request("/hub/recordings/upload", {
100
+ method: "POST",
101
+ body: "not valid jsonl",
102
+ headers: { "content-type": "text/plain" },
103
+ });
104
+ expect(res.status).toBe(400);
105
+ });
106
+ });
107
+
108
+ describe("GET /hub/recordings/:id", () => {
109
+ it("returns 404 for unknown id", async () => {
110
+ const res = await app.request("/hub/recordings/nonexistent");
111
+ expect(res.status).toBe(404);
112
+ });
113
+
114
+ it("returns meta for existing recording", async () => {
115
+ // Upload first
116
+ const uploadRes = await app.request("/hub/recordings/upload", {
117
+ method: "POST",
118
+ body: makeValidRecording(),
119
+ headers: { "content-type": "text/plain" },
120
+ });
121
+ const { id } = await uploadRes.json();
122
+
123
+ const res = await app.request(`/hub/recordings/${id}`);
124
+ expect(res.status).toBe(200);
125
+ const meta = await res.json();
126
+ expect(meta.id).toBe(id);
127
+ });
128
+ });
129
+
130
+ describe("GET /hub/recordings/:id/summary", () => {
131
+ it("returns 404 for unknown id", async () => {
132
+ const res = await app.request("/hub/recordings/nonexistent/summary");
133
+ expect(res.status).toBe(404);
134
+ });
135
+
136
+ it("returns summary for existing recording", async () => {
137
+ const uploadRes = await app.request("/hub/recordings/upload", {
138
+ method: "POST",
139
+ body: makeValidRecording(),
140
+ headers: { "content-type": "text/plain" },
141
+ });
142
+ const { id } = await uploadRes.json();
143
+
144
+ const res = await app.request(`/hub/recordings/${id}/summary`);
145
+ expect(res.status).toBe(200);
146
+ const summary = await res.json();
147
+ expect(summary.entryCount).toBe(3);
148
+ });
149
+ });
150
+
151
+ describe("DELETE /hub/recordings/:id", () => {
152
+ it("returns 404 for unknown id", async () => {
153
+ const res = await app.request("/hub/recordings/nonexistent", { method: "DELETE" });
154
+ expect(res.status).toBe(404);
155
+ });
156
+
157
+ it("deletes existing recording", async () => {
158
+ const uploadRes = await app.request("/hub/recordings/upload", {
159
+ method: "POST",
160
+ body: makeValidRecording(),
161
+ headers: { "content-type": "text/plain" },
162
+ });
163
+ const { id } = await uploadRes.json();
164
+
165
+ const deleteRes = await app.request(`/hub/recordings/${id}`, { method: "DELETE" });
166
+ expect(deleteRes.status).toBe(200);
167
+ expect(await deleteRes.json()).toEqual({ ok: true });
168
+
169
+ // Verify it's gone
170
+ const getRes = await app.request(`/hub/recordings/${id}`);
171
+ expect(getRes.status).toBe(404);
172
+ });
173
+ });
174
+
175
+ // ── Import local ────────────────────────────────────────────────────
176
+
177
+ describe("POST /hub/recordings/import-local", () => {
178
+ it("imports a local recording file", async () => {
179
+ const filename = "test-recording.jsonl";
180
+ writeFileSync(join(RECORDINGS_DIR, filename), makeValidRecording());
181
+
182
+ const res = await app.request("/hub/recordings/import-local", {
183
+ method: "POST",
184
+ body: JSON.stringify({ filename }),
185
+ headers: { "content-type": "application/json" },
186
+ });
187
+ expect(res.status).toBe(201);
188
+ const meta = await res.json();
189
+ expect(meta.sessionId).toBe("test-session");
190
+ });
191
+
192
+ it("returns 400 for missing filename", async () => {
193
+ const res = await app.request("/hub/recordings/import-local", {
194
+ method: "POST",
195
+ body: JSON.stringify({}),
196
+ headers: { "content-type": "application/json" },
197
+ });
198
+ expect(res.status).toBe(400);
199
+ });
200
+
201
+ it("returns 404 for non-existent file", async () => {
202
+ const res = await app.request("/hub/recordings/import-local", {
203
+ method: "POST",
204
+ body: JSON.stringify({ filename: "does-not-exist.jsonl" }),
205
+ headers: { "content-type": "application/json" },
206
+ });
207
+ expect(res.status).toBe(404);
208
+ });
209
+
210
+ it("rejects path traversal attempts", async () => {
211
+ const res = await app.request("/hub/recordings/import-local", {
212
+ method: "POST",
213
+ body: JSON.stringify({ filename: "../../etc/passwd" }),
214
+ headers: { "content-type": "application/json" },
215
+ });
216
+ expect(res.status).toBe(400);
217
+ const body = await res.json();
218
+ expect(body.error).toBe("Invalid filename");
219
+ });
220
+ });
221
+
222
+ // ── Tags ────────────────────────────────────────────────────────────
223
+
224
+ describe("PUT /hub/recordings/:id/tags", () => {
225
+ it("updates tags", async () => {
226
+ const uploadRes = await app.request("/hub/recordings/upload", {
227
+ method: "POST",
228
+ body: makeValidRecording(),
229
+ headers: { "content-type": "text/plain" },
230
+ });
231
+ const { id } = await uploadRes.json();
232
+
233
+ const res = await app.request(`/hub/recordings/${id}/tags`, {
234
+ method: "PUT",
235
+ body: JSON.stringify({ tags: ["regression", "v2"] }),
236
+ headers: { "content-type": "application/json" },
237
+ });
238
+ expect(res.status).toBe(200);
239
+ const meta = await res.json();
240
+ expect(meta.tags).toEqual(["regression", "v2"]);
241
+ });
242
+
243
+ it("returns 400 for missing tags array", async () => {
244
+ const res = await app.request("/hub/recordings/some-id/tags", {
245
+ method: "PUT",
246
+ body: JSON.stringify({}),
247
+ headers: { "content-type": "application/json" },
248
+ });
249
+ expect(res.status).toBe(400);
250
+ });
251
+
252
+ it("returns 404 for unknown id", async () => {
253
+ const res = await app.request("/hub/recordings/nonexistent/tags", {
254
+ method: "PUT",
255
+ body: JSON.stringify({ tags: ["test"] }),
256
+ headers: { "content-type": "application/json" },
257
+ });
258
+ expect(res.status).toBe(404);
259
+ });
260
+ });
261
+
262
+ // ── Replay ──────────────────────────────────────────────────────────
263
+
264
+ describe("POST /hub/replay", () => {
265
+ it("returns 400 for missing recordingId", async () => {
266
+ const res = await app.request("/hub/replay", {
267
+ method: "POST",
268
+ body: JSON.stringify({}),
269
+ headers: { "content-type": "application/json" },
270
+ });
271
+ expect(res.status).toBe(400);
272
+ });
273
+
274
+ it("returns 404 for non-existent recording", async () => {
275
+ const res = await app.request("/hub/replay", {
276
+ method: "POST",
277
+ body: JSON.stringify({ recordingId: "nonexistent" }),
278
+ headers: { "content-type": "application/json" },
279
+ });
280
+ expect(res.status).toBe(404);
281
+ });
282
+
283
+ it("returns 400 for invalid speed", async () => {
284
+ const res = await app.request("/hub/replay", {
285
+ method: "POST",
286
+ body: JSON.stringify({ recordingId: "x", speed: -1 }),
287
+ headers: { "content-type": "application/json" },
288
+ });
289
+ expect(res.status).toBe(400);
290
+ expect((await res.json()).error).toBe("Invalid 'speed' value");
291
+ });
292
+
293
+ it("creates a replay session", async () => {
294
+ // Upload a recording first
295
+ const uploadRes = await app.request("/hub/recordings/upload", {
296
+ method: "POST",
297
+ body: makeValidRecording(),
298
+ headers: { "content-type": "text/plain" },
299
+ });
300
+ const { id } = await uploadRes.json();
301
+
302
+ const res = await app.request("/hub/replay", {
303
+ method: "POST",
304
+ body: JSON.stringify({ recordingId: id, speed: 2 }),
305
+ headers: { "content-type": "application/json" },
306
+ });
307
+ expect(res.status).toBe(201);
308
+ const body = await res.json();
309
+ expect(body.sessionId).toMatch(/^replay-/);
310
+ expect(body.speed).toBe(2);
311
+ expect(body.backendType).toBe("claude");
312
+ expect(bridge.attachBackendAdapter).toHaveBeenCalledOnce();
313
+ });
314
+ });
315
+
316
+ describe("replay control endpoints", () => {
317
+ it("returns 404 for unknown replay sessions", async () => {
318
+ const pauseRes = await app.request("/hub/replay/nonexistent/pause", { method: "POST" });
319
+ expect(pauseRes.status).toBe(404);
320
+
321
+ const resumeRes = await app.request("/hub/replay/nonexistent/resume", { method: "POST" });
322
+ expect(resumeRes.status).toBe(404);
323
+
324
+ const speedRes = await app.request("/hub/replay/nonexistent/speed", {
325
+ method: "POST",
326
+ body: JSON.stringify({ speed: 2 }),
327
+ headers: { "content-type": "application/json" },
328
+ });
329
+ expect(speedRes.status).toBe(404);
330
+
331
+ const progressRes = await app.request("/hub/replay/nonexistent/progress");
332
+ expect(progressRes.status).toBe(404);
333
+ });
334
+ });
335
+
336
+ describe("POST /hub/replay/:sessionId/speed", () => {
337
+ it("returns 400 for invalid speed", async () => {
338
+ // We can't easily test with a real replay session here since the adapter
339
+ // is not in the map, but we can verify the 404 for unknown session.
340
+ const res = await app.request("/hub/replay/nonexistent/speed", {
341
+ method: "POST",
342
+ body: JSON.stringify({ speed: 0 }),
343
+ headers: { "content-type": "application/json" },
344
+ });
345
+ // 404 because session doesn't exist (speed validation would be 400 but session check comes first)
346
+ expect(res.status).toBe(404);
347
+ });
348
+ });
349
+
350
+ // ── Validation ──────────────────────────────────────────────────────
351
+
352
+ describe("POST /hub/recordings/:id/validate", () => {
353
+ it("returns 404 for unknown id", async () => {
354
+ const res = await app.request("/hub/recordings/nonexistent/validate", { method: "POST" });
355
+ expect(res.status).toBe(404);
356
+ });
357
+
358
+ it("validates an existing recording", async () => {
359
+ const uploadRes = await app.request("/hub/recordings/upload", {
360
+ method: "POST",
361
+ body: makeValidRecording(),
362
+ headers: { "content-type": "text/plain" },
363
+ });
364
+ const { id } = await uploadRes.json();
365
+
366
+ const res = await app.request(`/hub/recordings/${id}/validate`, { method: "POST" });
367
+ expect(res.status).toBe(200);
368
+ const result = await res.json();
369
+ expect(result.compatible).toBe(true);
370
+ expect(result.backendType).toBe("claude");
371
+ });
372
+ });
373
+
374
+ // ── Diagnostics ─────────────────────────────────────────────────────
375
+
376
+ describe("GET /hub/recordings/:id/diagnostics", () => {
377
+ it("returns 404 for unknown id", async () => {
378
+ const res = await app.request("/hub/recordings/nonexistent/diagnostics");
379
+ expect(res.status).toBe(404);
380
+ });
381
+
382
+ it("returns diagnostics for existing recording", async () => {
383
+ const uploadRes = await app.request("/hub/recordings/upload", {
384
+ method: "POST",
385
+ body: makeValidRecording(),
386
+ headers: { "content-type": "text/plain" },
387
+ });
388
+ const { id } = await uploadRes.json();
389
+
390
+ const res = await app.request(`/hub/recordings/${id}/diagnostics`);
391
+ expect(res.status).toBe(200);
392
+ const report = await res.json();
393
+ expect(report.sessionId).toBe("test-session");
394
+ });
395
+ });
396
+
397
+ describe("GET /hub/recordings/:id/timeline", () => {
398
+ it("returns 404 for unknown id", async () => {
399
+ const res = await app.request("/hub/recordings/nonexistent/timeline");
400
+ expect(res.status).toBe(404);
401
+ });
402
+
403
+ it("returns timeline for existing recording", async () => {
404
+ const uploadRes = await app.request("/hub/recordings/upload", {
405
+ method: "POST",
406
+ body: makeValidRecording(),
407
+ headers: { "content-type": "text/plain" },
408
+ });
409
+ const { id } = await uploadRes.json();
410
+
411
+ const res = await app.request(`/hub/recordings/${id}/timeline`);
412
+ expect(res.status).toBe(200);
413
+ const timeline = await res.json();
414
+ expect(Array.isArray(timeline)).toBe(true);
415
+ });
416
+ });
417
+ });
@@ -0,0 +1,236 @@
1
+ /**
2
+ * REST API routes for the Recording Hub.
3
+ *
4
+ * All routes are under /api/hub/ and only registered when COMPANION_RECORDING_HUB=1.
5
+ */
6
+
7
+ import type { Hono } from "hono";
8
+ import { join, resolve, sep } from "node:path";
9
+ import { existsSync } from "node:fs";
10
+ import { HubStore } from "./hub-store.js";
11
+ import type { ReplayAdapter } from "./replay-adapter.js";
12
+ import type { WsBridge } from "../ws-bridge.js";
13
+
14
+ // ─── Types ───────────────────────────────────────────────────────────────────
15
+
16
+ interface HubRoutesOptions {
17
+ wsBridge: WsBridge;
18
+ recordingsDir: string; // Auto-recording directory for import-local
19
+ }
20
+
21
+ // ─── Route Registration ─────────────────────────────────────────────────────
22
+
23
+ export function registerHubRoutes(api: Hono, options: HubRoutesOptions): void {
24
+ const store = new HubStore();
25
+ const replayAdapters = new Map<string, ReplayAdapter>();
26
+
27
+ // ── Recording CRUD ────────────────────────────────────────────────────
28
+
29
+ api.get("/hub/recordings", (c) => {
30
+ return c.json(store.list());
31
+ });
32
+
33
+ api.get("/hub/recordings/:id", (c) => {
34
+ const meta = store.get(c.req.param("id"));
35
+ if (!meta) return c.json({ error: "Recording not found" }, 404);
36
+ return c.json(meta);
37
+ });
38
+
39
+ api.get("/hub/recordings/:id/summary", (c) => {
40
+ const summary = store.getSummary(c.req.param("id"));
41
+ if (!summary) return c.json({ error: "Recording not found" }, 404);
42
+ return c.json(summary);
43
+ });
44
+
45
+ api.delete("/hub/recordings/:id", (c) => {
46
+ const deleted = store.delete(c.req.param("id"));
47
+ if (!deleted) return c.json({ error: "Recording not found" }, 404);
48
+ return c.json({ ok: true });
49
+ });
50
+
51
+ // ── Upload (raw JSONL content in body) ────────────────────────────────
52
+
53
+ api.post("/hub/recordings/upload", async (c) => {
54
+ try {
55
+ const contentType = c.req.header("content-type") || "";
56
+
57
+ let content: string;
58
+ let originalFilename: string | undefined;
59
+
60
+ if (contentType.includes("multipart/form-data")) {
61
+ const formData = await c.req.formData();
62
+ const file = formData.get("file");
63
+ if (!file || !(file instanceof File)) {
64
+ return c.json({ error: "Missing 'file' field in multipart form" }, 400);
65
+ }
66
+ content = await file.text();
67
+ originalFilename = file.name;
68
+ } else {
69
+ // Plain text body
70
+ content = await c.req.text();
71
+ }
72
+
73
+ const meta = store.importContent(content, originalFilename);
74
+ return c.json(meta, 201);
75
+ } catch (e: unknown) {
76
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
77
+ }
78
+ });
79
+
80
+ // ── Import from local auto-recordings ─────────────────────────────────
81
+
82
+ api.post("/hub/recordings/import-local", async (c) => {
83
+ try {
84
+ const body = await c.req.json().catch(() => ({} as { filename?: string }));
85
+ if (!body.filename) {
86
+ return c.json({ error: "Missing 'filename' field" }, 400);
87
+ }
88
+ const sourcePath = join(options.recordingsDir, body.filename);
89
+ const resolvedSource = resolve(sourcePath);
90
+ const resolvedBase = resolve(options.recordingsDir);
91
+ if (!resolvedSource.startsWith(resolvedBase + sep) && resolvedSource !== resolvedBase) {
92
+ return c.json({ error: "Invalid filename" }, 400);
93
+ }
94
+ if (!existsSync(sourcePath)) {
95
+ return c.json({ error: "Recording file not found in auto-recordings directory" }, 404);
96
+ }
97
+ const meta = store.importLocal(sourcePath);
98
+ return c.json(meta, 201);
99
+ } catch (e: unknown) {
100
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
101
+ }
102
+ });
103
+
104
+ // ── Tags ──────────────────────────────────────────────────────────────
105
+
106
+ api.put("/hub/recordings/:id/tags", async (c) => {
107
+ const body = await c.req.json().catch(() => ({} as { tags?: string[] }));
108
+ if (!Array.isArray(body.tags)) {
109
+ return c.json({ error: "Missing 'tags' array" }, 400);
110
+ }
111
+ const meta = store.updateTags(c.req.param("id"), body.tags);
112
+ if (!meta) return c.json({ error: "Recording not found" }, 404);
113
+ return c.json(meta);
114
+ });
115
+
116
+ // ── Replay Sessions ───────────────────────────────────────────────────
117
+
118
+ api.post("/hub/replay", async (c) => {
119
+ try {
120
+ const body = await c.req.json().catch(() => ({} as { recordingId?: string; speed?: number }));
121
+ if (!body.recordingId) {
122
+ return c.json({ error: "Missing 'recordingId'" }, 400);
123
+ }
124
+
125
+ if (body.speed !== undefined && (typeof body.speed !== "number" || body.speed <= 0)) {
126
+ return c.json({ error: "Invalid 'speed' value" }, 400);
127
+ }
128
+
129
+ const recording = store.loadRecording(body.recordingId);
130
+ if (!recording) {
131
+ return c.json({ error: "Recording not found" }, 404);
132
+ }
133
+
134
+ // Lazy import to avoid circular dependency at module load time
135
+ const { ReplayAdapter } = await import("./replay-adapter.js");
136
+
137
+ const replaySessionId = `replay-${Date.now().toString(36)}`;
138
+ const speed = body.speed ?? 1;
139
+ const adapter = new ReplayAdapter(recording, speed);
140
+
141
+ options.wsBridge.attachBackendAdapter(
142
+ replaySessionId,
143
+ adapter,
144
+ recording.header.backend_type,
145
+ );
146
+
147
+ replayAdapters.set(replaySessionId, adapter);
148
+
149
+ // Clean up when replay finishes
150
+ adapter.onDisconnect(() => {
151
+ replayAdapters.delete(replaySessionId);
152
+ });
153
+
154
+ // Start playback
155
+ adapter.play();
156
+
157
+ return c.json({
158
+ sessionId: replaySessionId,
159
+ backendType: recording.header.backend_type,
160
+ speed,
161
+ entryCount: recording.entries.length,
162
+ }, 201);
163
+ } catch (e: unknown) {
164
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
165
+ }
166
+ });
167
+
168
+ api.post("/hub/replay/:sessionId/pause", (c) => {
169
+ const adapter = replayAdapters.get(c.req.param("sessionId"));
170
+ if (!adapter) return c.json({ error: "Replay session not found" }, 404);
171
+ adapter.pause();
172
+ return c.json({ ok: true, paused: true });
173
+ });
174
+
175
+ api.post("/hub/replay/:sessionId/resume", (c) => {
176
+ const adapter = replayAdapters.get(c.req.param("sessionId"));
177
+ if (!adapter) return c.json({ error: "Replay session not found" }, 404);
178
+ adapter.play();
179
+ return c.json({ ok: true, paused: false });
180
+ });
181
+
182
+ api.post("/hub/replay/:sessionId/speed", async (c) => {
183
+ const adapter = replayAdapters.get(c.req.param("sessionId"));
184
+ if (!adapter) return c.json({ error: "Replay session not found" }, 404);
185
+ const body = await c.req.json().catch(() => ({} as { speed?: number }));
186
+ if (typeof body.speed !== "number" || body.speed <= 0) {
187
+ return c.json({ error: "Invalid 'speed' value" }, 400);
188
+ }
189
+ adapter.setSpeed(body.speed);
190
+ return c.json({ ok: true, speed: body.speed });
191
+ });
192
+
193
+ api.get("/hub/replay/:sessionId/progress", (c) => {
194
+ const adapter = replayAdapters.get(c.req.param("sessionId"));
195
+ if (!adapter) return c.json({ error: "Replay session not found" }, 404);
196
+ return c.json(adapter.getProgress());
197
+ });
198
+
199
+ // ── Compatibility Validation ──────────────────────────────────────────
200
+
201
+ api.post("/hub/recordings/:id/validate", async (c) => {
202
+ try {
203
+ const recording = store.loadRecording(c.req.param("id"));
204
+ if (!recording) return c.json({ error: "Recording not found" }, 404);
205
+ const { validateRecording } = await import("./compat-validator.js");
206
+ const result = validateRecording(recording);
207
+ return c.json(result);
208
+ } catch (e: unknown) {
209
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 500);
210
+ }
211
+ });
212
+
213
+ // ── Disconnection Diagnostics ─────────────────────────────────────────
214
+
215
+ api.get("/hub/recordings/:id/diagnostics", async (c) => {
216
+ try {
217
+ const recording = store.loadRecording(c.req.param("id"));
218
+ if (!recording) return c.json({ error: "Recording not found" }, 404);
219
+ const { analyzeDisconnections } = await import("./diagnostics.js");
220
+ return c.json(analyzeDisconnections(recording));
221
+ } catch (e: unknown) {
222
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 500);
223
+ }
224
+ });
225
+
226
+ api.get("/hub/recordings/:id/timeline", async (c) => {
227
+ try {
228
+ const recording = store.loadRecording(c.req.param("id"));
229
+ if (!recording) return c.json({ error: "Recording not found" }, 404);
230
+ const { buildTimeline } = await import("./diagnostics.js");
231
+ return c.json(buildTimeline(recording));
232
+ } catch (e: unknown) {
233
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 500);
234
+ }
235
+ });
236
+ }