@sleep2agi/commhub-server 0.9.0-preview.30 → 0.9.0-preview.32

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.
@@ -16,6 +16,168 @@ function fixture() {
16
16
  }
17
17
 
18
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
+
19
181
  test("fails closed on the currently non-atomic PostgreSQL adapter", () => {
20
182
  expect(() => new SideThreadCommandStore({ dialect: "postgres" } as any)).toThrow(/atomic SQLite/);
21
183
  });
@@ -225,6 +225,7 @@ export class DurableSideThreadCommandPort implements SideThreadExecutionPort {
225
225
  networkForNode: (nodeId: string) => string | null;
226
226
  capabilityForNode: (nodeId: string) => SideThreadCapability;
227
227
  grantAttachment: (nodeId: string, ref: SideThreadAttachmentRef) => Record<string, unknown>;
228
+ ackWaitMs?: number;
228
229
  }) {}
229
230
  capability(nodeId: string) { return this.opts.capabilityForNode(nodeId); }
230
231
  fork(input: Parameters<SideThreadExecutionPort["fork"]>[0]) { return this.issue(input, "fork", { sourceThreadId: input.sourceThreadId, boundary: input.boundary }, "threadId") as Promise<{threadId:string}>; }
@@ -261,10 +262,18 @@ export class DurableSideThreadCommandPort implements SideThreadExecutionPort {
261
262
  requestKey: input.requestKey ?? input.operationId, nodeId: input.nodeId, sideThreadId: input.sideChatId,
262
263
  attemptId: input.attemptId ?? null, kind, payload };
263
264
  this.opts.store.enqueue(command, { networkId, nodeId: input.nodeId });
264
- const receipt = this.opts.store.receipt(input.operationId);
265
+ const deadline = Date.now() + (this.opts.ackWaitMs ?? 0);
266
+ let receipt = this.opts.store.receipt(input.operationId);
267
+ while (!receipt && Date.now() < deadline) {
268
+ await new Promise((resolve) => setTimeout(resolve, 10));
269
+ receipt = this.opts.store.receipt(input.operationId);
270
+ }
265
271
  if (!receipt) throw new SideThreadError("SIDE_THREAD_AMBIGUOUS", "command durably queued; awaiting node ACK", 202, input.operationId, input.sideChatId, input.attemptId);
266
272
  if (receipt.state === "accepted") return resultKey ? { [resultKey]: object(receipt.result)[resultKey] } : undefined;
267
- throw new SideThreadError(receipt.errorCode === "SIDE_THREAD_UNSUPPORTED" ? "SIDE_THREAD_UNSUPPORTED" : "SIDE_THREAD_AMBIGUOUS", "node command did not complete synchronously", receipt.errorCode === "SIDE_THREAD_UNSUPPORTED" ? 501 : 202, input.operationId, input.sideChatId, input.attemptId);
273
+ const code = receipt.state === "unsupported" ? "SIDE_THREAD_UNSUPPORTED"
274
+ : receipt.state === "failed" ? "SIDE_THREAD_CONFLICT" : "SIDE_THREAD_AMBIGUOUS";
275
+ const status = code === "SIDE_THREAD_UNSUPPORTED" ? 501 : code === "SIDE_THREAD_CONFLICT" ? 409 : 202;
276
+ throw new SideThreadError(code, "node command returned an authoritative result", status, input.operationId, input.sideChatId, input.attemptId);
268
277
  }
269
278
  }
270
279
 
