@sleep2agi/commhub-server 0.9.0-preview.6 → 0.9.0-preview.60

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 (140) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +88 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  10. package/src/api-host-supervisors-fallback.test.ts +271 -0
  11. package/src/api-nodes-shape.test.ts +26 -10
  12. package/src/auth-validate.test.ts +5 -1
  13. package/src/auth.ts +93 -14
  14. package/src/avatar-validate.test.ts +163 -0
  15. package/src/avatar-validate.ts +103 -0
  16. package/src/blocked-exit-on-reply.test.ts +122 -0
  17. package/src/boot-side-effect.test.ts +124 -0
  18. package/src/config-apply-sec1.test.ts +107 -0
  19. package/src/config-apply-validate.test.ts +92 -0
  20. package/src/config-apply-validate.ts +81 -3
  21. package/src/create-node-blocked-gate.test.ts +356 -0
  22. package/src/create-node-tool-schema.test.ts +39 -0
  23. package/src/create-node-validate.test.ts +89 -1
  24. package/src/create-node-validate.ts +55 -7
  25. package/src/create-node.ts +51 -1
  26. package/src/dashboard-slash-routing-http.test.ts +107 -0
  27. package/src/db-adapter-guard.test.ts +85 -0
  28. package/src/db-adapter.ts +64 -23
  29. package/src/db-timestamp.test.ts +38 -0
  30. package/src/db-timestamp.ts +15 -0
  31. package/src/db.ts +710 -33
  32. package/src/delivered-without-push.test.ts +140 -0
  33. package/src/desktop-message-seam.test.ts +150 -0
  34. package/src/desktop-message.test.ts +368 -0
  35. package/src/ext-token-shared.test.ts +60 -0
  36. package/src/ext-validation-regression.test.ts +131 -0
  37. package/src/external-schedule-edits-http.test.ts +320 -0
  38. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  39. package/src/external-schedule-edits.ts +327 -0
  40. package/src/file-download-authz-dev-open.test.ts +135 -0
  41. package/src/file-download-authz.test.ts +403 -0
  42. package/src/file-download-date-bucket.test.ts +436 -0
  43. package/src/file-network-scope-dev-open.test.ts +97 -0
  44. package/src/file-network-scope.test.ts +731 -0
  45. package/src/health-redaction.test.ts +641 -0
  46. package/src/http-gzip.test.ts +73 -0
  47. package/src/http-gzip.ts +60 -0
  48. package/src/hub-timestamp.test.ts +38 -0
  49. package/src/hub-timestamp.ts +27 -0
  50. package/src/inbox-count-sse.test.ts +310 -0
  51. package/src/inbox-count.ts +29 -0
  52. package/src/index.ts +15 -2227
  53. package/src/lifecycle-guard.ts +50 -0
  54. package/src/list-host-supervisors.test.ts +314 -0
  55. package/src/mcp-write-network-resolution.test.ts +429 -0
  56. package/src/messages-ack-by-agent-http.test.ts +142 -0
  57. package/src/network-name-http.test.ts +75 -0
  58. package/src/network-scope.ts +101 -0
  59. package/src/node-attrs-validate.ts +88 -0
  60. package/src/node-attrs.test.ts +217 -0
  61. package/src/node-create-request-status-http.test.ts +92 -0
  62. package/src/node-files-transport.test.ts +344 -0
  63. package/src/node-id-alias.test.ts +188 -0
  64. package/src/node-lifecycle-controllable.test.ts +64 -0
  65. package/src/node-lifecycle-controllable.ts +51 -0
  66. package/src/node-request-retention.test.ts +281 -0
  67. package/src/node-request-retention.ts +104 -0
  68. package/src/node-skills-transport.test.ts +209 -0
  69. package/src/observer-avatar-http.test.ts +428 -0
  70. package/src/observer-push.test.ts +252 -0
  71. package/src/package-version-consistency.test.ts +25 -0
  72. package/src/peer-reply-atomic.test.ts +455 -0
  73. package/src/peer-reply-capability.test.ts +70 -0
  74. package/src/push.ts +163 -7
  75. package/src/redact-tokens.test.ts +65 -0
  76. package/src/redact-tokens.ts +40 -0
  77. package/src/reply-target-derivation.test.ts +96 -0
  78. package/src/report-status-host-schema.test.ts +211 -0
  79. package/src/report-status-resume-id-handover.test.ts +150 -0
  80. package/src/require-explicit-test-db.ts +9 -0
  81. package/src/resolve-port.test.ts +57 -0
  82. package/src/resolve-port.ts +44 -0
  83. package/src/response-charset.test.ts +130 -0
  84. package/src/rest-explicit-columns-http.test.ts +216 -0
  85. package/src/rest-identity.test.ts +129 -0
  86. package/src/rest-identity.ts +100 -0
  87. package/src/rest-projections.ts +67 -0
  88. package/src/rest-write-network-resolution.test.ts +133 -0
  89. package/src/retention.ts +10 -1
  90. package/src/rules-file-duplicate-alias.test.ts +234 -0
  91. package/src/rules-file-session-target.test.ts +207 -0
  92. package/src/rules-file-transport.test.ts +237 -0
  93. package/src/runtime-label.test.ts +22 -0
  94. package/src/runtime-label.ts +21 -0
  95. package/src/scheduled-run-terminal.test.ts +268 -0
  96. package/src/scheduled-tasks-http.test.ts +483 -0
  97. package/src/scheduled-tasks.ts +549 -0
  98. package/src/send-reply-agent-warning.test.ts +202 -0
  99. package/src/send-reply-alias-optional.test.ts +131 -0
  100. package/src/send-reply-attachments.test.ts +458 -0
  101. package/src/serve-error.test.ts +38 -0
  102. package/src/serve-error.ts +27 -0
  103. package/src/server.ts +3842 -0
  104. package/src/shared/external-schedule-contract.ts +60 -0
  105. package/src/side-thread-command-transport.test.ts +302 -0
  106. package/src/side-thread-command-transport.ts +318 -0
  107. package/src/side-thread-http-integration.test.ts +233 -0
  108. package/src/side-thread-http.ts +504 -0
  109. package/src/side-thread-production.test.ts +72 -0
  110. package/src/side-thread-production.ts +118 -0
  111. package/src/side-thread.test.ts +1067 -0
  112. package/src/side-thread.ts +2495 -0
  113. package/src/skillhub-http.test.ts +92 -0
  114. package/src/start-node.test.ts +109 -0
  115. package/src/stop-delete-node.test.ts +952 -0
  116. package/src/task-auth-origin-http.test.ts +91 -0
  117. package/src/task-auth-origin.test.ts +24 -0
  118. package/src/task-auth-origin.ts +7 -0
  119. package/src/task-consumption.test.ts +482 -0
  120. package/src/task-diagnostic-http.test.ts +149 -0
  121. package/src/task-diagnostic.test.ts +115 -0
  122. package/src/task-diagnostic.ts +118 -0
  123. package/src/task-idempotency-mcp.test.ts +142 -0
  124. package/src/task-idempotency.test.ts +60 -0
  125. package/src/task-idempotency.ts +75 -0
  126. package/src/task-lifecycle-watcher.test.ts +204 -0
  127. package/src/task-lifecycle-watcher.ts +49 -0
  128. package/src/task-network-resolution-http.test.ts +141 -0
  129. package/src/tasks-pagination-http.test.ts +141 -0
  130. package/src/tools-list-json-schema.test.ts +66 -0
  131. package/src/tools.ts +3231 -204
  132. package/src/update-provider.test.ts +65 -0
  133. package/src/upload-early-reject-guard.test.ts +155 -0
  134. package/src/uploads-http.test.ts +97 -15
  135. package/src/uploads.test.ts +66 -0
  136. package/src/uploads.ts +130 -5
  137. package/src/user-inbox-by-agent-http.test.ts +161 -0
  138. package/src/user-inbox-netid-notnull.test.ts +122 -0
  139. package/src/user-inbox-read-http.test.ts +223 -0
  140. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,60 @@
