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

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.
@@ -0,0 +1,309 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { DbAdapter } from "./db-adapter.js";
3
+ import {
4
+ SideThreadError, type SideThreadAttachmentRef, type SideThreadCapability,
5
+ type SideThreadExecutionPort, type SideThreadRuntimeEvent,
6
+ } from "./side-thread.js";
7
+
8
+ export const SIDE_THREAD_COMMAND_PROTOCOL = "side_thread.command.v1" as const;
9
+ export const SIDE_THREAD_ACK_PROTOCOL = "side_thread.ack.v1" as const;
10
+ export const SIDE_THREAD_TERMINAL_PROTOCOL = "side_thread.terminal.v1" as const;
11
+
12
+ type CommandState = "pending" | "delivered" | "accepted" | "ambiguous" | "failed" | "unsupported";
13
+ type CommandRow = {
14
+ command_id: string; operation_id: string; request_key: string; network_id: string;
15
+ node_id: string; side_chat_id: string; attempt_id: string | null; kind: string;
16
+ fingerprint: string; command_json: string; status: CommandState;
17
+ consumed_by_token: string | null; ack_json: string | null;
18
+ };
19
+
20
+ export type NodeCommandActor = { tokenId: string; networkId: string; nodeId: string };
21
+
22
+ export function installSideThreadCommandSchema(db: DbAdapter): void {
23
+ db.exec(`
24
+ CREATE TABLE IF NOT EXISTS side_thread_commands (
25
+ command_id TEXT PRIMARY KEY, operation_id TEXT NOT NULL UNIQUE,
26
+ request_key TEXT NOT NULL, network_id TEXT NOT NULL, node_id TEXT NOT NULL,
27
+ side_chat_id TEXT NOT NULL, attempt_id TEXT, kind TEXT NOT NULL,
28
+ fingerprint TEXT NOT NULL, command_json TEXT NOT NULL,
29
+ status TEXT NOT NULL DEFAULT 'pending', consumed_by_token TEXT,
30
+ ack_json TEXT, created_at INTEGER NOT NULL, delivered_at INTEGER, acked_at INTEGER
31
+ );
32
+ CREATE INDEX IF NOT EXISTS idx_side_thread_commands_pending
33
+ ON side_thread_commands(network_id,node_id,status,created_at);
34
+ CREATE TABLE IF NOT EXISTS side_thread_terminal_receipts (
35
+ side_chat_id TEXT NOT NULL, attempt_id TEXT NOT NULL, thread_id TEXT NOT NULL,
36
+ turn_id TEXT NOT NULL, status TEXT NOT NULL, envelope_json TEXT NOT NULL,
37
+ consumed_by_token TEXT NOT NULL, created_at INTEGER NOT NULL,
38
+ applying_at INTEGER, applied_at INTEGER,
39
+ PRIMARY KEY(side_chat_id,attempt_id,thread_id,turn_id)
40
+ );
41
+ `);
42
+ for (const sql of [
43
+ "ALTER TABLE side_thread_terminal_receipts ADD COLUMN applying_at INTEGER",
44
+ "ALTER TABLE side_thread_terminal_receipts ADD COLUMN applied_at INTEGER",
45
+ ]) try { db.exec(sql); } catch { /* additive migration already applied */ }
46
+ }
47
+
48
+ export class SideThreadCommandStore {
49
+ constructor(readonly db: DbAdapter, private readonly now = Date.now) {
50
+ // PgAdapter transactions are currently documented non-atomic. Advertising
51
+ // a durable command outbox on it would be false, so fail closed until a
52
+ // real PostgreSQL race gate and single-connection transaction land.
53
+ if (db.dialect !== "sqlite") throw new Error("SideThread command transport requires atomic SQLite transactions");
54
+ installSideThreadCommandSchema(db);
55
+ }
56
+
57
+ enqueue(command: Record<string, unknown>, scope: { networkId: string; nodeId: string }): CommandRow {
58
+ const operationId = id(command.operationId, "operationId");
59
+ const commandId = id(command.commandId, "commandId");
60
+ const sideChatId = id(command.sideThreadId, "sideThreadId");
61
+ const attemptId = command.attemptId === null ? null : id(command.attemptId, "attemptId");
62
+ if (command.protocol !== SIDE_THREAD_COMMAND_PROTOCOL || command.nodeId !== scope.nodeId) throw new Error("invalid command envelope");
63
+ const canonical = JSON.stringify(command);
64
+ const fingerprint = sha(canonical);
65
+ const old = this.db.get<CommandRow>("SELECT * FROM side_thread_commands WHERE operation_id=?1", operationId);
66
+ if (old) {
67
+ if (old.command_id !== commandId || old.fingerprint !== fingerprint || old.network_id !== scope.networkId || old.node_id !== scope.nodeId)
68
+ throw new SideThreadError("SIDE_THREAD_CONFLICT", "operation identity reused with different command", 409, operationId, sideChatId, attemptId ?? undefined);
69
+ return old;
70
+ }
71
+ this.db.run(
72
+ `INSERT INTO side_thread_commands
73
+ (command_id,operation_id,request_key,network_id,node_id,side_chat_id,attempt_id,kind,fingerprint,command_json,status,created_at)
74
+ VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,'pending',?11)`,
75
+ [commandId, operationId, id(command.requestKey, "requestKey"), scope.networkId, scope.nodeId,
76
+ sideChatId, attemptId, id(command.kind, "kind"), fingerprint, canonical, this.now()],
77
+ );
78
+ return this.db.get<CommandRow>("SELECT * FROM side_thread_commands WHERE command_id=?1", commandId)!;
79
+ }
80
+
81
+ claim(actor: NodeCommandActor): Record<string, unknown> | null {
82
+ return this.db.transaction(() => {
83
+ const delivered = this.db.get<CommandRow>(
84
+ "SELECT * FROM side_thread_commands WHERE network_id=?1 AND node_id=?2 AND status='delivered' AND consumed_by_token=?3 ORDER BY delivered_at LIMIT 1",
85
+ actor.networkId, actor.nodeId, actor.tokenId,
86
+ );
87
+ if (delivered) return JSON.parse(delivered.command_json);
88
+ const row = this.db.get<CommandRow>(
89
+ "SELECT * FROM side_thread_commands WHERE network_id=?1 AND node_id=?2 AND status='pending' ORDER BY created_at,command_id LIMIT 1",
90
+ actor.networkId, actor.nodeId,
91
+ );
92
+ if (!row) return null;
93
+ const changed = this.db.run(
94
+ "UPDATE side_thread_commands SET status='delivered',consumed_by_token=?1,delivered_at=?2 WHERE command_id=?3 AND status='pending'",
95
+ [actor.tokenId, this.now(), row.command_id],
96
+ );
97
+ return changed.changes === 1 ? JSON.parse(row.command_json) : null;
98
+ });
99
+ }
100
+
101
+ ack(actor: NodeCommandActor, raw: unknown): { idempotent: boolean; ack: Record<string, unknown> } {
102
+ const ack = object(raw);
103
+ exactKeys(ack, ["protocol", "commandId", "operationId", "state", "errorCode", "result"]);
104
+ if (ack.protocol !== SIDE_THREAD_ACK_PROTOCOL) throw new Error("invalid ack protocol");
105
+ const commandId = id(ack.commandId, "commandId"), operationId = id(ack.operationId, "operationId");
106
+ if (!["accepted", "ambiguous", "failed", "unsupported"].includes(String(ack.state))) throw new Error("invalid ack state");
107
+ const expectedError = ack.state === "accepted" ? null : ack.state === "ambiguous" ? "SIDE_THREAD_AMBIGUOUS"
108
+ : ack.state === "unsupported" ? "SIDE_THREAD_UNSUPPORTED" : "SIDE_THREAD_CONFLICT";
109
+ if (ack.errorCode !== expectedError) throw new Error("invalid ack error code");
110
+ const result = object(ack.result);
111
+ exactKeys(result, ["threadId", "turnId", "destinationTurnId"]);
112
+ for (const key of ["threadId", "turnId", "destinationTurnId"])
113
+ if (result[key] !== null && !isId(result[key])) throw new Error("invalid ack result identity");
114
+ const canonical = JSON.stringify(ack);
115
+ return this.db.transaction(() => {
116
+ const row = this.db.get<CommandRow>("SELECT * FROM side_thread_commands WHERE command_id=?1", commandId);
117
+ if (!row || row.operation_id !== operationId || row.network_id !== actor.networkId || row.node_id !== actor.nodeId
118
+ || row.consumed_by_token !== actor.tokenId) throw new Error("command ownership mismatch");
119
+ if (row.ack_json) {
120
+ if (row.ack_json !== canonical) throw new Error("command ack is immutable");
121
+ return { idempotent: true, ack };
122
+ }
123
+ if (row.status !== "delivered") throw new Error("command was not delivered");
124
+ this.assertAckOwnership(row, ack);
125
+ const changed = this.db.run(
126
+ "UPDATE side_thread_commands SET status=?1,ack_json=?2,acked_at=?3 WHERE command_id=?4 AND status='delivered' AND consumed_by_token=?5",
127
+ [ack.state, canonical, this.now(), commandId, actor.tokenId],
128
+ );
129
+ if (changed.changes !== 1) throw new Error("command ack race");
130
+ return { idempotent: false, ack };
131
+ });
132
+ }
133
+
134
+ terminal(actor: NodeCommandActor, raw: unknown): { idempotent: boolean; event: SideThreadRuntimeEvent; key: [string,string,string,string] } {
135
+ const env = object(raw);
136
+ exactKeys(env, ["protocol", "sideThreadId", "attemptId", "threadId", "turnId", "status", "text", "errorCode"]);
137
+ if (env.protocol !== SIDE_THREAD_TERMINAL_PROTOCOL) throw new Error("invalid terminal protocol");
138
+ const sideChatId = id(env.sideThreadId, "sideThreadId"), attemptId = id(env.attemptId, "attemptId");
139
+ const threadId = id(env.threadId, "threadId"), turnId = id(env.turnId, "turnId");
140
+ const command = this.db.get<CommandRow>(
141
+ "SELECT * FROM side_thread_commands WHERE network_id=?1 AND node_id=?2 AND side_chat_id=?3 AND attempt_id=?4 AND kind='start' AND status='accepted' ORDER BY acked_at DESC LIMIT 1",
142
+ actor.networkId, actor.nodeId, sideChatId, attemptId,
143
+ );
144
+ if (!command || command.consumed_by_token !== actor.tokenId) throw new Error("terminal ownership mismatch");
145
+ const ack = object(JSON.parse(command.ack_json!));
146
+ const cmd = object(JSON.parse(command.command_json));
147
+ if (object(ack.result).turnId !== turnId || object(cmd.payload).threadId !== threadId) throw new Error("terminal four-tuple mismatch");
148
+ if (!["completed", "failed", "interrupted"].includes(String(env.status))) throw new Error("invalid terminal status");
149
+ if (env.status === "completed") {
150
+ if (typeof env.text !== "string" || env.text.includes("\0") || env.text.length > 1_000_000 || env.errorCode !== null)
151
+ throw new Error("invalid completed terminal payload");
152
+ } else if (env.text !== null || (env.status === "failed" ? env.errorCode !== "SIDE_THREAD_RUNTIME_FAILED" : env.errorCode !== null)) {
153
+ throw new Error("invalid non-completed terminal payload");
154
+ }
155
+ const event: SideThreadRuntimeEvent = {
156
+ sideChatId, attemptId, threadId, turnId, status: env.status as any,
157
+ ...(env.status === "completed" ? { text: typeof env.text === "string" ? env.text : "" } : {}),
158
+ ...(env.status === "failed" ? { error: String(env.errorCode ?? "SIDE_THREAD_RUNTIME_FAILED") } : {}),
159
+ };
160
+ const canonical = JSON.stringify(env);
161
+ try {
162
+ this.db.run(
163
+ "INSERT INTO side_thread_terminal_receipts (side_chat_id,attempt_id,thread_id,turn_id,status,envelope_json,consumed_by_token,created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
164
+ [sideChatId, attemptId, threadId, turnId, env.status, canonical, actor.tokenId, this.now()],
165
+ );
166
+ return { idempotent: false, event, key: [sideChatId, attemptId, threadId, turnId] };
167
+ } catch {
168
+ const prior = this.db.get<{ envelope_json: string; consumed_by_token: string }>(
169
+ "SELECT envelope_json,consumed_by_token FROM side_thread_terminal_receipts WHERE side_chat_id=?1 AND attempt_id=?2 AND thread_id=?3 AND turn_id=?4",
170
+ sideChatId, attemptId, threadId, turnId,
171
+ );
172
+ if (!prior || prior.envelope_json !== canonical || prior.consumed_by_token !== actor.tokenId) throw new Error("terminal receipt is immutable");
173
+ return { idempotent: true, event, key: [sideChatId, attemptId, threadId, turnId] };
174
+ }
175
+ }
176
+
177
+ claimTerminal(key: [string,string,string,string]): boolean {
178
+ const stale = this.now() - 30_000;
179
+ return this.db.run(
180
+ "UPDATE side_thread_terminal_receipts SET applying_at=?1 WHERE side_chat_id=?2 AND attempt_id=?3 AND thread_id=?4 AND turn_id=?5 AND applied_at IS NULL AND (applying_at IS NULL OR applying_at<?6)",
181
+ [this.now(), ...key, stale],
182
+ ).changes === 1;
183
+ }
184
+ settleTerminal(key: [string,string,string,string], applied: boolean): void {
185
+ this.db.run(
186
+ applied
187
+ ? "UPDATE side_thread_terminal_receipts SET applied_at=?1,applying_at=NULL WHERE side_chat_id=?2 AND attempt_id=?3 AND thread_id=?4 AND turn_id=?5 AND applied_at IS NULL"
188
+ : "UPDATE side_thread_terminal_receipts SET applying_at=NULL WHERE side_chat_id=?1 AND attempt_id=?2 AND thread_id=?3 AND turn_id=?4 AND applied_at IS NULL",
189
+ applied ? [this.now(), ...key] : key,
190
+ );
191
+ }
192
+ terminalApplyState(key: [string,string,string,string]): "received" | "applying" | "applied" {
193
+ const row = this.db.get<{ applying_at: number | null; applied_at: number | null }>(
194
+ "SELECT applying_at,applied_at FROM side_thread_terminal_receipts WHERE side_chat_id=?1 AND attempt_id=?2 AND thread_id=?3 AND turn_id=?4",
195
+ ...key,
196
+ );
197
+ if (!row) throw new Error("terminal receipt disappeared");
198
+ return row.applied_at !== null ? "applied" : row.applying_at !== null ? "applying" : "received";
199
+ }
200
+
201
+ receipt(operationId: string): Record<string, unknown> | null {
202
+ const row = this.db.get<CommandRow>("SELECT * FROM side_thread_commands WHERE operation_id=?1", operationId);
203
+ return row?.ack_json ? JSON.parse(row.ack_json) : null;
204
+ }
205
+
206
+ private assertAckOwnership(row: CommandRow, ack: Record<string, unknown>): void {
207
+ const command = object(JSON.parse(row.command_json));
208
+ const result = object(ack.result);
209
+ if (ack.state !== "accepted") return;
210
+ if (row.kind === "fork" && !isId(result.threadId)) throw new Error("fork ack missing thread identity");
211
+ if (row.kind === "start" && (!row.attempt_id || !isId(result.turnId) || !isId(object(command.payload).threadId)))
212
+ throw new Error("start ack missing ownership tuple");
213
+ if (row.kind === "bring-back" && !isId(result.destinationTurnId)) throw new Error("bring-back ack missing destination identity");
214
+ if (row.kind !== "fork" && result.threadId !== null) throw new Error("unexpected thread identity");
215
+ if (row.kind !== "start" && result.turnId !== null) throw new Error("unexpected turn identity");
216
+ if (row.kind !== "bring-back" && result.destinationTurnId !== null) throw new Error("unexpected destination identity");
217
+ }
218
+ }
219
+
220
+ /** Durable command outbox port. It has no task/inbox/FIFO dependency. */
221
+ export class DurableSideThreadCommandPort implements SideThreadExecutionPort {
222
+ private listeners = new Set<(event: SideThreadRuntimeEvent) => void | Promise<void>>();
223
+ constructor(private readonly opts: {
224
+ store: SideThreadCommandStore;
225
+ networkForNode: (nodeId: string) => string | null;
226
+ capabilityForNode: (nodeId: string) => SideThreadCapability;
227
+ grantAttachment: (nodeId: string, ref: SideThreadAttachmentRef) => Record<string, unknown>;
228
+ }) {}
229
+ capability(nodeId: string) { return this.opts.capabilityForNode(nodeId); }
230
+ fork(input: Parameters<SideThreadExecutionPort["fork"]>[0]) { return this.issue(input, "fork", { sourceThreadId: input.sourceThreadId, boundary: input.boundary }, "threadId") as Promise<{threadId:string}>; }
231
+ start(input: Parameters<SideThreadExecutionPort["start"]>[0]) { return this.issue(input, "start", { threadId: input.threadId, question: input.prompt, attachments: input.attachments.map((x) => this.opts.grantAttachment(input.nodeId, x)) }, "turnId") as Promise<{turnId:string}>; }
232
+ async cancel(input: Parameters<SideThreadExecutionPort["cancel"]>[0]) { await this.issue(input, "cancel", { threadId: input.threadId, turnId: input.turnId }); }
233
+ async archive(input: Parameters<SideThreadExecutionPort["archive"]>[0]) { await this.issue(input, "archive", { threadId: input.threadId }); }
234
+ async purge(input: Parameters<SideThreadExecutionPort["purge"]>[0]) { await this.issue(input, "purge", { threadId: input.threadId }); }
235
+ bringBack(input: Parameters<SideThreadExecutionPort["bringBack"]>[0]) { return this.issue(input, "bring-back", { sourceThreadId: input.sourceThreadId, sourceTurnId: input.sourceTurnId, destinationThreadId: input.destinationThreadId, text: input.text }, "destinationTurnId") as Promise<{destinationTurnId:string}>; }
236
+ subscribe(listener: (event: SideThreadRuntimeEvent) => void | Promise<void>) {
237
+ if (this.listeners.size > 0) throw new Error("SideThread command port supports exactly one terminal applier");
238
+ this.listeners.add(listener); return () => this.listeners.delete(listener);
239
+ }
240
+ async acceptTerminal(actor: NodeCommandActor, raw: unknown) {
241
+ const x = this.opts.store.terminal(actor, raw);
242
+ if (!this.opts.store.claimTerminal(x.key)) {
243
+ const applyState = this.opts.store.terminalApplyState(x.key);
244
+ return { ...x, pending: applyState !== "applied", confirmed: applyState === "applied", applyState };
245
+ }
246
+ try {
247
+ if (this.listeners.size === 0) throw new Error("SideThread terminal applier unavailable");
248
+ for (const listener of this.listeners) await listener(x.event);
249
+ this.opts.store.settleTerminal(x.key, true);
250
+ return { ...x, pending: false, confirmed: true, applyState: "applied" as const };
251
+ } catch (error) {
252
+ this.opts.store.settleTerminal(x.key, false);
253
+ throw error;
254
+ }
255
+ }
256
+ private async issue(input: any, kind: string, payload: Record<string, unknown>, resultKey?: string): Promise<any> {
257
+ const networkId = this.opts.networkForNode(input.nodeId);
258
+ if (!networkId) throw new SideThreadError("SIDE_THREAD_NOT_FOUND", "node not found", 404);
259
+ const commandId = `stc_${sha(input.operationId).slice(7)}`;
260
+ const command = { protocol: SIDE_THREAD_COMMAND_PROTOCOL, commandId, operationId: input.operationId,
261
+ requestKey: input.requestKey ?? input.operationId, nodeId: input.nodeId, sideThreadId: input.sideChatId,
262
+ attemptId: input.attemptId ?? null, kind, payload };
263
+ this.opts.store.enqueue(command, { networkId, nodeId: input.nodeId });
264
+ const receipt = this.opts.store.receipt(input.operationId);
265
+ if (!receipt) throw new SideThreadError("SIDE_THREAD_AMBIGUOUS", "command durably queued; awaiting node ACK", 202, input.operationId, input.sideChatId, input.attemptId);
266
+ 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);
268
+ }
269
+ }
270
+
271
+ export async function handleSideThreadCommandRequest(input: {
272
+ req: Request; url: URL; actor: NodeCommandActor | null;
273
+ store: SideThreadCommandStore; port: DurableSideThreadCommandPort;
274
+ }): Promise<Response | null> {
275
+ const match = input.url.pathname.match(/^\/api\/nodes\/([^/]+)\/side-thread-commands(?:\/(pending|terminals|([^/]+)\/ack))?$/);
276
+ if (!match) return null;
277
+ if (!input.actor) return Response.json({ ok: false, error: "node_token_binding_required" }, { status: 403 });
278
+ let nodeId: string;
279
+ try { nodeId = decodeURIComponent(match[1]); } catch { return Response.json({ ok: false, error: "invalid_node_id" }, { status: 400 }); }
280
+ if (nodeId !== input.actor.nodeId || !isId(nodeId)) return Response.json({ ok: false, error: "node_token_binding_required" }, { status: 403 });
281
+ try {
282
+ if (match[2] === "pending" && input.req.method === "GET")
283
+ return Response.json({ ok: true, command: input.store.claim(input.actor) });
284
+ if (match[2] === "terminals" && input.req.method === "POST") {
285
+ const result = await input.port.acceptTerminal(input.actor, await input.req.json());
286
+ if (!result.confirmed)
287
+ return Response.json({ ok: false, error: "terminal_applying", retryable: true }, { status: 503 });
288
+ return Response.json({ ok: true, idempotent: result.idempotent, applied: true });
289
+ }
290
+ if (match[3] && input.req.method === "POST") {
291
+ const body = object(await input.req.json());
292
+ if (body.commandId !== decodeURIComponent(match[3])) throw new Error("command path/body mismatch");
293
+ const result = input.store.ack(input.actor, body);
294
+ return Response.json({ ok: true, idempotent: result.idempotent });
295
+ }
296
+ return Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 });
297
+ } catch (error) {
298
+ return Response.json({ ok: false, error: "invalid_side_thread_command", message: error instanceof Error ? error.message : "invalid request" }, { status: 409 });
299
+ }
300
+ }
301
+
302
+ function object(value: unknown): Record<string, any> { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("object required"); return value as any; }
303
+ function exactKeys(value: Record<string, unknown>, allowed: readonly string[]): void {
304
+ const keys = Object.keys(value), expected = new Set(allowed);
305
+ if (keys.length !== allowed.length || keys.some((key) => !expected.has(key))) throw new Error("unexpected protocol fields");
306
+ }
307
+ function isId(value: unknown): value is string { return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(value); }
308
+ function id(value: unknown, label: string): string { if (!isId(value)) throw new Error(`invalid ${label}`); return value; }
309
+ function sha(value: string): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; }
@@ -0,0 +1,229 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import type {
4
+ SideThreadExecutionPort,
5
+ SideThreadRuntimeEvent,
6
+ } from "./side-thread.js";
7
+
8
+ const suffix = randomUUID().replace(/-/g, "");
9
+ const dbPath = `/tmp/anet-side-thread-http-${suffix}.db`;
10
+ const ownerToken = `utok_${suffix}`;
11
+ const otherToken = `utok_other_${suffix}`;
12
+ const nodeToken = `ntok_${suffix}`;
13
+ let hub: any;
14
+ let db: any;
15
+ let detachPort: () => void = () => {};
16
+ let base = "";
17
+
18
+ class HttpFakePort implements SideThreadExecutionPort {
19
+ listeners = new Set<(event: SideThreadRuntimeEvent) => void>();
20
+ capability() {
21
+ return {
22
+ supported: true,
23
+ mode: "native-exact-fork" as const,
24
+ runtime: "codex",
25
+ runtimeVersion: "0.148.0",
26
+ topology: "owned-stdio",
27
+ evidenceRevision: "reviewed",
28
+ exactBoundary: { through: true, before: true },
29
+ };
30
+ }
31
+ async fork(input: any) {
32
+ return { threadId: `derived_${input.sideChatId}` };
33
+ }
34
+ async start(input: any) {
35
+ return { turnId: `turn_${input.attemptId}` };
36
+ }
37
+ async cancel() {}
38
+ async archive() {}
39
+ async purge() {}
40
+ async bringBack() {
41
+ return { destinationTurnId: "destination_turn" };
42
+ }
43
+ subscribe(listener: (event: SideThreadRuntimeEvent) => void) {
44
+ this.listeners.add(listener);
45
+ return () => this.listeners.delete(listener);
46
+ }
47
+ }
48
+
49
+ function hash(value: string) {
50
+ return createHash("sha256").update(value).digest("hex");
51
+ }
52
+ function headers(token = ownerToken) {
53
+ return {
54
+ Authorization: `Bearer ${token}`,
55
+ "Content-Type": "application/json",
56
+ };
57
+ }
58
+
59
+ beforeAll(async () => {
60
+ process.env.NODE_ENV = "test";
61
+ process.env.COMMHUB_DB = dbPath;
62
+ process.env.COMMHUB_ENABLE_SIDE_THREADS = "1";
63
+ delete process.env.DATABASE_URL;
64
+ const dbModule = await import("./db.js");
65
+ db = dbModule.db;
66
+ db.run(
67
+ "INSERT INTO users (user_id,username,password_hash,role) VALUES ('usr_owner','owner','x','user')",
68
+ );
69
+ db.run(
70
+ "INSERT INTO users (user_id,username,password_hash,role) VALUES ('usr_other','other','x','user')",
71
+ );
72
+ db.run(
73
+ "INSERT INTO networks (network_id,network_name,owner_id) VALUES ('net_a','A','usr_owner')",
74
+ );
75
+ db.run(
76
+ "INSERT INTO network_members (network_id,user_id,role) VALUES ('net_a','usr_owner','owner')",
77
+ );
78
+ db.run(
79
+ "INSERT INTO network_members (network_id,user_id,role) VALUES ('net_a','usr_other','member')",
80
+ );
81
+ db.run(
82
+ "INSERT INTO nodes (node_id,node_name,alias,network_id,owner_user_id) VALUES ('node_a','node-a','node-a','net_a','usr_owner')",
83
+ );
84
+ db.run(
85
+ "INSERT INTO nodes (node_id,node_name,alias,network_id,owner_user_id) VALUES ('node_b','node-b','node-b','net_a','usr_owner')",
86
+ );
87
+ db.run(
88
+ "INSERT INTO api_tokens (token_id,token_hash,user_id,name,scope) VALUES ('tok_owner',?1,'usr_owner','owner','user')",
89
+ [hash(ownerToken)],
90
+ );
91
+ db.run(
92
+ "INSERT INTO api_tokens (token_id,token_hash,user_id,name,scope) VALUES ('tok_other',?1,'usr_other','other','user')",
93
+ [hash(otherToken)],
94
+ );
95
+ db.run(
96
+ "INSERT INTO api_tokens (token_id,token_hash,user_id,network_id,name,scope,bound_node_id) VALUES ('tok_node',?1,'usr_owner','net_a','node:node-a','network','node_a')",
97
+ [hash(nodeToken)],
98
+ );
99
+ const serverModule = await import("./server.js");
100
+ detachPort = serverModule.installSideThreadExecutionPort(new HttpFakePort());
101
+ hub = serverModule.bootServer({ port: 0, hostname: "127.0.0.1" });
102
+ base = `http://127.0.0.1:${hub.port}`;
103
+ });
104
+
105
+ afterAll(() => {
106
+ detachPort();
107
+ try {
108
+ hub?.stop(true);
109
+ } catch {}
110
+ try {
111
+ db?.close();
112
+ } catch {}
113
+ });
114
+
115
+ describe("SideThread authenticated Hub HTTP", () => {
116
+ let sideChatId = "";
117
+ test("POST create and GET hydrate the complete question", async () => {
118
+ const response = await fetch(`${base}/api/side-threads`, {
119
+ method: "POST",
120
+ headers: headers(),
121
+ body: JSON.stringify({
122
+ requestKey: "http-create-0001",
123
+ networkId: "net_a",
124
+ nodeId: "node_a",
125
+ sourceThreadId: "source_thread",
126
+ boundary: { kind: "through", turnId: "source_turn" },
127
+ prompt: "完整原问题\n第二行",
128
+ }),
129
+ });
130
+ expect(response.status).toBe(201);
131
+ const body = (await response.json()) as any;
132
+ sideChatId = body.sideThread.sideThreadId;
133
+ expect(body.sideThread).toMatchObject({
134
+ requestKey: "http-create-0001",
135
+ question: "完整原问题\n第二行",
136
+ title: "完整原问题",
137
+ state: "running",
138
+ });
139
+ const hydrated = await fetch(`${base}/api/side-threads/${sideChatId}`, {
140
+ headers: headers(),
141
+ });
142
+ expect(((await hydrated.json()) as any).sideThread.question).toBe(
143
+ "完整原问题\n第二行",
144
+ );
145
+ const listed = await fetch(
146
+ `${base}/api/side-threads?network_id=net_a&limit=20`,
147
+ { headers: headers() },
148
+ );
149
+ expect(await listed.json()).toMatchObject({
150
+ count: 1,
151
+ sideThreads: [{ question: "完整原问题\n第二行", bringBacks: [] }],
152
+ });
153
+ });
154
+
155
+ test("cross-owner and query-token reads cannot recover the question", async () => {
156
+ const cross = await fetch(`${base}/api/side-threads/${sideChatId}`, {
157
+ headers: headers(otherToken),
158
+ });
159
+ expect(cross.status).toBe(404);
160
+ expect(JSON.stringify(await cross.json())).not.toContain("完整原问题");
161
+ const crossList = await fetch(`${base}/api/side-threads`, {
162
+ headers: headers(otherToken),
163
+ });
164
+ const crossListBody = await crossList.json();
165
+ expect(crossListBody).toMatchObject({ count: 0, sideThreads: [] });
166
+ expect(JSON.stringify(crossListBody)).not.toContain("完整原问题");
167
+ const queryOnly = await fetch(
168
+ `${base}/api/side-threads/${sideChatId}?token=${ownerToken}`,
169
+ );
170
+ expect(queryOnly.status).toBe(401);
171
+ });
172
+
173
+ test("SSE replays metadata without question/result bodies", async () => {
174
+ const response = await fetch(
175
+ `${base}/api/side-threads/${sideChatId}/events`,
176
+ { headers: { Authorization: `Bearer ${ownerToken}` } },
177
+ );
178
+ expect(response.status).toBe(200);
179
+ const reader = response.body!.getReader();
180
+ const chunk = await reader.read();
181
+ const text = new TextDecoder().decode(chunk.value);
182
+ await reader.cancel();
183
+ expect(text).toContain("side_thread");
184
+ expect(text).toContain("sideThreadId");
185
+ expect(text).not.toContain("sideChatId");
186
+ expect(text).not.toContain("完整原问题");
187
+ });
188
+
189
+ test("node token is bound to its exact node", async () => {
190
+ const response = await fetch(`${base}/api/side-threads`, {
191
+ method: "POST",
192
+ headers: headers(nodeToken),
193
+ body: JSON.stringify({
194
+ request_key: "http-create-node2",
195
+ network_id: "net_a",
196
+ node_id: "node_b",
197
+ source_thread_id: "source_thread",
198
+ boundary: { kind: "through", turn_id: "source_turn" },
199
+ prompt: "must fail",
200
+ }),
201
+ });
202
+ expect(response.status).toBe(404);
203
+ expect(await response.json()).toMatchObject({
204
+ error: "SIDE_THREAD_NOT_FOUND",
205
+ });
206
+ });
207
+
208
+ test("removing the verified port yields typed unsupported, never a task row", async () => {
209
+ detachPort();
210
+ const before = db.get("SELECT COUNT(*) AS n FROM tasks").n;
211
+ const response = await fetch(`${base}/api/side-threads`, {
212
+ method: "POST",
213
+ headers: headers(),
214
+ body: JSON.stringify({
215
+ request_key: "http-no-port-01",
216
+ network_id: "net_a",
217
+ node_id: "node_a",
218
+ source_thread_id: "source_thread",
219
+ boundary: { kind: "through", turn_id: "source_turn" },
220
+ prompt: "no fallback",
221
+ }),
222
+ });
223
+ expect(response.status).toBe(501);
224
+ expect(await response.json()).toMatchObject({
225
+ error: "SIDE_THREAD_UNSUPPORTED",
226
+ });
227
+ expect(db.get("SELECT COUNT(*) AS n FROM tasks").n).toBe(before);
228
+ });
229
+ });