@@ -0,0 +1,72 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { SQLiteAdapter } from "./db-adapter";
7
+ import { createProductionSideThreadTransport } from "./side-thread-production";
8
+
9
+ const roots: string[] = [];
10
+ const priorUploads = process.env.COMMHUB_UPLOADS_DIR;
11
+ afterEach(() => {
12
+ if (priorUploads === undefined) delete process.env.COMMHUB_UPLOADS_DIR;
13
+ else process.env.COMMHUB_UPLOADS_DIR = priorUploads;
14
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("production Hub SideThread wiring", () => {
18
+ test("advertises only an exact node snapshot and serves a short-lived node-bound grant", async () => {
19
+ const root = mkdtempSync(join(tmpdir(), "side-hub-prod-")); roots.push(root);
20
+ process.env.COMMHUB_UPLOADS_DIR = root;
21
+ const db = new SQLiteAdapter(new Database(":memory:"));
22
+ db.exec("CREATE TABLE nodes(node_id TEXT PRIMARY KEY, network_id TEXT, config_snapshot TEXT)");
23
+ db.run("INSERT INTO nodes VALUES (?1,?2,?3)", ["node-1", "net-1", JSON.stringify({ side_thread_capability: {
24
+ supported: true, runtime: "codex-app-server", runtimeVersion: "0.148.0", topology: "owned-stdio",
25
+ evidenceRevision: "test1190-wire-v2", mode: "native-exact-fork", exactBoundary: { through: true, before: false },
26
+ } })]);
27
+ let now = 1_000;
28
+ const transport = createProductionSideThreadTransport(db, () => now, 0);
29
+ expect(transport.port.capability("node-1")).toMatchObject({ supported: true, exactBoundary: { before: false } });
30
+ expect(transport.port.capability("missing")).toMatchObject({ supported: false, reason: "runtime" });
31
+
32
+ const fileId = "file12345678"; const bytes = Buffer.from("image bytes"); const bucket = "2026-08-26";
33
+ mkdirSync(join(root, ".index"), { recursive: true }); mkdirSync(join(root, bucket), { recursive: true });
34
+ writeFileSync(join(root, bucket, `${fileId}.png`), bytes);
35
+ writeFileSync(join(root, ".index", `${fileId}.json`), JSON.stringify({
36
+ file_id: fileId, date_bucket: bucket, ext: ".png", name: "x.png", mime: "image/png",
37
+ size: bytes.length, owner_id: "user-1", network_id: "net-1", uploaded_at: new Date().toISOString(),
38
+ }));
39
+ await transport.port.start({
40
+ operationId: "op-start", requestKey: "rk-start", sideChatId: "side-1", attemptId: "attempt-1",
41
+ nodeId: "node-1", threadId: "derived-1", prompt: "read it",
42
+ attachments: [{ fileId, mediaType: "image/png", size: bytes.length }],
43
+ }).catch(() => {});
44
+ const command = transport.store.claim({ tokenId: "token-1", networkId: "net-1", nodeId: "node-1" }) as any;
45
+ const grant = command.payload.attachments[0];
46
+ expect(grant).toMatchObject({ fileId, mediaType: "image/png", size: bytes.length });
47
+ expect(grant.sha256).toMatch(/^[a-f0-9]{64}$/);
48
+ const url = new URL(`http://hub/api/side-thread/attachment-grants/${grant.grantId}`);
49
+ const foreign = await transport.attachment(new Request(url), url, { tokenId: "foreign", networkId: "net-1", nodeId: "node-2" });
50
+ expect(foreign?.status).toBe(404);
51
+ const response = await transport.attachment(new Request(url), url, { tokenId: "token-1", networkId: "net-1", nodeId: "node-1" });
52
+ expect(response?.status).toBe(200); expect(Buffer.from(await response!.arrayBuffer())).toEqual(bytes);
53
+ now += 5 * 60_000 + 1;
54
+ expect((await transport.attachment(new Request(url), url, { tokenId: "token-1", networkId: "net-1", nodeId: "node-1" }))?.status).toBe(404);
55
+ await transport.port.start({
56
+ operationId: "op-start-2", requestKey: "rk-start-2", sideChatId: "side-2", attemptId: "attempt-2",
57
+ nodeId: "node-1", threadId: "derived-2", prompt: "read it again",
58
+ attachments: [{ fileId, mediaType: "image/png", size: bytes.length }],
59
+ }).catch(() => {});
60
+ expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM side_thread_attachment_grants")?.count).toBe(1);
61
+ });
62
+
63
+ test("rejects forged capability shapes instead of widening the allowlist", () => {
64
+ const db = new SQLiteAdapter(new Database(":memory:"));
65
+ db.exec("CREATE TABLE nodes(node_id TEXT PRIMARY KEY, network_id TEXT, config_snapshot TEXT)");
66
+ db.run("INSERT INTO nodes VALUES (?1,?2,?3)", ["node-1", "net-1", JSON.stringify({ side_thread_capability: {
67
+ supported: true, runtime: "codex-app-server", runtimeVersion: "latest", topology: "owned-stdio",
68
+ evidenceRevision: "test1190-wire-v2", mode: "native-exact-fork", exactBoundary: { through: true, before: true },
69
+ } })]);
70
+ expect(createProductionSideThreadTransport(db).port.capability("node-1")).toMatchObject({ supported: false });
71
+ });
72
+ });
@@ -0,0 +1,118 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import type { DbAdapter } from "./db-adapter.js";
4
+ import { indexEntryPath, pathForExistingBlob, validateIndexEntry } from "./uploads.js";
5
+ import type { SideThreadAttachmentRef, SideThreadCapability } from "./side-thread.js";
6
+ import {
7
+ DurableSideThreadCommandPort,
8
+ SideThreadCommandStore,
9
+ handleSideThreadCommandRequest,
10
+ type NodeCommandActor,
11
+ } from "./side-thread-command-transport.js";
12
+
13
+ const GRANT_TTL_MS = 5 * 60_000;
14
+
15
+ type GrantRow = {
16
+ grant_id: string; file_id: string; network_id: string; node_id: string;
17
+ sha256: string; size: number; media_type: string; expires_at: number;
18
+ };
19
+
20
+ /** Production-only SideThread transport. It remains absent unless the public
21
+ * feature flag is enabled and the configured DB can provide atomic claims. */
22
+ export function createProductionSideThreadTransport(db: DbAdapter, now = Date.now, ackWaitMs = 2_500) {
23
+ const store = new SideThreadCommandStore(db, now);
24
+ db.exec(`CREATE TABLE IF NOT EXISTS side_thread_attachment_grants (
25
+ grant_id TEXT PRIMARY KEY, file_id TEXT NOT NULL, network_id TEXT NOT NULL,
26
+ node_id TEXT NOT NULL, sha256 TEXT NOT NULL, size INTEGER NOT NULL,
27
+ media_type TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL
28
+ )`);
29
+ const port = new DurableSideThreadCommandPort({
30
+ store,
31
+ networkForNode(nodeId) {
32
+ return db.get<{ network_id: string | null }>("SELECT network_id FROM nodes WHERE node_id=?1", nodeId)?.network_id ?? null;
33
+ },
34
+ capabilityForNode(nodeId) {
35
+ const row = db.get<{ config_snapshot: string | null }>("SELECT config_snapshot FROM nodes WHERE node_id=?1", nodeId);
36
+ if (!row?.config_snapshot) return unsupported("runtime");
37
+ try {
38
+ const cap = JSON.parse(row.config_snapshot)?.side_thread_capability;
39
+ return validateCapability(cap) ? cap : unsupported("runtime");
40
+ } catch { return unsupported("runtime"); }
41
+ },
42
+ grantAttachment(nodeId, ref) { return issueGrant(db, nodeId, ref, now); },
43
+ // The dedicated node consumer polls asynchronously. Give its durable ACK
44
+ // a bounded window so the coordinator can advance fork→start atomically;
45
+ // timeout remains an honest ambiguous result, never a task fallback.
46
+ ackWaitMs,
47
+ });
48
+ return {
49
+ store,
50
+ port,
51
+ handle: (input: { req: Request; url: URL; actor: NodeCommandActor | null }) =>
52
+ handleSideThreadCommandRequest({ ...input, store, port }),
53
+ attachment: (req: Request, url: URL, actor: NodeCommandActor | null) =>
54
+ serveGrant(db, req, url, actor, now),
55
+ };
56
+ }
57
+
58
+ function issueGrant(db: DbAdapter, nodeId: string, ref: SideThreadAttachmentRef, now: () => number): Record<string, unknown> {
59
+ // Grants are deliberately short-lived and must not become an unbounded
60
+ // metadata cache. Lazy sweeping keeps the hot download path read-only while
61
+ // ensuring every new issuance bounds accumulated expired rows.
62
+ db.run("DELETE FROM side_thread_attachment_grants WHERE expires_at < ?1", [now()]);
63
+ const node = db.get<{ network_id: string | null }>("SELECT network_id FROM nodes WHERE node_id=?1", nodeId);
64
+ if (!node?.network_id) throw new Error("SideThread attachment node scope unavailable");
65
+ const indexPath = indexEntryPath(ref.fileId);
66
+ if (!indexPath) throw new Error("SideThread attachment index unavailable");
67
+ const entry = JSON.parse(readFileSync(indexPath, "utf8"));
68
+ if (!validateIndexEntry(entry) || entry.file_id !== ref.fileId || entry.network_id !== node.network_id)
69
+ throw new Error("SideThread attachment scope mismatch");
70
+ const path = pathForExistingBlob(entry.date_bucket, entry.file_id, entry.ext).absolutePath;
71
+ const bytes = readFileSync(path);
72
+ if (bytes.byteLength !== entry.size) throw new Error("SideThread attachment size changed");
73
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
74
+ const grantId = `stg_${randomUUID().replace(/-/g, "")}`;
75
+ const mediaType = typeof entry.mime === "string" && entry.mime.length <= 200 ? entry.mime : "application/octet-stream";
76
+ db.run(
77
+ "INSERT INTO side_thread_attachment_grants (grant_id,file_id,network_id,node_id,sha256,size,media_type,expires_at,created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
78
+ [grantId, ref.fileId, node.network_id, nodeId, sha256, bytes.byteLength, mediaType, now() + GRANT_TTL_MS, now()],
79
+ );
80
+ return { fileId: ref.fileId, grantId, sha256, size: bytes.byteLength, mediaType };
81
+ }
82
+
83
+ async function serveGrant(db: DbAdapter, req: Request, url: URL, actor: NodeCommandActor | null, now: () => number): Promise<Response | null> {
84
+ const match = url.pathname.match(/^\/api\/side-thread\/attachment-grants\/([^/]+)$/);
85
+ if (!match) return null;
86
+ if (req.method !== "GET") return Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 });
87
+ if (!actor) return Response.json({ ok: false, error: "node_token_binding_required" }, { status: 403 });
88
+ let grantId: string;
89
+ try { grantId = decodeURIComponent(match[1]); } catch { return Response.json({ ok: false, error: "invalid_grant" }, { status: 400 }); }
90
+ const row = db.get<GrantRow>("SELECT * FROM side_thread_attachment_grants WHERE grant_id=?1", grantId);
91
+ // 404 deliberately hides existence, ownership and expiry.
92
+ if (!row || row.node_id !== actor.nodeId || row.network_id !== actor.networkId || row.expires_at < now())
93
+ return Response.json({ ok: false, error: "not_found" }, { status: 404 });
94
+ const indexPath = indexEntryPath(row.file_id);
95
+ if (!indexPath) return Response.json({ ok: false, error: "not_found" }, { status: 404 });
96
+ try {
97
+ const entry = JSON.parse(readFileSync(indexPath, "utf8"));
98
+ if (!validateIndexEntry(entry) || entry.network_id !== row.network_id || entry.size !== row.size)
99
+ return Response.json({ ok: false, error: "not_found" }, { status: 404 });
100
+ const bytes = readFileSync(pathForExistingBlob(entry.date_bucket, entry.file_id, entry.ext).absolutePath);
101
+ if (bytes.byteLength !== row.size || createHash("sha256").update(bytes).digest("hex") !== row.sha256)
102
+ return Response.json({ ok: false, error: "not_found" }, { status: 404 });
103
+ return new Response(bytes, { headers: { "Content-Type": row.media_type, "Content-Length": String(row.size), "Cache-Control": "no-store" } });
104
+ } catch { return Response.json({ ok: false, error: "not_found" }, { status: 404 }); }
105
+ }
106
+
107
+ function validateCapability(value: unknown): value is SideThreadCapability {
108
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
109
+ const v = value as Record<string, unknown>;
110
+ return v.supported === true && v.runtime === "codex-app-server" && v.runtimeVersion === "0.148.0"
111
+ && v.topology === "owned-stdio" && v.evidenceRevision === "test1190-wire-v2"
112
+ && v.mode === "native-exact-fork" && !!v.exactBoundary && typeof v.exactBoundary === "object"
113
+ && (v.exactBoundary as any).through === true && typeof (v.exactBoundary as any).before === "boolean";
114
+ }
115
+
116
+ function unsupported(reason: SideThreadCapability["reason"]): SideThreadCapability {
117
+ return { supported: false, runtime: "unknown", runtimeVersion: "unknown", topology: "unknown", evidenceRevision: "none", reason };
118
+ }
@@ -9,6 +9,7 @@ import {
9
9
  UnsupportedSideThreadPort,
10
10
  type SideThreadActor,
11
11
  type SideThreadExecutionPort,
12
+ type SideThreadEventRecord,
12
13
  type SideThreadRuntimeEvent,
13
14
  } from "./side-thread.js";
