@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.
- package/package.json +1 -1
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/delivered-without-push.test.ts +140 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/push.ts +11 -0
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/server.ts +23 -3
- package/src/side-thread-command-transport.test.ts +162 -0
- package/src/side-thread-command-transport.ts +11 -2
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +40 -11
- package/src/side-thread.ts +313 -59
- package/src/tools.ts +59 -13
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/side-thread.test.ts
CHANGED
|
@@ -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("
|
|
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
|
|
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(
|
|
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:
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|