@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.40

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 (117) 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 +84 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-fallback.test.ts +267 -0
  10. package/src/api-nodes-shape.test.ts +107 -13
  11. package/src/auth.ts +93 -14
  12. package/src/avatar-validate.test.ts +163 -0
  13. package/src/avatar-validate.ts +103 -0
  14. package/src/boot-side-effect.test.ts +124 -0
  15. package/src/config-apply-sec1.test.ts +107 -0
  16. package/src/config-apply-validate.test.ts +92 -0
  17. package/src/config-apply-validate.ts +81 -3
  18. package/src/create-node-tool-schema.test.ts +39 -0
  19. package/src/create-node-validate.test.ts +80 -1
  20. package/src/create-node-validate.ts +49 -5
  21. package/src/create-node.ts +51 -1
  22. package/src/dashboard-slash-routing-http.test.ts +103 -0
  23. package/src/db-adapter-guard.test.ts +85 -0
  24. package/src/db-adapter.ts +64 -23
  25. package/src/db-timestamp.test.ts +38 -0
  26. package/src/db-timestamp.ts +15 -0
  27. package/src/db.ts +622 -33
  28. package/src/delivered-without-push.test.ts +140 -0
  29. package/src/desktop-message.test.ts +286 -0
  30. package/src/ext-token-shared.test.ts +60 -0
  31. package/src/ext-validation-regression.test.ts +131 -0
  32. package/src/external-schedule-edits-http.test.ts +316 -0
  33. package/src/external-schedule-edits.ts +314 -0
  34. package/src/file-download-authz-dev-open.test.ts +135 -0
  35. package/src/file-download-authz.test.ts +403 -0
  36. package/src/file-download-date-bucket.test.ts +436 -0
  37. package/src/file-network-scope-dev-open.test.ts +97 -0
  38. package/src/file-network-scope.test.ts +731 -0
  39. package/src/health-redaction.test.ts +637 -0
  40. package/src/inbox-count-sse.test.ts +310 -0
  41. package/src/inbox-count.ts +29 -0
  42. package/src/index.ts +15 -2187
  43. package/src/lifecycle-guard.ts +50 -0
  44. package/src/list-host-supervisors.test.ts +314 -0
  45. package/src/mcp-write-network-resolution.test.ts +429 -0
  46. package/src/network-name-http.test.ts +71 -0
  47. package/src/network-scope.ts +101 -0
  48. package/src/node-attrs-validate.ts +88 -0
  49. package/src/node-attrs.test.ts +213 -0
  50. package/src/node-id-alias.test.ts +147 -0
  51. package/src/node-lifecycle-controllable.test.ts +64 -0
  52. package/src/node-lifecycle-controllable.ts +51 -0
  53. package/src/observer-avatar-http.test.ts +424 -0
  54. package/src/observer-push.test.ts +252 -0
  55. package/src/package-version-consistency.test.ts +25 -0
  56. package/src/peer-reply-atomic.test.ts +455 -0
  57. package/src/peer-reply-capability.test.ts +70 -0
  58. package/src/probe-validate.test.ts +221 -0
  59. package/src/probe-validate.ts +104 -0
  60. package/src/probe.test.ts +80 -0
  61. package/src/probe.ts +200 -0
  62. package/src/push.ts +163 -7
  63. package/src/reply-target-derivation.test.ts +96 -0
  64. package/src/require-explicit-test-db.ts +9 -0
  65. package/src/resolve-port.test.ts +57 -0
  66. package/src/resolve-port.ts +44 -0
  67. package/src/response-charset.test.ts +130 -0
  68. package/src/rest-explicit-columns-http.test.ts +212 -0
  69. package/src/rest-identity.test.ts +129 -0
  70. package/src/rest-identity.ts +100 -0
  71. package/src/rest-projections.ts +67 -0
  72. package/src/rest-write-network-resolution.test.ts +133 -0
  73. package/src/runtime-label.test.ts +22 -0
  74. package/src/runtime-label.ts +21 -0
  75. package/src/scheduled-run-terminal.test.ts +268 -0
  76. package/src/scheduled-tasks-http.test.ts +479 -0
  77. package/src/scheduled-tasks.ts +549 -0
  78. package/src/send-reply-agent-warning.test.ts +202 -0
  79. package/src/send-reply-alias-optional.test.ts +131 -0
  80. package/src/send-reply-attachments.test.ts +437 -0
  81. package/src/server.ts +3591 -0
  82. package/src/shared/external-schedule-contract.ts +60 -0
  83. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  84. package/src/shared/probe-host-allowlist.ts +128 -0
  85. package/src/side-thread-command-transport.test.ts +302 -0
  86. package/src/side-thread-command-transport.ts +318 -0
  87. package/src/side-thread-http-integration.test.ts +229 -0
  88. package/src/side-thread-http.ts +504 -0
  89. package/src/side-thread-production.test.ts +72 -0
  90. package/src/side-thread-production.ts +118 -0
  91. package/src/side-thread.test.ts +1067 -0
  92. package/src/side-thread.ts +2495 -0
  93. package/src/skillhub-http.test.ts +88 -0
  94. package/src/start-node.test.ts +109 -0
  95. package/src/stop-delete-node.test.ts +952 -0
  96. package/src/task-auth-origin-http.test.ts +87 -0
  97. package/src/task-auth-origin.test.ts +24 -0
  98. package/src/task-auth-origin.ts +7 -0
  99. package/src/task-consumption.test.ts +471 -0
  100. package/src/task-diagnostic-http.test.ts +145 -0
  101. package/src/task-diagnostic.test.ts +115 -0
  102. package/src/task-diagnostic.ts +118 -0
  103. package/src/task-idempotency-mcp.test.ts +142 -0
  104. package/src/task-idempotency.test.ts +60 -0
  105. package/src/task-idempotency.ts +75 -0
  106. package/src/task-lifecycle-watcher.test.ts +200 -0
  107. package/src/task-lifecycle-watcher.ts +49 -0
  108. package/src/task-network-resolution-http.test.ts +137 -0
  109. package/src/tasks-pagination-http.test.ts +137 -0
  110. package/src/tools.ts +2952 -191
  111. package/src/update-provider.test.ts +234 -0
  112. package/src/upload-early-reject-guard.test.ts +155 -0
  113. package/src/uploads-http.test.ts +92 -14
  114. package/src/uploads.test.ts +66 -0
  115. package/src/uploads.ts +130 -5
  116. package/src/vault.test.ts +200 -0
  117. package/src/vault.ts +156 -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,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import {
5
+ VENDOR_HOST_ALLOWLIST as HUB_ALLOW,
6
+ FORBIDDEN_IPV4_RE as HUB_V4,
7
+ FORBIDDEN_IPV6_RE as HUB_V6,
8
+ } from "./probe-host-allowlist.js";
9
+
10
+ // RFC-028 P1 fold-in #1 — hub-and-daemon probe-host-allowlist drift
11
+ // guard. Mirrors the G9 reserved-env drift test (RFC-026 §4.4.7).
12
+ // Two enforcement modes:
13
+ // 1. byte-identical source files
14
+ // 2. runtime set-equality of the imported constants (vendor regex
15
+ // source strings + private-IP regex source strings)
16
+ // If a future contributor edits one side without the other, this test
17
+ // breaks the merge — the daemon-side compromised-hub defense depends
18
+ // on the two lists being identical.
19
+
20
+ const HUB_PATH = join(import.meta.dir, "probe-host-allowlist.ts");
21
+ const DAEMON_PATH = join(
22
+ import.meta.dir,
23
+ "..", "..", "..", "agent-node", "src", "shared", "probe-host-allowlist.ts",
24
+ );
25
+
26
+ describe("RFC-028 P1 — hub/daemon probe-host-allowlist drift guard", () => {
27
+ test("byte-identical source files", () => {
28
+ const hub = readFileSync(HUB_PATH, "utf-8");
29
+ const daemon = readFileSync(DAEMON_PATH, "utf-8");
30
+ expect(daemon).toBe(hub);
31
+ });
32
+
33
+ test("VENDOR_HOST_ALLOWLIST regex sources equal at runtime", async () => {
34
+ const daemonMod = await import(DAEMON_PATH);
35
+ const daemonAllow = daemonMod.VENDOR_HOST_ALLOWLIST as Record<string, ReadonlyArray<RegExp>>;
36
+ // vendor set equality
37
+ expect(new Set(Object.keys(daemonAllow))).toEqual(new Set(Object.keys(HUB_ALLOW)));
38
+ // per-vendor regex source array equality (order matters — both should
39
+ // be authored in identical order to keep diffs minimal)
40
+ for (const vendor of Object.keys(HUB_ALLOW)) {
41
+ const hubSources = HUB_ALLOW[vendor].map(r => r.source);
42
+ const daemonSources = daemonAllow[vendor].map(r => r.source);
43
+ expect(daemonSources).toEqual(hubSources);
44
+ }
45
+ });
46
+
47
+ test("FORBIDDEN_IPV4_RE + FORBIDDEN_IPV6_RE sources equal at runtime", async () => {
48
+ const daemonMod = await import(DAEMON_PATH);
49
+ const daemonV4 = (daemonMod.FORBIDDEN_IPV4_RE as ReadonlyArray<RegExp>).map(r => r.source);
50
+ const daemonV6 = (daemonMod.FORBIDDEN_IPV6_RE as ReadonlyArray<RegExp>).map(r => r.source);
51
+ expect(daemonV4).toEqual(HUB_V4.map(r => r.source));
52
+ expect(daemonV6).toEqual(HUB_V6.map(r => r.source));
53
+ });
54
+ });
@@ -0,0 +1,128 @@
1
+ // RFC-028 P1 §4.4 — shared probe SSRF guards.
2
+ // Host allowlist + private IP block + validateBaseUrl.
3
+ //
4
+ // MUST be byte-identical between hub (server/src/shared/) and daemon
5
+ // (agent-node/src/shared/). Enforced by probe-host-allowlist-drift.test.ts
6
+ // (same pattern as reserved-env.ts G9 drift guard, RFC-026 §4.4.7).
7
+ //
8
+ // Why mirror not symlink: hub and daemon are separate npm packages
9
+ // installed independently on different machines. A drift-checked source
10
+ // copy survives `npm pack` + global install; a symlink does not.
11
+ //
12
+ // Threat model the daemon re-check defends against:
13
+ // - Compromised hub records a malicious base_url in providers/spec
14
+ // - Hub bypassed validateBaseUrl (regression / different code path)
15
+ // - On-wire injection between hub→daemon SSE
16
+ // Daemon trusts process.execPath + this file's content, NOT what
17
+ // `get_probe_request` claims. If hub-side check is identical, daemon
18
+ // re-check is a no-op; if hub-side ever loosens, daemon stays strict.
19
+
20
+ export class ProbeValidationError extends Error {
21
+ constructor(public code: string, public detail?: Record<string, unknown>) {
22
+ super(`${code}${detail ? ` ${JSON.stringify(detail)}` : ""}`);
23
+ this.name = "ProbeValidationError";
24
+ }
25
+ }
26
+
27
+ // ── §4.4.1 per-vendor host allowlist ──────────────────────────────
28
+ // `anthropic` vendor accepts Anthropic + curated 3rd-party Anthropic-
29
+ // compatible endpoints that Vincent / N站马 ship live providers
30
+ // against (DeepSeek + MiniMax both expose `/v1/messages` with the
31
+ // Anthropic protocol surface). Whitelist semantics preserved — NOT
32
+ // allow-any. New entries land here via PR with security review
33
+ // (network-admin-configurable allowlist is backlog: needs strong
34
+ // IP-range / shape guard before admins can edit).
35
+ //
36
+ // Per 通信龙 (RFC-028 P1.5+): the host whitelist is defense layer 1;
37
+ // layer 2 is the DNS-resolved IP block (FORBIDDEN_IPV{4,6}_RE below),
38
+ // applied AFTER allowlist passes. Any new host gets BOTH layers
39
+ // automatically — host in allowlist + DNS resolves to private/metadata
40
+ // IP still surfaces `probe_resolve_unsafe_ip`.
41
+ //
42
+ // P2 will add vendors (openai/zai/openrouter/qwen) as separate keys.
43
+ export const VENDOR_HOST_ALLOWLIST: Record<string, ReadonlyArray<RegExp>> = {
44
+ anthropic: [
45
+ /^api\.anthropic\.com$/,
46
+ /^api\.deepseek\.com$/, // Anthropic-compatible /v1/messages
47
+ /^api\.minimax\.chat$/, // MiniMax legacy domain
48
+ /^api\.minimax\.io$/, // MiniMax new domain (Vincent live use)
49
+ ],
50
+ // P2:
51
+ // openai: [/^api\.openai\.com$/],
52
+ // zai: [/^api\.z\.ai$/, /^open\.bigmodel\.cn$/],
53
+ // openrouter: [/^openrouter\.ai$/],
54
+ // qwen: [/^dashscope(-intl)?\.aliyuncs\.com$/],
55
+ };
56
+ export const SUPPORTED_VENDORS = Object.keys(VENDOR_HOST_ALLOWLIST);
57
+
58
+ // ── §4.4.2 private/reserved IP block (anti SSRF) ──────────────────
59
+ export const FORBIDDEN_IPV4_RE: ReadonlyArray<RegExp> = [
60
+ /^10\./,
61
+ /^127\./,
62
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
63
+ /^192\.168\./,
64
+ /^169\.254\./, // link-local + 169.254.169.254 cloud metadata
65
+ /^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./, // CGNAT
66
+ /^0\./,
67
+ /^22[4-9]\./, /^23[0-9]\./, // multicast
68
+ /^24[0-9]\./, /^25[0-5]\./, // experimental
69
+ ];
70
+ export const FORBIDDEN_IPV6_RE: ReadonlyArray<RegExp> = [
71
+ /^::1$/,
72
+ /^::$/,
73
+ /^fe80:/i, /^fc00:/i, /^fd00:/i,
74
+ ];
75
+
76
+ export function isLoopbackHost(host: string): boolean {
77
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
78
+ }
79
+
80
+ export function isForbiddenIp(ip: string): boolean {
81
+ // IPv4-mapped IPv6: ::ffff:10.0.0.1 — recurse on inner v4
82
+ if (ip.toLowerCase().startsWith("::ffff:")) {
83
+ return isForbiddenIp(ip.slice(7));
84
+ }
85
+ // Simple v4/v6 distinction by presence of '.' / ':'
86
+ if (ip.includes(":") && !ip.includes(".")) {
87
+ return FORBIDDEN_IPV6_RE.some(re => re.test(ip));
88
+ }
89
+ return FORBIDDEN_IPV4_RE.some(re => re.test(ip));
90
+ }
91
+
92
+ // ── §4.4.3 validateBaseUrl — boot/pre-fetch URL+host+vendor check ─
93
+ /** Pure, no I/O. Called by hub (upsert_provider write path) AND
94
+ * daemon (handleProbeDoorbell re-check after get_probe_request).
95
+ * allowLoopback: dev-only opt-in honoring http://localhost / 127.0.0.1.
96
+ * Production daemon MUST NOT set allowLoopback=true. */
97
+ export function validateBaseUrl(
98
+ vendor: string,
99
+ baseUrl: string,
100
+ opts: { allowLoopback?: boolean } = {},
101
+ ): void {
102
+ let u: URL;
103
+ try { u = new URL(baseUrl); }
104
+ catch {
105
+ throw new ProbeValidationError("probe_base_url_invalid", {
106
+ reason: "not a valid URL", baseUrl: baseUrl.slice(0, 100),
107
+ });
108
+ }
109
+
110
+ if (u.protocol !== "https:") {
111
+ if (!opts.allowLoopback || !isLoopbackHost(u.hostname) || u.protocol !== "http:") {
112
+ throw new ProbeValidationError("probe_base_url_invalid", {
113
+ reason: "must be https (or http+loopback with dev opt-in)",
114
+ });
115
+ }
116
+ }
117
+ const allowed = VENDOR_HOST_ALLOWLIST[vendor];
118
+ if (!allowed) {
119
+ throw new ProbeValidationError("vendor_not_supported", {
120
+ vendor, supported: SUPPORTED_VENDORS,
121
+ });
122
+ }
123
+ if (!allowed.some(re => re.test(u.hostname))) {
124
+ throw new ProbeValidationError("probe_target_forbidden", {
125
+ vendor, host: u.hostname, allowed: allowed.map(r => r.source),
126
+ });
127
+ }
128
+ }
@@ -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
+ });