14
15
  import { handleSideThreadHttpRequest } from "./side-thread-http.js";
@@ -312,6 +313,28 @@ describe("SideThread Hub contract", () => {
312
313
  ).toHaveLength(3);
313
314
  });
314
315
 
316
+ test("throwing observers cannot alter durable truth or starve cursor replay", async () => {
317
+ const f = fixture();
318
+ const record = await f.coordinator.create(owner, createInput());
319
+ const attempt = record.attempts[0];
320
+ const cursor = f.coordinator.listEvents(owner, record.sideChatId).at(-1)!.eventId;
321
+ const delivered: SideThreadEventRecord[] = [];
322
+ const offThrow = f.coordinator.subscribe(record.sideChatId, () => { throw new Error("observer exploded"); });
323
+ const offGood = f.coordinator.subscribe(record.sideChatId, (event) => delivered.push(event));
324
+ f.port.emit({ sideChatId: record.sideChatId, attemptId: attempt.attemptId, threadId: record.threadId!, turnId: attempt.turnId!, status: "completed", text: "durable answer" });
325
+ await Bun.sleep(0);
326
+ expect(f.coordinator.get(owner, record.sideChatId)).toMatchObject({ state: "completed", attempts: [{ result: "durable answer" }] });
327
+ expect(delivered.filter((event) => event.type === "attempt.terminal")).toHaveLength(1);
328
+ const replay = f.coordinator.listEvents(owner, record.sideChatId, cursor);
329
+ expect(replay.filter((event) => event.type === "attempt.terminal")).toHaveLength(1);
330
+ expect(f.coordinator.listEvents(owner, record.sideChatId, replay.at(-1)!.eventId)).toHaveLength(0);
331
+ offThrow(); offGood();
332
+ f.coordinator.close();
333
+ const reopened = new SideThreadCoordinator(f.store, f.port, { enabled: true, authorizeNode: () => true, authorizeAttachment: () => true });
334
+ closers.push(() => reopened.close());
335
+ expect(reopened.listEvents(owner, record.sideChatId, cursor).filter((event) => event.type === "attempt.terminal")).toHaveLength(1);
336
+ });
337
+
315
338
  test("a terminal event arriving before start returns settles once", async () => {
316
339
  const f = fixture();
317
340
  f.port.onStart = (input, turnId) =>
@@ -391,10 +414,10 @@ describe("SideThread Hub contract", () => {
391
414
  closers.push(() => second.close());
392
415
  const firstCreate = f.coordinator.create(owner, createInput());
393
416
  await entered;
394
- const replay = await second.create(owner, createInput());
395
- expect(replay.state).toBe("creating");
396
417
  releaseFork();
397
418
  const created = await firstCreate;
419
+ const replay = await second.create(owner, createInput());
420
+ expect(replay.state).toBe("running");
398
421
  expect(f.port.calls.fork).toBe(1);
399
422
 
400
423
  let cancelEntered!: () => void;
@@ -420,7 +443,9 @@ describe("SideThread Hub contract", () => {
420
443
  expect(concurrent.state).toBe("running");
421
444
  expect(f.port.calls.cancel).toBe(1);
422
445
  releaseCancel();
423
- expect((await firstCancel).state).toBe("cancelled");
446
+ expect((await firstCancel).state).toBe("running");
447
+ const cancelledAttempt = created.attempts[0];
448
+ f.port.emit({ sideChatId: created.sideChatId, attemptId: cancelledAttempt.attemptId, threadId: created.threadId!, turnId: cancelledAttempt.turnId!, status: "interrupted" });
424
449
 
425
450
  let archiveEntered!: () => void;
426
451
  const archiveStarted = new Promise<void>(
@@ -627,7 +652,7 @@ describe("SideThread Hub contract", () => {
627
652
  expect(reopened.list(stranger)).toHaveLength(0);
628
653
  });
629
654
 
630
- test("response loss is durable ambiguous and reconciles without a second start RPC", async () => {
655
+ test("response loss remains ambiguous under the same stable start operation", async () => {
631
656
  const port = new FakePort();
632
657
  let accepted: Parameters<SideThreadExecutionPort["start"]>[0] | undefined;
633
658
  port.start = async (input) => {
@@ -658,11 +683,11 @@ describe("SideThread Hub contract", () => {
658
683
  state: "ambiguous",
659
684
  errorCode: "SIDE_THREAD_RESPONSE_LOST",
660
685
  });
661
- expect(await f.coordinator.create(owner, createInput())).toMatchObject({
686
+ await expect(f.coordinator.create(owner, createInput())).rejects.toMatchObject({
687
+ code: "SIDE_THREAD_AMBIGUOUS",
662
688
  sideChatId: ambiguous.sideChatId,
663
- state: "ambiguous",
664
689
  });
665
- expect(port.calls).toMatchObject({ fork: 1, start: 1 });
690
+ expect(port.calls).toMatchObject({ fork: 1, start: 2 });
666
691
 
667
692
  f.coordinator.close();
668
693
  const reopened = new SideThreadCoordinator(f.store, port, {
@@ -689,13 +714,13 @@ describe("SideThread Hub contract", () => {
689
714
  expect(
690
715
  reconciled.operations.find((operation) => operation.kind === "start"),
691
716
  ).toMatchObject({ state: "completed", turnId: "turn_authoritative" });
692
- expect(port.calls.start).toBe(1);
717
+ expect(port.calls.start).toBe(2);
693
718
  expect(
694
719
  JSON.stringify(reopened.listEvents(owner, ambiguous.sideChatId)),
695
720
  ).not.toContain("secret transport detail");
696
721
  });
697
722
 
698
- test("ambiguous cancel/archive/purge keep stable claims and never issue a second RPC", async () => {
723
+ test("ambiguous cancel/archive/purge replay the same stable operation identity", async () => {
699
724
  const lose = () =>
700
725
  Object.assign(new Error("accepted but ACK lost"), {
701
726
  code: "SIDE_THREAD_RESPONSE_LOST",
@@ -728,7 +753,7 @@ describe("SideThread Hub contract", () => {
728
753
  code: "SIDE_THREAD_AMBIGUOUS",
729
754
  operationId: cancelOperationId,
730
755
  });
731
- expect(cancelPort.calls.cancel).toBe(1);
756
+ expect(cancelPort.calls.cancel).toBe(2);
732
757
 
733
758
  for (const action of ["archive", "purge"] as const) {
734
759
  const port = new FakePort();
@@ -767,7 +792,7 @@ describe("SideThread Hub contract", () => {
767
792
  code: "SIDE_THREAD_AMBIGUOUS",
768
793
  operationId,
769
794
  });
770
- expect(port.calls[action]).toBe(1);
795
+ expect(port.calls[action]).toBe(2);
771
796
  }
772
797
  });
773
798
  });
@@ -919,6 +944,10 @@ describe("SideThread HTTP contract", () => {
919
944
  coordinator: f.coordinator,
920
945
  });
921
946
  expect(response?.status).toBe(200);
947
+ if (action === "cancel") {
948
+ const attempt = record.attempts[0];
949
+ f.port.emit({ sideChatId: record.sideChatId, attemptId: attempt.attemptId, threadId: record.threadId!, turnId: attempt.turnId!, status: "interrupted" });
950
+ }
922
951
  }
923
952
  const hydrated = f.coordinator.get(owner, record.sideChatId);
924
953
  expect(