@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130

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 (317) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +930 -35
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +129 -0
  205. package/src/rest-identity.ts +125 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +91 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +141 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-stale-open-http.test.ts +290 -0
  303. package/src/task-stale-open.ts +103 -0
  304. package/src/tasks-pagination-http.test.ts +141 -0
  305. package/src/tool-audience-http.test.ts +220 -0
  306. package/src/tool-audience.ts +118 -0
  307. package/src/tools-list-json-schema.test.ts +67 -0
  308. package/src/tools.ts +3927 -323
  309. package/src/upload-early-reject-guard.test.ts +155 -0
  310. package/src/uploads-http.test.ts +97 -15
  311. package/src/uploads.ts +103 -6
  312. package/src/user-avatar-http.test.ts +74 -0
  313. package/src/user-avatar-migration.test.ts +25 -0
  314. package/src/user-inbox-by-agent-http.test.ts +161 -0
  315. package/src/user-inbox-netid-notnull.test.ts +122 -0
  316. package/src/user-inbox-read-http.test.ts +223 -0
  317. package/src/user-inbox-schema.test.ts +54 -0
@@ -17,27 +17,37 @@ import {
17
17
 
18
18
  const HUB_PATH = join(import.meta.dir, "reserved-env.ts");
19
19
  const DAEMON_PATH = join(import.meta.dir, "..", "..", "..", "agent-node", "src", "shared", "reserved-env.ts");
20
+ const CLI_PATH = join(import.meta.dir, "..", "..", "..", "agent-network", "src", "shared", "reserved-env.ts");
20
21
 
21
22
  describe("RFC-026 v4 §4.4.7 G9 — hub/daemon reserved-env drift guard", () => {
22
23
  test("byte-identical source files", () => {
23
24
  const hub = readFileSync(HUB_PATH, "utf-8");
24
25
  const daemon = readFileSync(DAEMON_PATH, "utf-8");
26
+ const cli = readFileSync(CLI_PATH, "utf-8");
25
27
  expect(daemon).toBe(hub);
28
+ expect(cli).toBe(hub);
26
29
  });
27
30
 
28
31
  test("imported constants are set-equal at runtime", async () => {
29
32
  // Use a dynamic file import path so this test breaks if the file
30
33
  // moves but still references the daemon copy.
31
34
  const daemonMod = await import(DAEMON_PATH);
35
+ const cliMod = await import(CLI_PATH);
32
36
  const daemonExact = daemonMod.RESERVED_ENV_KEYS_EXACT as Set<string>;
33
37
  const daemonPrefixes = daemonMod.RESERVED_ENV_PREFIXES as ReadonlyArray<string>;
38
+ const cliExact = cliMod.RESERVED_ENV_KEYS_EXACT as Set<string>;
39
+ const cliPrefixes = cliMod.RESERVED_ENV_PREFIXES as ReadonlyArray<string>;
34
40
 
35
41
  // EXACT set equality
36
42
  expect(daemonExact.size).toBe(HUB_EXACT.size);
37
43
  for (const k of HUB_EXACT) expect(daemonExact.has(k)).toBe(true);
38
44
  for (const k of daemonExact) expect(HUB_EXACT.has(k)).toBe(true);
45
+ expect(cliExact.size).toBe(HUB_EXACT.size);
46
+ for (const k of HUB_EXACT) expect(cliExact.has(k)).toBe(true);
47
+ for (const k of cliExact) expect(HUB_EXACT.has(k)).toBe(true);
39
48
 
40
49
  // PREFIX array order-independent equality
41
50
  expect(new Set(daemonPrefixes)).toEqual(new Set(HUB_PREFIXES));
51
+ expect(new Set(cliPrefixes)).toEqual(new Set(HUB_PREFIXES));
42
52
  });
43
53
  });
@@ -0,0 +1,5 @@
1
+ import { isReservedEnvKey } from './reserved-env';
2
+ export const validScopedSecretKey = (key: unknown): key is string => typeof key === 'string'
3
+ && /^[A-Z_][A-Z0-9_]{0,127}$/.test(key) && !isReservedEnvKey(key)
4
+ && !['CODEX_HOME','CLAUDE_CONFIG_DIR','XDG_CONFIG_HOME','XDG_DATA_HOME','TMUX','TMUX_PANE','TEMP','TMP'].includes(key)
5
+ && !['COMMHUB_','ANET_','CLAUDE_CODE_'].some(p => key.startsWith(p));
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { SESSION_TASK_PREVIEW_MAX, TASK_CONTENT_MAX, sessionTaskPreview } from "./task-content-limit.js";
5
+
6
+ // Board #668 — the node reports the whole task so the hub can match it
7
+ // against tasks.content. Both sides must agree on the cap.
8
+ const HUB_PATH = join(import.meta.dir, "task-content-limit.ts");
9
+ const NODE_PATH = join(import.meta.dir, "..", "..", "..", "agent-node", "src", "shared", "task-content-limit.ts");
10
+
11
+ describe("#668 — hub/node task-content-limit drift guard", () => {
12
+ test("byte-identical source files", () => {
13
+ expect(readFileSync(NODE_PATH, "utf-8")).toBe(readFileSync(HUB_PATH, "utf-8"));
14
+ });
15
+
16
+ test("constants equal at runtime", async () => {
17
+ const node = await import(NODE_PATH);
18
+ expect(node.TASK_CONTENT_MAX).toBe(TASK_CONTENT_MAX);
19
+ expect(node.SESSION_TASK_PREVIEW_MAX).toBe(SESSION_TASK_PREVIEW_MAX);
20
+ });
21
+
22
+ test("preview is the first 200 chars", () => {
23
+ expect(SESSION_TASK_PREVIEW_MAX).toBe(200);
24
+ const long = "字".repeat(TASK_CONTENT_MAX);
25
+ expect(sessionTaskPreview(long)).toBe(long.slice(0, 200));
26
+ expect(sessionTaskPreview("short")).toBe("short");
27
+ });
28
+ });
@@ -0,0 +1,25 @@
1
+ // Board #668 — task text limits shared by hub and node.
2
+ //
3
+ // This file is byte-identical in server/src/shared/task-content-limit.ts and
4
+ // agent-node/src/shared/task-content-limit.ts (task-content-limit-drift.test.ts
5
+ // fails the build on any difference).
6
+ //
7
+ // TASK_CONTENT_MAX is the cap every dispatch entry (send_task, REST /api/task,
8
+ // scheduled tasks) and report_status.task accept. The node reports the whole
9
+ // task text so the hub can match it against tasks.content and write
10
+ // started_at. If the dispatch cap grew without the node and report_status
11
+ // following, long tasks would silently stop matching again.
12
+ //
13
+ // SESSION_TASK_PREVIEW_MAX is what the hub keeps in sessions.task. That column
14
+ // is readable by get_all_status, the full /api/status and members who may see
15
+ // the agent but not its conversations, so only a short preview is stored —
16
+ // the full text is used for matching, never persisted on the session row.
17
+
18
+ export const TASK_CONTENT_MAX = 10_000;
19
+
20
+ export const SESSION_TASK_PREVIEW_MAX = 200;
21
+
22
+ /** The preview stored in sessions.task — same cut as the dispatch preview. */
23
+ export function sessionTaskPreview(task: string): string {
24
+ return task.slice(0, SESSION_TASK_PREVIEW_MAX);
25
+ }
@@ -0,0 +1,312 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { PgAdapter, SQLiteAdapter, resolveDatabaseTarget, type DbAdapter } from "./db-adapter";
4
+ import { DurableSideThreadCommandPort, SideThreadCommandStore, handleSideThreadCommandRequest } from "./side-thread-command-transport";
5
+ import { SideThreadCoordinator, SideThreadStore } from "./side-thread";
6
+
7
+ function actor(tokenId = "tok-node") { return { tokenId, networkId: "net-1", nodeId: "node-1" }; }
8
+ // RFC-039 S4: with COMMHUB_TEST_PG_URL (validated by the adapter guard) the
9
+ // same tests run on PostgreSQL, each fixture on an emptied schema.
10
+ function freshDb(): DbAdapter {
11
+ if (!process.env.COMMHUB_TEST_PG_URL) return new SQLiteAdapter(new Database(":memory:"));
12
+ const target = resolveDatabaseTarget();
13
+ if (target.kind !== "postgres") throw new Error("COMMHUB_TEST_PG_URL did not select PostgreSQL");
14
+ const pg = new PgAdapter(target.url);
15
+ pg.exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public");
16
+ return pg;
17
+ }
18
+ function fixture() {
19
+ const db = freshDb();
20
+ db.exec("CREATE TABLE inbox(id TEXT PRIMARY KEY, content TEXT)");
21
+ const store = new SideThreadCommandStore(db, () => 123);
22
+ const port = new DurableSideThreadCommandPort({ store, networkForNode: (id) => id === "node-1" ? "net-1" : null,
23
+ capabilityForNode: () => ({ supported: true, mode: "native-exact-fork", exactBoundary: { through: true, before: true } }),
24
+ grantAttachment: (_node, ref) => ({ fileId: ref.fileId, grantId: `grant-${ref.fileId}`, sha256: "a".repeat(64), size: 7, mediaType: "image/png" }) });
25
+ return { db, store, port };
26
+ }
27
+
28
+ describe("Hub durable SideThread command outbox", () => {
29
+ test("late fork and start ACKs reconcile the coordinator across restart", async () => {
30
+ const f = fixture();
31
+ const owner = { userId: "user-1", username: "owner", tokenId: "utok-1", kind: "user" as const };
32
+ const input = { requestKey: "rk-late-create", networkId: "net-1", nodeId: "node-1", sourceThreadId: "source-1", boundary: { kind: "through" as const, turnId: "source-turn" }, prompt: "late receipt", attachments: [] };
33
+ const sideStore = new SideThreadStore(f.db);
34
+ let coordinator = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
35
+ const originalTransaction = f.db.transaction.bind(f.db);
36
+ const failTransaction = (...nth: number[]) => { let calls = 0; (f.db as any).transaction = (fn: () => unknown) => nth.includes(++calls) ? (() => { throw new Error("injected create apply failure"); })() : originalTransaction(fn); };
37
+ failTransaction(2);
38
+ await expect(coordinator.create(owner, input)).rejects.toThrow("injected create apply failure");
39
+ const side = sideStore.getByCreateKey(owner, input.requestKey)!.record;
40
+ expect(side).toMatchObject({ state: "creating", attempts: [{ state: "starting" }], operations: [{ kind: "fork", state: "pending" }] });
41
+ const fork = f.store.claim(actor())!;
42
+ f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: fork.commandId, operationId: fork.operationId, state: "accepted", errorCode: null, result: { threadId: "derived-late", turnId: null, destinationTurnId: null } });
43
+ (f.db as any).transaction = originalTransaction;
44
+ coordinator.close();
45
+ coordinator = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
46
+ failTransaction(2, 3);
47
+ await expect(coordinator.create(owner, input)).rejects.toThrow("injected create apply failure");
48
+ expect(sideStore.getByCreateKey(owner, input.requestKey)!.record).toMatchObject({ state: "reconciling", attempts: [{ state: "reconciling" }], operations: [{ kind: "fork", state: "pending" }] });
49
+ expect(f.store.claim(actor())).toBeNull();
50
+ (f.db as any).transaction = originalTransaction;
51
+ coordinator.close();
52
+ coordinator = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
53
+ await expect(coordinator.create(owner, input)).rejects.toMatchObject({ code: "SIDE_THREAD_AMBIGUOUS" });
54
+ const start = f.store.claim(actor())!;
55
+ expect(start.kind).toBe("start");
56
+ f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: start.commandId, operationId: start.operationId, state: "accepted", errorCode: null, result: { threadId: null, turnId: "turn-late", destinationTurnId: null } });
57
+ failTransaction(3);
58
+ await expect(coordinator.create(owner, input)).rejects.toMatchObject({ code: "SIDE_THREAD_AMBIGUOUS" });
59
+ expect(f.store.claim(actor())).toBeNull();
60
+ (f.db as any).transaction = originalTransaction;
61
+ coordinator.close();
62
+ coordinator = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
63
+ expect(await coordinator.create(owner, input)).toMatchObject({ state: "running", threadId: "derived-late", attempts: [{ turnId: "turn-late" }] });
64
+ const terminal = { protocol: "side_thread.terminal.v1", sideThreadId: side.sideChatId, attemptId: side.activeAttemptId!, threadId: "derived-late", turnId: "turn-late", status: "completed" as const, text: "late answer", errorCode: null };
65
+ expect((await f.port.acceptTerminal(actor(), terminal)).confirmed).toBe(true);
66
+
67
+ expect(await coordinator.create(owner, input)).toMatchObject({ sideChatId: side.sideChatId, state: "completed", threadId: "derived-late", attempts: [{ turnId: "turn-late", result: "late answer" }] });
68
+ expect(coordinator.get(owner, side.sideChatId).operations.filter((operation) => operation.kind === "fork" || operation.kind === "start")).toHaveLength(2);
69
+ expect(f.store.claim(actor())).toBeNull();
70
+ expect((await f.port.acceptTerminal(actor(), terminal)).idempotent).toBe(true);
71
+ expect(coordinator.get(owner, side.sideChatId)).toMatchObject({ state: "completed", attempts: [{ result: "late answer" }] });
72
+ coordinator.close();
73
+ });
74
+
75
+ for (const receipt of [
76
+ { state: "unsupported", errorCode: "SIDE_THREAD_UNSUPPORTED", code: "SIDE_THREAD_UNSUPPORTED", status: 501 },
77
+ { state: "failed", errorCode: "SIDE_THREAD_CONFLICT", code: "SIDE_THREAD_CONFLICT", status: 409 },
78
+ ] as const) test(`late ${receipt.state} fork ACK converges to stable failed across coordinators`, async () => {
79
+ const f = fixture();
80
+ const owner = { userId: "user-1", username: "owner", tokenId: "utok-1", kind: "user" as const };
81
+ const input = { requestKey: `rk-late-${receipt.state}`, networkId: "net-1", nodeId: "node-1", sourceThreadId: "source-1", boundary: { kind: "through" as const, turnId: "source-turn" }, prompt: "late failure", attachments: [] };
82
+ const sideStore = new SideThreadStore(f.db);
83
+ let first = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
84
+ await expect(first.create(owner, input)).rejects.toMatchObject({ code: "SIDE_THREAD_AMBIGUOUS" });
85
+ const command = f.store.claim(actor())!;
86
+ f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: command.commandId, operationId: command.operationId, state: receipt.state, errorCode: receipt.errorCode, result: { threadId: null, turnId: null, destinationTurnId: null } });
87
+ first.close();
88
+
89
+ const second = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
90
+ await expect(second.create(owner, input)).rejects.toMatchObject({ code: receipt.code, status: receipt.status });
91
+ const failed = sideStore.getByCreateKey(owner, input.requestKey)!.record;
92
+ expect(failed).toMatchObject({ state: "failed", attempts: [{ state: "failed" }], operations: [{ kind: "fork", state: "failed", errorCode: receipt.code }] });
93
+ expect(failed.activeAttemptId).toBeUndefined();
94
+ expect(f.db.get<{ active: string | null }>("SELECT active_attempt_id active FROM side_chats WHERE side_chat_id=?1", failed.sideChatId)?.active).toBeNull();
95
+ expect(await second.create(owner, input)).toMatchObject({ sideChatId: failed.sideChatId, state: "failed" });
96
+ expect(f.store.claim(actor())).toBeNull();
97
+ second.close();
98
+ });
99
+
100
+ for (const action of ["cancel", "archive", "purge", "bring-back"] as const)
101
+ for (const receipt of [
102
+ { state: "accepted", errorCode: null, code: null },
103
+ { state: "unsupported", errorCode: "SIDE_THREAD_UNSUPPORTED", code: "SIDE_THREAD_UNSUPPORTED" },
104
+ { state: "failed", errorCode: "SIDE_THREAD_CONFLICT", code: "SIDE_THREAD_CONFLICT" },
105
+ ] as const) test(`${action} consumes a late ${receipt.state} receipt after restart`, async () => {
106
+ const f = fixture();
107
+ const owner = { userId: "user-1", username: "owner", tokenId: "utok-1", kind: "user" as const };
108
+ const input = { requestKey: `rk-create-${action}-${receipt.state}`, networkId: "net-1", nodeId: "node-1", sourceThreadId: "source-1", boundary: { kind: "through" as const, turnId: "source-turn" }, prompt: "lifecycle", attachments: [] };
109
+ const sideStore = new SideThreadStore(f.db);
110
+ let coordinator = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
111
+ await coordinator.create(owner, input).catch(() => {});
112
+ const fork = f.store.claim(actor())!;
113
+ f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: fork.commandId, operationId: fork.operationId, state: "accepted", errorCode: null, result: { threadId: "derived-life", turnId: null, destinationTurnId: null } });
114
+ await coordinator.create(owner, input).catch(() => {});
115
+ const start = f.store.claim(actor())!;
116
+ f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: start.commandId, operationId: start.operationId, state: "accepted", errorCode: null, result: { threadId: null, turnId: "turn-life", destinationTurnId: null } });
117
+ const running = await coordinator.create(owner, input);
118
+ if (action !== "cancel") await f.port.acceptTerminal(actor(), { protocol: "side_thread.terminal.v1", sideThreadId: running.sideChatId, attemptId: running.activeAttemptId!, threadId: "derived-life", turnId: "turn-life", status: "completed", text: "answer", errorCode: null });
119
+ const requestKey = `rk-${action}-${receipt.state}`;
120
+ const invoke = (c: SideThreadCoordinator) => action === "cancel" ? c.cancel(owner, running.sideChatId, { requestKey })
121
+ : action === "archive" ? c.archive(owner, running.sideChatId, { requestKey })
122
+ : action === "purge" ? c.purge(owner, running.sideChatId, { requestKey })
123
+ : c.bringBack(owner, running.sideChatId, { requestKey, destinationThreadId: "source-1" });
124
+ await invoke(coordinator).catch(() => {});
125
+ const command = f.store.claim(actor())!;
126
+ f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: command.commandId, operationId: command.operationId, state: receipt.state, errorCode: receipt.errorCode, result: { threadId: null, turnId: null, destinationTurnId: action === "bring-back" && receipt.state === "accepted" ? "destination-life" : null } });
127
+ coordinator.close();
128
+ coordinator = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
129
+ if (receipt.code) {
130
+ await expect(invoke(coordinator)).rejects.toMatchObject({ code: receipt.code });
131
+ await expect(invoke(coordinator)).rejects.toMatchObject({ code: receipt.code });
132
+ } else {
133
+ if (action === "archive" || action === "bring-back") {
134
+ const originalTransaction = f.db.transaction.bind(f.db);
135
+ let failApply = true;
136
+ (f.db as any).transaction = (fn: () => unknown) => {
137
+ if (failApply) { failApply = false; throw new Error("injected local apply failure"); }
138
+ return originalTransaction(fn);
139
+ };
140
+ await expect(invoke(coordinator)).rejects.toMatchObject({ code: "SIDE_THREAD_AMBIGUOUS", status: 202 });
141
+ const unsettled = sideStore.getByCreateKey(owner, input.requestKey)!.record.operations.find((op) => op.kind === action && op.requestKey === requestKey)!;
142
+ expect(["ambiguous", "reconciling"]).toContain(unsettled.state);
143
+ expect(unsettled.state).not.toBe("failed");
144
+ (f.db as any).transaction = originalTransaction;
145
+ coordinator.close();
146
+ coordinator = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
147
+ }
148
+ const [one, two] = await Promise.all([invoke(coordinator), invoke(coordinator)]);
149
+ if (action === "bring-back") expect(one).toEqual({ bringBackId: expect.any(String), destinationTurnId: "destination-life" });
150
+ else expect(one).toMatchObject({ state: action === "cancel" ? "running" : action === "archive" ? "archived" : "purged" });
151
+ expect(two).toEqual(one);
152
+ const hydrated = coordinator.get(owner, running.sideChatId);
153
+ const operation = hydrated.operations.find((op) => op.kind === action && op.requestKey === requestKey)!;
154
+ expect(operation).toMatchObject({ state: "completed", threadId: action === "bring-back" ? "source-1" : "derived-life" });
155
+ if (action === "bring-back") expect(operation.turnId).toBe("destination-life");
156
+ if (action === "cancel") expect(operation.turnId).toBe("turn-life");
157
+ const domainType = action === "bring-back" ? "side_chat.brought_back" : action === "archive" ? "side_chat.archived" : action === "purge" ? "side_chat.purged" : null;
158
+ const afterEvents = coordinator.listEvents(owner, running.sideChatId);
159
+ if (domainType) expect(afterEvents.filter((event) => event.type === domainType)).toHaveLength(1);
160
+ else expect(afterEvents.filter((event) => ["side_chat.brought_back", "side_chat.archived", "side_chat.purged"].includes(event.type))).toHaveLength(0);
161
+ }
162
+ expect(f.store.claim(actor())).toBeNull();
163
+ coordinator.close();
164
+ });
165
+
166
+ test("two coordinators race a late archive receipt but persist one domain event", async () => {
167
+ const f = fixture();
168
+ const owner = { userId: "user-1", username: "owner", tokenId: "utok-1", kind: "user" as const };
169
+ const input = { requestKey: "rk-multi-archive-create", networkId: "net-1", nodeId: "node-1", sourceThreadId: "source-1", boundary: { kind: "through" as const, turnId: "source-turn" }, prompt: "multi", attachments: [] };
170
+ const sideStore = new SideThreadStore(f.db);
171
+ const bootstrap = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
172
+ await bootstrap.create(owner, input).catch(() => {});
173
+ const fork = f.store.claim(actor())!; f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: fork.commandId, operationId: fork.operationId, state: "accepted", errorCode: null, result: { threadId: "derived-multi", turnId: null, destinationTurnId: null } });
174
+ await bootstrap.create(owner, input).catch(() => {});
175
+ const start = f.store.claim(actor())!; f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: start.commandId, operationId: start.operationId, state: "accepted", errorCode: null, result: { threadId: null, turnId: "turn-multi", destinationTurnId: null } });
176
+ const running = await bootstrap.create(owner, input);
177
+ await f.port.acceptTerminal(actor(), { protocol: "side_thread.terminal.v1", sideThreadId: running.sideChatId, attemptId: running.activeAttemptId!, threadId: "derived-multi", turnId: "turn-multi", status: "completed", text: "done", errorCode: null });
178
+ await bootstrap.archive(owner, running.sideChatId, { requestKey: "rk-multi-archive" }).catch(() => {});
179
+ const archive = f.store.claim(actor())!; f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: archive.commandId, operationId: archive.operationId, state: "accepted", errorCode: null, result: { threadId: null, turnId: null, destinationTurnId: null } });
180
+ bootstrap.close();
181
+ const port = () => new DurableSideThreadCommandPort({ store: f.store, networkForNode: () => "net-1", capabilityForNode: () => ({ supported: true, mode: "native-exact-fork", exactBoundary: { through: true, before: true } }), grantAttachment: (_node, ref) => ({ fileId: ref.fileId }) });
182
+ const a = new SideThreadCoordinator(sideStore, port(), { enabled: true, authorizeNode: () => true });
183
+ const b = new SideThreadCoordinator(sideStore, port(), { enabled: true, authorizeNode: () => true });
184
+ const [one, two] = await Promise.all([a.archive(owner, running.sideChatId, { requestKey: "rk-multi-archive" }), b.archive(owner, running.sideChatId, { requestKey: "rk-multi-archive" })]);
185
+ expect(one.state).toBe("archived"); expect(two.state).toBe("archived");
186
+ expect(sideStore.events(running.sideChatId).filter((event) => event.type === "side_chat.archived")).toHaveLength(1);
187
+ expect(f.store.claim(actor())).toBeNull();
188
+ a.close(); b.close();
189
+ });
190
+
191
+ test("fails closed on an adapter without atomic transactions", () => {
192
+ expect(() => new SideThreadCommandStore({ dialect: "postgres", atomicTransactions: false } as any)).toThrow(/atomic SQLite/);
193
+ });
194
+ test("allows exactly one durable terminal applier", () => {
195
+ const f = fixture(); const close = f.port.subscribe(async () => {});
196
+ expect(() => f.port.subscribe(async () => {})).toThrow(/exactly one/);
197
+ close();
198
+ });
199
+ test("queues outside inbox/FIFO and only exact node token can retain the delivery", async () => {
200
+ const f = fixture();
201
+ await expect(f.port.fork({ operationId: "op-1", requestKey: "rk-1", sideChatId: "side-1", nodeId: "node-1", sourceThreadId: "source-1", boundary: { kind: "through", turnId: "source-turn" } })).rejects.toMatchObject({ code: "SIDE_THREAD_AMBIGUOUS" });
202
+ expect(f.db.get<{ n: number }>("SELECT COUNT(*) n FROM inbox")!.n).toBe(0);
203
+ expect(f.store.claim({ ...actor(), nodeId: "node-2" })).toBeNull();
204
+ const delivered = f.store.claim(actor())!;
205
+ expect(delivered).toMatchObject({ protocol: "side_thread.command.v1", operationId: "op-1", nodeId: "node-1" });
206
+ expect(f.store.claim(actor())).toEqual(delivered);
207
+ });
208
+
209
+ test("ACK response loss is idempotent and foreign token/collision cannot rewrite it", async () => {
210
+ const f = fixture();
211
+ await f.port.start({ operationId: "op-start", requestKey: "rk-start", sideChatId: "side-1", attemptId: "attempt-1", nodeId: "node-1", threadId: "derived-1", prompt: "q", attachments: [] }).catch(() => {});
212
+ const command = f.store.claim(actor())!;
213
+ const ack = { protocol: "side_thread.ack.v1", commandId: command.commandId, operationId: "op-start", state: "accepted", errorCode: null, result: { threadId: null, turnId: "turn-1", destinationTurnId: null } };
214
+ expect(f.store.ack(actor(), ack).idempotent).toBe(false);
215
+ expect(f.store.ack(actor(), ack).idempotent).toBe(true);
216
+ expect(() => f.store.ack(actor("tok-foreign"), ack)).toThrow(/ownership/);
217
+ expect(() => f.store.ack(actor(), { ...ack, state: "failed", errorCode: "SIDE_THREAD_CONFLICT" })).toThrow(/immutable/);
218
+ });
219
+
220
+ test("terminal receipt requires exact side/attempt/thread/turn tuple and is replay-safe", async () => {
221
+ const f = fixture(); const events: any[] = []; f.port.subscribe((e) => events.push(e));
222
+ await f.port.start({ operationId: "op-start", requestKey: "rk-start", sideChatId: "side-1", attemptId: "attempt-1", nodeId: "node-1", threadId: "derived-1", prompt: "q", attachments: [] }).catch(() => {});
223
+ const command = f.store.claim(actor())!;
224
+ f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: command.commandId, operationId: "op-start", state: "accepted", errorCode: null, result: { threadId: null, turnId: "turn-1", destinationTurnId: null } });
225
+ const terminal = { protocol: "side_thread.terminal.v1", sideThreadId: "side-1", attemptId: "attempt-1", threadId: "derived-1", turnId: "turn-1", status: "completed", text: "answer", errorCode: null };
226
+ await expect(f.port.acceptTerminal(actor(), { ...terminal, turnId: "turn-foreign" })).rejects.toThrow(/four-tuple/);
227
+ expect((await f.port.acceptTerminal(actor(), terminal)).idempotent).toBe(false);
228
+ expect((await f.port.acceptTerminal(actor(), terminal)).idempotent).toBe(true);
229
+ expect(events).toHaveLength(1);
230
+ });
231
+
232
+ test("durable accepted receipt reconciles a later POST retry without a second delivery", async () => {
233
+ const f = fixture(); const input = { operationId: "op-fork", requestKey: "rk-fork", sideChatId: "side-1", nodeId: "node-1", sourceThreadId: "source-1", boundary: { kind: "before" as const, turnId: "source-turn" } };
234
+ await f.port.fork(input).catch(() => {});
235
+ const command = f.store.claim(actor())!;
236
+ f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: command.commandId, operationId: input.operationId, state: "accepted", errorCode: null, result: { threadId: "derived-1", turnId: null, destinationTurnId: null } });
237
+ expect(await f.port.fork(input)).toEqual({ threadId: "derived-1" });
238
+ expect(f.store.claim(actor())).toBeNull();
239
+ });
240
+
241
+ test("HTTP surface binds path node and ACK body to the authenticated node actor", async () => {
242
+ const f = fixture();
243
+ await f.port.fork({ operationId: "op-http", requestKey: "rk-http", sideChatId: "side-http", nodeId: "node-1", sourceThreadId: "source-1", boundary: { kind: "through", turnId: "t-1" } }).catch(() => {});
244
+ const foreign = await handleSideThreadCommandRequest({ req: new Request("http://hub/api/nodes/node-1/side-thread-commands/pending"), url: new URL("http://hub/api/nodes/node-1/side-thread-commands/pending"), actor: { ...actor(), nodeId: "node-2" }, store: f.store, port: f.port });
245
+ expect(foreign?.status).toBe(403);
246
+ const pulled = await handleSideThreadCommandRequest({ req: new Request("http://hub/api/nodes/node-1/side-thread-commands/pending"), url: new URL("http://hub/api/nodes/node-1/side-thread-commands/pending"), actor: actor(), store: f.store, port: f.port });
247
+ const command = (await pulled!.json() as any).command;
248
+ const badBody = { protocol: "side_thread.ack.v1", commandId: "other-command", operationId: command.operationId, state: "accepted", errorCode: null, result: { threadId: "derived", turnId: null, destinationTurnId: null } };
249
+ const rejected = await handleSideThreadCommandRequest({ req: new Request(`http://hub/api/nodes/node-1/side-thread-commands/${command.commandId}/ack`, { method: "POST", body: JSON.stringify(badBody) }), url: new URL(`http://hub/api/nodes/node-1/side-thread-commands/${command.commandId}/ack`), actor: actor(), store: f.store, port: f.port });
250
+ expect(rejected?.status).toBe(409);
251
+ });
252
+
253
+ test("hostile node cannot smuggle extra receipt fields or unbounded terminal text", async () => {
254
+ const f = fixture(); f.port.subscribe(async () => {});
255
+ await f.port.start({ operationId: "op-hostile", requestKey: "rk-hostile", sideChatId: "side-hostile", attemptId: "attempt-hostile", nodeId: "node-1", threadId: "derived-hostile", prompt: "q", attachments: [] }).catch(() => {});
256
+ const command = f.store.claim(actor())!;
257
+ const clean = { protocol: "side_thread.ack.v1", commandId: command.commandId, operationId: "op-hostile", state: "accepted", errorCode: null, result: { threadId: null, turnId: "turn-hostile", destinationTurnId: null } };
258
+ expect(() => f.store.ack(actor(), { ...clean, exception: "Bearer ntok_secret" })).toThrow(/unexpected protocol fields/);
259
+ f.store.ack(actor(), clean);
260
+ const terminal = { protocol: "side_thread.terminal.v1", sideThreadId: "side-hostile", attemptId: "attempt-hostile", threadId: "derived-hostile", turnId: "turn-hostile", status: "completed", text: "ok", errorCode: null };
261
+ await expect(f.port.acceptTerminal(actor(), { ...terminal, stack: "secret" })).rejects.toThrow(/unexpected protocol fields/);
262
+ await expect(f.port.acceptTerminal(actor(), { ...terminal, text: "x".repeat(1_000_001) })).rejects.toThrow(/completed terminal payload/);
263
+ expect((await f.port.acceptTerminal(actor(), terminal)).idempotent).toBe(false);
264
+ });
265
+
266
+ test("receipt commit followed by listener failure remains durably redeliverable", async () => {
267
+ const f = fixture(); let calls = 0;
268
+ f.port.subscribe(async () => { calls++; if (calls === 1) throw new Error("coordinator crashed"); });
269
+ await f.port.start({ operationId: "op-terminal-crash", requestKey: "rk-terminal-crash", sideChatId: "side-crash", attemptId: "attempt-crash", nodeId: "node-1", threadId: "derived-crash", prompt: "q", attachments: [] }).catch(() => {});
270
+ const command = f.store.claim(actor())!;
271
+ f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: command.commandId, operationId: "op-terminal-crash", state: "accepted", errorCode: null, result: { threadId: null, turnId: "turn-crash", destinationTurnId: null } });
272
+ const terminal = { protocol: "side_thread.terminal.v1", sideThreadId: "side-crash", attemptId: "attempt-crash", threadId: "derived-crash", turnId: "turn-crash", status: "completed", text: "answer", errorCode: null };
273
+ await expect(f.port.acceptTerminal(actor(), terminal)).rejects.toThrow(/coordinator crashed/);
274
+ expect((await f.port.acceptTerminal(actor(), terminal)).pending).toBe(false);
275
+ expect(calls).toBe(2);
276
+ });
277
+
278
+ test("concurrent terminal POST cannot acknowledge WAL deletion while apply is pending", async () => {
279
+ const f = fixture(); let release!: () => void;
280
+ const gate = new Promise<void>((resolve) => { release = resolve; });
281
+ f.port.subscribe(async () => { await gate; throw new Error("late apply failure"); });
282
+ await f.port.start({ operationId: "op-concurrent-terminal", requestKey: "rk-concurrent-terminal", sideChatId: "side-concurrent", attemptId: "attempt-concurrent", nodeId: "node-1", threadId: "derived-concurrent", prompt: "q", attachments: [] }).catch(() => {});
283
+ const command = f.store.claim(actor())!;
284
+ f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: command.commandId, operationId: "op-concurrent-terminal", state: "accepted", errorCode: null, result: { threadId: null, turnId: "turn-concurrent", destinationTurnId: null } });
285
+ const terminal = { protocol: "side_thread.terminal.v1", sideThreadId: "side-concurrent", attemptId: "attempt-concurrent", threadId: "derived-concurrent", turnId: "turn-concurrent", status: "completed", text: "answer", errorCode: null };
286
+ const url = new URL("http://hub/api/nodes/node-1/side-thread-commands/terminals");
287
+ const call = () => handleSideThreadCommandRequest({ req: new Request(url, { method: "POST", body: JSON.stringify(terminal) }), url, actor: actor(), store: f.store, port: f.port });
288
+ const first = call(); await Bun.sleep(10);
289
+ const second = await call();
290
+ expect(second?.status).toBe(503);
291
+ expect(await second?.json()).toMatchObject({ ok: false, retryable: true });
292
+ release();
293
+ expect((await first)?.status).toBe(409);
294
+ expect(f.store.terminalApplyState(["side-concurrent", "attempt-concurrent", "derived-concurrent", "turn-concurrent"])).toBe("received");
295
+ });
296
+
297
+ test("real coordinator database failure rejects apply and leaves receipt retryable", async () => {
298
+ const f = fixture();
299
+ const sideStore = new SideThreadStore(f.db);
300
+ const coordinator = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
301
+ await f.port.start({ operationId: "op-db-fail", requestKey: "rk-db-fail", sideChatId: "side-db", attemptId: "attempt-db", nodeId: "node-1", threadId: "derived-db", prompt: "q", attachments: [] }).catch(() => {});
302
+ const command = f.store.claim(actor())!;
303
+ f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: command.commandId, operationId: "op-db-fail", state: "accepted", errorCode: null, result: { threadId: null, turnId: "turn-db", destinationTurnId: null } });
304
+ const original = f.db.transaction.bind(f.db);
305
+ (f.db as any).transaction = () => { throw new Error("disk I/O failure"); };
306
+ const terminal = { protocol: "side_thread.terminal.v1", sideThreadId: "side-db", attemptId: "attempt-db", threadId: "derived-db", turnId: "turn-db", status: "completed", text: "answer", errorCode: null };
307
+ await expect(f.port.acceptTerminal(actor(), terminal)).rejects.toThrow(/disk I\/O/);
308
+ expect(f.store.terminalApplyState(["side-db", "attempt-db", "derived-db", "turn-db"])).toBe("received");
309
+ (f.db as any).transaction = original;
310
+ coordinator.close();
311
+ });
312
+ });