1
+ const FIELD_BOUNDS = [
2
+ [0, 59],
3
+ [0, 23],
4
+ [1, 31],
5
+ [1, 12],
6
+ [0, 7],
7
+ ] as const;
8
+
9
+ function integer(text: string, min: number, max: number): number {
10
+ if (!/^\d+$/.test(text)) throw new Error("invalid_cron");
11
+ const value = Number(text);
12
+ if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error("invalid_cron");
13
+ return value;
14
+ }
15
+
16
+ function validateAtom(atom: string, min: number, max: number): void {
17
+ const [base, step, extra] = atom.split("/");
18
+ if (extra !== undefined || !base) throw new Error("invalid_cron");
19
+ if (step !== undefined) integer(step, 1, max - min + 1);
20
+ if (base === "*") return;
21
+ const range = base.split("-");
22
+ if (range.length === 1) {
23
+ integer(range[0], min, max);
24
+ return;
25
+ }
26
+ if (range.length !== 2) throw new Error("invalid_cron");
27
+ const start = integer(range[0], min, max);
28
+ const end = integer(range[1], min, max);
29
+ if (start > end) throw new Error("invalid_cron");
30
+ }
31
+
32
+ /** Strict five-field cron timing. It deliberately excludes commands, names,
33
+ * @aliases, a sixth seconds field and every control character. */
34
+ export function parseManagedCronExpression(raw: unknown): string {
35
+ if (typeof raw !== "string" || raw.length < 5 || raw.length > 120) throw new Error("invalid_cron");
36
+ if (/[^\x20-\x7e]/.test(raw) || /[\r\n]/.test(raw)) throw new Error("invalid_cron");
37
+ const fields = raw.trim().split(/ +/);
38
+ if (fields.length !== 5) throw new Error("invalid_cron");
39
+ fields.forEach((field, index) => {
40
+ if (!field || !/^[0-9*/,-]+$/.test(field)) throw new Error("invalid_cron");
41
+ for (const atom of field.split(",")) validateAtom(atom, FIELD_BOUNDS[index][0], FIELD_BOUNDS[index][1]);
42
+ });
43
+ return fields.join(" ");
44
+ }
45
+
46
+ export type ExternalSchedulePatch = { enabled?: boolean; cron?: string };
47
+
48
+ export function parseExternalSchedulePatch(raw: unknown): ExternalSchedulePatch {
49
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid_patch");
50
+ const obj = raw as Record<string, unknown>;
51
+ const keys = Object.keys(obj);
52
+ if (keys.length === 0 || keys.some((key) => key !== "enabled" && key !== "cron")) throw new Error("invalid_patch");
53
+ const patch: ExternalSchedulePatch = {};
54
+ if (obj.enabled !== undefined) {
55
+ if (typeof obj.enabled !== "boolean") throw new Error("invalid_enabled");
56
+ patch.enabled = obj.enabled;
57
+ }
58
+ if (obj.cron !== undefined) patch.cron = parseManagedCronExpression(obj.cron);
59
+ return patch;
60
+ }
@@ -0,0 +1,302 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { SQLiteAdapter } 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
+ function fixture() {
9
+ const db = new SQLiteAdapter(new Database(":memory:"));
10
+ db.exec("CREATE TABLE inbox(id TEXT PRIMARY KEY, content TEXT)");
11
+ const store = new SideThreadCommandStore(db, () => 123);
12
+ const port = new DurableSideThreadCommandPort({ store, networkForNode: (id) => id === "node-1" ? "net-1" : null,
13
+ capabilityForNode: () => ({ supported: true, mode: "native-exact-fork", exactBoundary: { through: true, before: true } }),
14
+ grantAttachment: (_node, ref) => ({ fileId: ref.fileId, grantId: `grant-${ref.fileId}`, sha256: "a".repeat(64), size: 7, mediaType: "image/png" }) });
15
+ return { db, store, port };
16
+ }
17
+
18
+ describe("Hub durable SideThread command outbox", () => {
19
+ test("late fork and start ACKs reconcile the coordinator across restart", async () => {
20
+ const f = fixture();
21
+ const owner = { userId: "user-1", username: "owner", tokenId: "utok-1", kind: "user" as const };
22
+ 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: [] };
23
+ const sideStore = new SideThreadStore(f.db);
24
+ let coordinator = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
25
+ const originalTransaction = f.db.transaction.bind(f.db);
26
+ 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); };
27
+ failTransaction(2);
28
+ await expect(coordinator.create(owner, input)).rejects.toThrow("injected create apply failure");
29
+ const side = sideStore.getByCreateKey(owner, input.requestKey)!.record;
30
+ expect(side).toMatchObject({ state: "creating", attempts: [{ state: "starting" }], operations: [{ kind: "fork", state: "pending" }] });
31
+ const fork = f.store.claim(actor())!;
32
+ 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 } });
33
+ (f.db as any).transaction = originalTransaction;
34
+ coordinator.close();
35
+ coordinator = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
36
+ failTransaction(2, 3);
37
+ await expect(coordinator.create(owner, input)).rejects.toThrow("injected create apply failure");
38
+ expect(sideStore.getByCreateKey(owner, input.requestKey)!.record).toMatchObject({ state: "reconciling", attempts: [{ state: "reconciling" }], operations: [{ kind: "fork", state: "pending" }] });
39
+ expect(f.store.claim(actor())).toBeNull();
40
+ (f.db as any).transaction = originalTransaction;
41
+ coordinator.close();
42
+ coordinator = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
43
+ await expect(coordinator.create(owner, input)).rejects.toMatchObject({ code: "SIDE_THREAD_AMBIGUOUS" });
44
+ const start = f.store.claim(actor())!;
45
+ expect(start.kind).toBe("start");
46
+ 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 } });
47
+ failTransaction(3);
48
+ await expect(coordinator.create(owner, input)).rejects.toMatchObject({ code: "SIDE_THREAD_AMBIGUOUS" });
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
+ expect(await coordinator.create(owner, input)).toMatchObject({ state: "running", threadId: "derived-late", attempts: [{ turnId: "turn-late" }] });
54
+ 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 };
55
+ expect((await f.port.acceptTerminal(actor(), terminal)).confirmed).toBe(true);
56
+
57
+ expect(await coordinator.create(owner, input)).toMatchObject({ sideChatId: side.sideChatId, state: "completed", threadId: "derived-late", attempts: [{ turnId: "turn-late", result: "late answer" }] });
58
+ expect(coordinator.get(owner, side.sideChatId).operations.filter((operation) => operation.kind === "fork" || operation.kind === "start")).toHaveLength(2);
59
+ expect(f.store.claim(actor())).toBeNull();
60
+ expect((await f.port.acceptTerminal(actor(), terminal)).idempotent).toBe(true);
61
+ expect(coordinator.get(owner, side.sideChatId)).toMatchObject({ state: "completed", attempts: [{ result: "late answer" }] });
62
+ coordinator.close();
63
+ });
64
+
65
+ for (const receipt of [
66
+ { state: "unsupported", errorCode: "SIDE_THREAD_UNSUPPORTED", code: "SIDE_THREAD_UNSUPPORTED", status: 501 },
67
+ { state: "failed", errorCode: "SIDE_THREAD_CONFLICT", code: "SIDE_THREAD_CONFLICT", status: 409 },
68
+ ] as const) test(`late ${receipt.state} fork ACK converges to stable failed across coordinators`, async () => {
69
+ const f = fixture();
70
+ const owner = { userId: "user-1", username: "owner", tokenId: "utok-1", kind: "user" as const };
71
+ 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: [] };
72
+ const sideStore = new SideThreadStore(f.db);
73
+ let first = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
74
+ await expect(first.create(owner, input)).rejects.toMatchObject({ code: "SIDE_THREAD_AMBIGUOUS" });
75
+ const command = f.store.claim(actor())!;
76
+ 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 } });
77
+ first.close();
78
+
79
+ const second = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
80
+ await expect(second.create(owner, input)).rejects.toMatchObject({ code: receipt.code, status: receipt.status });
81
+ const failed = sideStore.getByCreateKey(owner, input.requestKey)!.record;
82
+ expect(failed).toMatchObject({ state: "failed", attempts: [{ state: "failed" }], operations: [{ kind: "fork", state: "failed", errorCode: receipt.code }] });
83
+ expect(failed.activeAttemptId).toBeUndefined();
84
+ expect(f.db.get<{ active: string | null }>("SELECT active_attempt_id active FROM side_chats WHERE side_chat_id=?1", failed.sideChatId)?.active).toBeNull();
85
+ expect(await second.create(owner, input)).toMatchObject({ sideChatId: failed.sideChatId, state: "failed" });
86
+ expect(f.store.claim(actor())).toBeNull();
87
+ second.close();
88
+ });
89
+
90
+ for (const action of ["cancel", "archive", "purge", "bring-back"] as const)
91
+ for (const receipt of [
92
+ { state: "accepted", errorCode: null, code: null },
93
+ { state: "unsupported", errorCode: "SIDE_THREAD_UNSUPPORTED", code: "SIDE_THREAD_UNSUPPORTED" },
94
+ { state: "failed", errorCode: "SIDE_THREAD_CONFLICT", code: "SIDE_THREAD_CONFLICT" },
95
+ ] as const) test(`${action} consumes a late ${receipt.state} receipt after restart`, async () => {
96
+ const f = fixture();
97
+ const owner = { userId: "user-1", username: "owner", tokenId: "utok-1", kind: "user" as const };
98
+ 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: [] };
99
+ const sideStore = new SideThreadStore(f.db);
100
+ let coordinator = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
101
+ await coordinator.create(owner, input).catch(() => {});
102
+ const fork = f.store.claim(actor())!;
103
+ 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 } });
104
+ await coordinator.create(owner, input).catch(() => {});
105
+ const start = f.store.claim(actor())!;
106
+ 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 } });
107
+ const running = await coordinator.create(owner, input);
108
+ 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 });
109
+ const requestKey = `rk-${action}-${receipt.state}`;
110
+ const invoke = (c: SideThreadCoordinator) => action === "cancel" ? c.cancel(owner, running.sideChatId, { requestKey })
111
+ : action === "archive" ? c.archive(owner, running.sideChatId, { requestKey })
112
+ : action === "purge" ? c.purge(owner, running.sideChatId, { requestKey })
113
+ : c.bringBack(owner, running.sideChatId, { requestKey, destinationThreadId: "source-1" });
114
+ await invoke(coordinator).catch(() => {});
115
+ const command = f.store.claim(actor())!;
116
+ 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 } });
117
+ coordinator.close();
118
+ coordinator = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
119
+ if (receipt.code) {
120
+ await expect(invoke(coordinator)).rejects.toMatchObject({ code: receipt.code });
121
+ await expect(invoke(coordinator)).rejects.toMatchObject({ code: receipt.code });
122
+ } else {
123
+ if (action === "archive" || action === "bring-back") {
124
+ const originalTransaction = f.db.transaction.bind(f.db);
125
+ let failApply = true;
126
+ (f.db as any).transaction = (fn: () => unknown) => {
127
+ if (failApply) { failApply = false; throw new Error("injected local apply failure"); }
128
+ return originalTransaction(fn);
129
+ };
130
+ await expect(invoke(coordinator)).rejects.toMatchObject({ code: "SIDE_THREAD_AMBIGUOUS", status: 202 });
131
+ const unsettled = sideStore.getByCreateKey(owner, input.requestKey)!.record.operations.find((op) => op.kind === action && op.requestKey === requestKey)!;
132
+ expect(["ambiguous", "reconciling"]).toContain(unsettled.state);
133
+ expect(unsettled.state).not.toBe("failed");
134
+ (f.db as any).transaction = originalTransaction;
135
+ coordinator.close();
136
+ coordinator = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
137
+ }
138
+ const [one, two] = await Promise.all([invoke(coordinator), invoke(coordinator)]);
139
+ if (action === "bring-back") expect(one).toEqual({ bringBackId: expect.any(String), destinationTurnId: "destination-life" });
140
+ else expect(one).toMatchObject({ state: action === "cancel" ? "running" : action === "archive" ? "archived" : "purged" });
141
+ expect(two).toEqual(one);
142
+ const hydrated = coordinator.get(owner, running.sideChatId);
143
+ const operation = hydrated.operations.find((op) => op.kind === action && op.requestKey === requestKey)!;
144
+ expect(operation).toMatchObject({ state: "completed", threadId: action === "bring-back" ? "source-1" : "derived-life" });
145
+ if (action === "bring-back") expect(operation.turnId).toBe("destination-life");
146
+ if (action === "cancel") expect(operation.turnId).toBe("turn-life");
147
+ const domainType = action === "bring-back" ? "side_chat.brought_back" : action === "archive" ? "side_chat.archived" : action === "purge" ? "side_chat.purged" : null;
148
+ const afterEvents = coordinator.listEvents(owner, running.sideChatId);
149
+ if (domainType) expect(afterEvents.filter((event) => event.type === domainType)).toHaveLength(1);
150
+ else expect(afterEvents.filter((event) => ["side_chat.brought_back", "side_chat.archived", "side_chat.purged"].includes(event.type))).toHaveLength(0);
151
+ }
152
+ expect(f.store.claim(actor())).toBeNull();
153
+ coordinator.close();
154
+ });
155
+
156
+ test("two coordinators race a late archive receipt but persist one domain event", async () => {
157
+ const f = fixture();
158
+ const owner = { userId: "user-1", username: "owner", tokenId: "utok-1", kind: "user" as const };
159
+ 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: [] };
160
+ const sideStore = new SideThreadStore(f.db);
161
+ const bootstrap = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
162
+ await bootstrap.create(owner, input).catch(() => {});
163
+ 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 } });
164
+ await bootstrap.create(owner, input).catch(() => {});
165
+ 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 } });
166
+ const running = await bootstrap.create(owner, input);
167
+ 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 });
168
+ await bootstrap.archive(owner, running.sideChatId, { requestKey: "rk-multi-archive" }).catch(() => {});
169
+ 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 } });
170
+ bootstrap.close();
171
+ 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 }) });
172
+ const a = new SideThreadCoordinator(sideStore, port(), { enabled: true, authorizeNode: () => true });
173
+ const b = new SideThreadCoordinator(sideStore, port(), { enabled: true, authorizeNode: () => true });
174
+ const [one, two] = await Promise.all([a.archive(owner, running.sideChatId, { requestKey: "rk-multi-archive" }), b.archive(owner, running.sideChatId, { requestKey: "rk-multi-archive" })]);
175
+ expect(one.state).toBe("archived"); expect(two.state).toBe("archived");
176
+ expect(sideStore.events(running.sideChatId).filter((event) => event.type === "side_chat.archived")).toHaveLength(1);
177
+ expect(f.store.claim(actor())).toBeNull();
178
+ a.close(); b.close();
179
+ });
180
+
181
+ test("fails closed on the currently non-atomic PostgreSQL adapter", () => {
182
+ expect(() => new SideThreadCommandStore({ dialect: "postgres" } as any)).toThrow(/atomic SQLite/);
183
+ });
184
+ test("allows exactly one durable terminal applier", () => {
185
+ const f = fixture(); const close = f.port.subscribe(async () => {});
186
+ expect(() => f.port.subscribe(async () => {})).toThrow(/exactly one/);
187
+ close();
188
+ });
189
+ test("queues outside inbox/FIFO and only exact node token can retain the delivery", async () => {
190
+ const f = fixture();
191
+ 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" });
192
+ expect(f.db.get<{ n: number }>("SELECT COUNT(*) n FROM inbox")!.n).toBe(0);
193
+ expect(f.store.claim({ ...actor(), nodeId: "node-2" })).toBeNull();
194
+ const delivered = f.store.claim(actor())!;
195
+ expect(delivered).toMatchObject({ protocol: "side_thread.command.v1", operationId: "op-1", nodeId: "node-1" });
196
+ expect(f.store.claim(actor())).toEqual(delivered);
197
+ });
198
+
199
+ test("ACK response loss is idempotent and foreign token/collision cannot rewrite it", async () => {
200
+ const f = fixture();
201
+ 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(() => {});
202
+ const command = f.store.claim(actor())!;
203
+ 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 } };
204
+ expect(f.store.ack(actor(), ack).idempotent).toBe(false);
205
+ expect(f.store.ack(actor(), ack).idempotent).toBe(true);
206
+ expect(() => f.store.ack(actor("tok-foreign"), ack)).toThrow(/ownership/);
207
+ expect(() => f.store.ack(actor(), { ...ack, state: "failed", errorCode: "SIDE_THREAD_CONFLICT" })).toThrow(/immutable/);
208
+ });
209
+
210
+ test("terminal receipt requires exact side/attempt/thread/turn tuple and is replay-safe", async () => {
211
+ const f = fixture(); const events: any[] = []; f.port.subscribe((e) => events.push(e));
212
+ 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(() => {});
213
+ const command = f.store.claim(actor())!;
214
+ 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 } });
215
+ 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 };
216
+ await expect(f.port.acceptTerminal(actor(), { ...terminal, turnId: "turn-foreign" })).rejects.toThrow(/four-tuple/);
217
+ expect((await f.port.acceptTerminal(actor(), terminal)).idempotent).toBe(false);
218
+ expect((await f.port.acceptTerminal(actor(), terminal)).idempotent).toBe(true);
219
+ expect(events).toHaveLength(1);
220
+ });
221
+
222
+ test("durable accepted receipt reconciles a later POST retry without a second delivery", async () => {
223
+ 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" } };
224
+ await f.port.fork(input).catch(() => {});
225
+ const command = f.store.claim(actor())!;
226
+ 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 } });
227
+ expect(await f.port.fork(input)).toEqual({ threadId: "derived-1" });
228
+ expect(f.store.claim(actor())).toBeNull();
229
+ });
230
+
231
+ test("HTTP surface binds path node and ACK body to the authenticated node actor", async () => {
232
+ const f = fixture();
233
+ 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(() => {});
234
+ 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 });
235
+ expect(foreign?.status).toBe(403);
236
+ 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 });
237
+ const command = (await pulled!.json() as any).command;
238
+ const badBody = { protocol: "side_thread.ack.v1", commandId: "other-command", operationId: command.operationId, state: "accepted", errorCode: null, result: { threadId: "derived", turnId: null, destinationTurnId: null } };
239
+ 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 });
240
+ expect(rejected?.status).toBe(409);
241
+ });
242
+
243
+ test("hostile node cannot smuggle extra receipt fields or unbounded terminal text", async () => {
244
+ const f = fixture(); f.port.subscribe(async () => {});
245
+ 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(() => {});
246
+ const command = f.store.claim(actor())!;
247
+ 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 } };
248
+ expect(() => f.store.ack(actor(), { ...clean, exception: "Bearer ntok_secret" })).toThrow(/unexpected protocol fields/);
249
+ f.store.ack(actor(), clean);
250
+ 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 };
251
+ await expect(f.port.acceptTerminal(actor(), { ...terminal, stack: "secret" })).rejects.toThrow(/unexpected protocol fields/);
252
+ await expect(f.port.acceptTerminal(actor(), { ...terminal, text: "x".repeat(1_000_001) })).rejects.toThrow(/completed terminal payload/);
253
+ expect((await f.port.acceptTerminal(actor(), terminal)).idempotent).toBe(false);
254
+ });
255
+
256
+ test("receipt commit followed by listener failure remains durably redeliverable", async () => {
257
+ const f = fixture(); let calls = 0;
258
+ f.port.subscribe(async () => { calls++; if (calls === 1) throw new Error("coordinator crashed"); });
259
+ 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(() => {});
260
+ const command = f.store.claim(actor())!;
261
+ 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 } });
262
+ 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 };
263
+ await expect(f.port.acceptTerminal(actor(), terminal)).rejects.toThrow(/coordinator crashed/);
264
+ expect((await f.port.acceptTerminal(actor(), terminal)).pending).toBe(false);
265
+ expect(calls).toBe(2);
266
+ });
267
+
268
+ test("concurrent terminal POST cannot acknowledge WAL deletion while apply is pending", async () => {
269
+ const f = fixture(); let release!: () => void;
270
+ const gate = new Promise<void>((resolve) => { release = resolve; });
271
+ f.port.subscribe(async () => { await gate; throw new Error("late apply failure"); });
272
+ 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(() => {});
273
+ const command = f.store.claim(actor())!;
274
+ 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 } });
275
+ 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 };
276
+ const url = new URL("http://hub/api/nodes/node-1/side-thread-commands/terminals");
277
+ const call = () => handleSideThreadCommandRequest({ req: new Request(url, { method: "POST", body: JSON.stringify(terminal) }), url, actor: actor(), store: f.store, port: f.port });
278
+ const first = call(); await Bun.sleep(10);
279
+ const second = await call();
280
+ expect(second?.status).toBe(503);
281
+ expect(await second?.json()).toMatchObject({ ok: false, retryable: true });
282
+ release();
283
+ expect((await first)?.status).toBe(409);
284
+ expect(f.store.terminalApplyState(["side-concurrent", "attempt-concurrent", "derived-concurrent", "turn-concurrent"])).toBe("received");
285
+ });
286
+
287
+ test("real coordinator database failure rejects apply and leaves receipt retryable", async () => {
288
+ const f = fixture();
289
+ const sideStore = new SideThreadStore(f.db);
290
+ const coordinator = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
291
+ 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(() => {});
292
+ const command = f.store.claim(actor())!;
293
+ 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 } });
294
+ const original = f.db.transaction.bind(f.db);
295
+ (f.db as any).transaction = () => { throw new Error("disk I/O failure"); };
296
+ 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 };
297
+ await expect(f.port.acceptTerminal(actor(), terminal)).rejects.toThrow(/disk I\/O/);
298
+ expect(f.store.terminalApplyState(["side-db", "attempt-db", "derived-db", "turn-db"])).toBe("received");
299
+ (f.db as any).transaction = original;
300
+ coordinator.close();
301
+ });
302
+ });