@sleep2agi/commhub-server 0.9.0-preview.3 → 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.
Files changed (99) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +84 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-fallback.test.ts +267 -0
  10. package/src/api-nodes-shape.test.ts +188 -0
  11. package/src/auth.ts +93 -14
  12. package/src/avatar-validate.test.ts +163 -0
  13. package/src/avatar-validate.ts +103 -0
  14. package/src/boot-side-effect.test.ts +124 -0
  15. package/src/config-apply-sec1.test.ts +107 -0
  16. package/src/config-apply-validate.test.ts +92 -0
  17. package/src/config-apply-validate.ts +81 -3
  18. package/src/create-node.ts +48 -1
  19. package/src/dashboard-slash-routing-http.test.ts +103 -0
  20. package/src/db-adapter-guard.test.ts +85 -0
  21. package/src/db-adapter.ts +64 -23
  22. package/src/db.ts +522 -32
  23. package/src/ext-token-shared.test.ts +60 -0
  24. package/src/ext-validation-regression.test.ts +131 -0
  25. package/src/external-schedule-edits-http.test.ts +316 -0
  26. package/src/external-schedule-edits.ts +314 -0
  27. package/src/file-download-authz-dev-open.test.ts +135 -0
  28. package/src/file-download-authz.test.ts +403 -0
  29. package/src/file-download-date-bucket.test.ts +436 -0
  30. package/src/file-network-scope-dev-open.test.ts +97 -0
  31. package/src/file-network-scope.test.ts +731 -0
  32. package/src/health-redaction.test.ts +637 -0
  33. package/src/index.ts +15 -2172
  34. package/src/lifecycle-guard.ts +50 -0
  35. package/src/list-host-supervisors.test.ts +314 -0
  36. package/src/mcp-write-network-resolution.test.ts +429 -0
  37. package/src/network-name-http.test.ts +71 -0
  38. package/src/network-scope.ts +101 -0
  39. package/src/node-attrs-validate.ts +88 -0
  40. package/src/node-attrs.test.ts +213 -0
  41. package/src/observer-avatar-http.test.ts +349 -0
  42. package/src/observer-push.test.ts +176 -0
  43. package/src/peer-reply-atomic.test.ts +455 -0
  44. package/src/peer-reply-capability.test.ts +70 -0
  45. package/src/probe-validate.test.ts +221 -0
  46. package/src/probe-validate.ts +104 -0
  47. package/src/probe.test.ts +80 -0
  48. package/src/probe.ts +200 -0
  49. package/src/push.ts +96 -7
  50. package/src/require-explicit-test-db.ts +9 -0
  51. package/src/resolve-port.test.ts +57 -0
  52. package/src/resolve-port.ts +44 -0
  53. package/src/response-charset.test.ts +130 -0
  54. package/src/rest-explicit-columns-http.test.ts +211 -0
  55. package/src/rest-identity.test.ts +129 -0
  56. package/src/rest-identity.ts +100 -0
  57. package/src/rest-projections.ts +67 -0
  58. package/src/rest-write-network-resolution.test.ts +133 -0
  59. package/src/runtime-label.test.ts +22 -0
  60. package/src/runtime-label.ts +21 -0
  61. package/src/scheduled-run-terminal.test.ts +268 -0
  62. package/src/scheduled-tasks-http.test.ts +479 -0
  63. package/src/scheduled-tasks.ts +549 -0
  64. package/src/send-reply-agent-warning.test.ts +202 -0
  65. package/src/send-reply-attachments.test.ts +437 -0
  66. package/src/server.ts +3492 -0
  67. package/src/shared/external-schedule-contract.ts +60 -0
  68. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  69. package/src/shared/probe-host-allowlist.ts +128 -0
  70. package/src/side-thread-command-transport.test.ts +140 -0
  71. package/src/side-thread-command-transport.ts +309 -0
  72. package/src/side-thread-http-integration.test.ts +229 -0
  73. package/src/side-thread-http.ts +504 -0
  74. package/src/side-thread.test.ts +1038 -0
  75. package/src/side-thread.ts +2241 -0
  76. package/src/skillhub-http.test.ts +88 -0
  77. package/src/stop-delete-node.test.ts +720 -0
  78. package/src/task-auth-origin-http.test.ts +87 -0
  79. package/src/task-auth-origin.test.ts +24 -0
  80. package/src/task-auth-origin.ts +7 -0
  81. package/src/task-consumption.test.ts +398 -0
  82. package/src/task-diagnostic-http.test.ts +145 -0
  83. package/src/task-diagnostic.test.ts +115 -0
  84. package/src/task-diagnostic.ts +118 -0
  85. package/src/task-idempotency-mcp.test.ts +142 -0
  86. package/src/task-idempotency.test.ts +60 -0
  87. package/src/task-idempotency.ts +75 -0
  88. package/src/task-lifecycle-watcher.test.ts +200 -0
  89. package/src/task-lifecycle-watcher.ts +49 -0
  90. package/src/task-network-resolution-http.test.ts +137 -0
  91. package/src/tasks-pagination-http.test.ts +137 -0
  92. package/src/tools.ts +2302 -169
  93. package/src/update-provider.test.ts +234 -0
  94. package/src/upload-early-reject-guard.test.ts +155 -0
  95. package/src/uploads-http.test.ts +92 -14
  96. package/src/uploads.test.ts +66 -0
  97. package/src/uploads.ts +130 -5
  98. package/src/vault.test.ts +200 -0
  99. package/src/vault.ts +156 -0
@@ -0,0 +1,176 @@
1
+ // #461 — unit tests for the network observer SSE channel (push.ts).
2
+ //
3
+ // These tests read REAL bytes off the observer stream, so they are a
4
+ // live gate: stub pushNetworkObserverEvent out to a no-op and every
5
+ // "receives …" test below times out red. (Per 通信龙 2026-07-28 rule:
6
+ // a test that stays green when the core function is emptied is a fake
7
+ // gate.)
8
+
9
+ import { afterEach, describe, expect, test } from "bun:test";
10
+ import {
11
+ __resetSSEClientsForTest,
12
+ createNetworkObserverStream,
13
+ createSSEStream,
14
+ pushEvent,
15
+ pushNetworkObserverEvent,
16
+ } from "./push";
17
+
18
+ afterEach(() => {
19
+ __resetSSEClientsForTest();
20
+ });
21
+
22
+ type Frame = Record<string, any>;
23
+
24
+ /** Read the next SSE data frame (skipping keepalive comments), with a
25
+ * hard timeout so a silent stream fails the test instead of hanging. */
26
+ async function readFrame(
27
+ reader: ReadableStreamDefaultReader<Uint8Array>,
28
+ timeoutMs = 2_000,
29
+ ): Promise<Frame> {
30
+ const decoder = new TextDecoder();
31
+ let buf = "";
32
+ const deadline = Date.now() + timeoutMs;
33
+ while (Date.now() < deadline) {
34
+ const remaining = deadline - Date.now();
35
+ const result = await Promise.race([
36
+ reader.read(),
37
+ new Promise<never>((_, rej) =>
38
+ setTimeout(() => rej(new Error(`no SSE frame within ${timeoutMs}ms`)), remaining),
39
+ ),
40
+ ]);
41
+ if (result.done) throw new Error("stream ended before a data frame arrived");
42
+ buf += decoder.decode(result.value, { stream: true });
43
+ const sep = buf.indexOf("\n\n");
44
+ if (sep === -1) continue;
45
+ const rawFrame = buf.slice(0, sep);
46
+ buf = buf.slice(sep + 2);
47
+ const dataLine = rawFrame.split("\n").find((l) => l.startsWith("data: "));
48
+ if (!dataLine) continue; // keepalive comment frame — keep reading
49
+ return JSON.parse(dataLine.slice(6));
50
+ }
51
+ throw new Error(`no SSE frame within ${timeoutMs}ms`);
52
+ }
53
+
54
+ describe("#461 network observer stream", () => {
55
+ test("observer receives connected frame, then new_task summary with metadata only", async () => {
56
+ const res = createNetworkObserverStream("net_obs_a");
57
+ const reader = res.body!.getReader();
58
+
59
+ const connected = await readFrame(reader);
60
+ expect(connected.type).toBe("connected");
61
+ expect(connected.observer).toBe(true);
62
+ expect(connected.network_id).toBe("net_obs_a");
63
+
64
+ pushNetworkObserverEvent("net_obs_a", {
65
+ type: "new_task",
66
+ task_id: "task-123",
67
+ from: "sender-agent",
68
+ to: "receiver-agent",
69
+ status: "delivered",
70
+ priority: "high",
71
+ });
72
+
73
+ const evt = await readFrame(reader);
74
+ expect(evt.type).toBe("new_task");
75
+ expect(evt.task_id).toBe("task-123");
76
+ expect(evt.from).toBe("sender-agent");
77
+ expect(evt.to).toBe("receiver-agent");
78
+ expect(evt.status).toBe("delivered");
79
+ expect(evt.priority).toBe("high");
80
+ expect(evt.network_id).toBe("net_obs_a");
81
+ expect(evt.scope).toBe("network");
82
+ // Summary contract: NO content field, ever.
83
+ expect("content" in evt).toBe(false);
84
+ expect("task" in evt).toBe(false);
85
+ await reader.cancel();
86
+ });
87
+
88
+ test("observer receives new_reply summary", async () => {
89
+ const res = createNetworkObserverStream("net_obs_reply");
90
+ const reader = res.body!.getReader();
91
+ await readFrame(reader); // connected
92
+
93
+ pushNetworkObserverEvent("net_obs_reply", {
94
+ type: "new_reply",
95
+ task_id: "parent-task",
96
+ message_id: "msg-9",
97
+ from: "worker",
98
+ to: "boss",
99
+ status: "completed",
100
+ });
101
+
102
+ const evt = await readFrame(reader);
103
+ expect(evt.type).toBe("new_reply");
104
+ expect(evt.task_id).toBe("parent-task");
105
+ expect(evt.message_id).toBe("msg-9");
106
+ expect(evt.status).toBe("completed");
107
+ expect("content" in evt).toBe(false);
108
+ expect("text" in evt).toBe(false);
109
+ await reader.cancel();
110
+ });
111
+
112
+ test("events are network-isolated: observer on net B never sees net A traffic", async () => {
113
+ const resB = createNetworkObserverStream("net_obs_b");
114
+ const readerB = resB.body!.getReader();
115
+ await readFrame(readerB); // connected
116
+
117
+ // Push to A first, then a marker to B. Delivery per key is
118
+ // synchronous and ordered, so if B's next frame is the marker,
119
+ // the A event provably never reached B.
120
+ pushNetworkObserverEvent("net_obs_a2", { type: "new_task", task_id: "leaked" });
121
+ pushNetworkObserverEvent("net_obs_b", { type: "new_task", task_id: "marker-b" });
122
+
123
+ const evt = await readFrame(readerB);
124
+ expect(evt.task_id).toBe("marker-b");
125
+ await readerB.cancel();
126
+ });
127
+
128
+ test("observer stream is isolated from session channels (both directions)", async () => {
129
+ const sessionRes = createSSEStream("agent-x", "net_obs_c");
130
+ const sessionReader = sessionRes.body!.getReader();
131
+ await readFrame(sessionReader); // connected
132
+
133
+ const obsRes = createNetworkObserverStream("net_obs_c");
134
+ const obsReader = obsRes.body!.getReader();
135
+ await readFrame(obsReader); // connected
136
+
137
+ // Session push must NOT hit the observer; observer push must NOT
138
+ // hit the session. Marker technique as above.
139
+ pushEvent("agent-x", { type: "new_task", inbox_count: 1, marker: "to-session" }, "net_obs_c");
140
+ pushNetworkObserverEvent("net_obs_c", { type: "new_task", task_id: "to-observer" });
141
+
142
+ const sessionEvt = await readFrame(sessionReader);
143
+ expect(sessionEvt.marker).toBe("to-session");
144
+ expect("task_id" in sessionEvt).toBe(false);
145
+
146
+ const obsEvt = await readFrame(obsReader);
147
+ expect(obsEvt.task_id).toBe("to-observer");
148
+ expect("marker" in obsEvt).toBe(false);
149
+
150
+ await sessionReader.cancel();
151
+ await obsReader.cancel();
152
+ });
153
+
154
+ test("null / undefined networkId is a silent no-op", () => {
155
+ // Must not throw — legacy null-network traffic has no observer
156
+ // stream by design (documented in #461 / the PR).
157
+ expect(() => pushNetworkObserverEvent(null, { type: "new_task" })).not.toThrow();
158
+ expect(() => pushNetworkObserverEvent(undefined, { type: "new_task" })).not.toThrow();
159
+ });
160
+
161
+ test("multiple observers on the same network all receive the event", async () => {
162
+ const r1 = createNetworkObserverStream("net_obs_multi");
163
+ const r2 = createNetworkObserverStream("net_obs_multi");
164
+ const reader1 = r1.body!.getReader();
165
+ const reader2 = r2.body!.getReader();
166
+ await readFrame(reader1);
167
+ await readFrame(reader2);
168
+
169
+ pushNetworkObserverEvent("net_obs_multi", { type: "new_task", task_id: "fanout" });
170
+
171
+ expect((await readFrame(reader1)).task_id).toBe("fanout");
172
+ expect((await readFrame(reader2)).task_id).toBe("fanout");
173
+ await reader1.cancel();
174
+ await reader2.cancel();
175
+ });
176
+ });
@@ -0,0 +1,455 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { db } from "./db.js";
4
+ import { registerTools } from "./tools.js";
5
+ import { CommHubError } from "../../agent-node/src/reply-reliability.js";
6
+ import { sendPeerReplyCompatible } from "../../agent-node/src/peer-reply-send.js";
7
+ import { __resetSSEClientsForTest, createSSEStream } from "./push.js";
8
+ import { eventBus } from "./event_bus.js";
9
+
10
+ const NET = "net_peer_reply_698";
11
+ const USER = "user_peer_reply_698";
12
+ const A = "peer-a-698";
13
+ const B = "peer-b-698";
14
+ const A_ID = "node-peer-a-698";
15
+ const B_ID = "node-peer-b-698";
16
+
17
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
18
+
19
+ function cleanup() {
20
+ __resetSSEClientsForTest();
21
+ try { db.exec("DROP TRIGGER IF EXISTS test698_abort_terminal"); } catch {}
22
+ try { db.exec("DROP TRIGGER IF EXISTS test698_abort_run"); } catch {}
23
+ for (const table of ["scheduled_task_runs", "scheduled_tasks", "tasks", "inbox", "task_events", "sessions", "api_tokens", "nodes", "rename_txn"]) {
24
+ try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
25
+ }
26
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
27
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
28
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
29
+ }
30
+
31
+ function seed() {
32
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'user', datetime('now'))", [USER]);
33
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
34
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
35
+ for (const [alias, nodeId] of [[A, A_ID], [B, B_ID]] as const) {
36
+ db.run(
37
+ "INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, config_snapshot, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?2, ?3, 'host', ?4, datetime('now'), datetime('now'), 'active')",
38
+ [nodeId, alias, NET, JSON.stringify({ peer_reply_inbox_capable: true })],
39
+ );
40
+ db.run(
41
+ "INSERT INTO sessions (resume_id, alias, status, node_id, network_id, peer_reply_inbox_capable, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, 1, datetime('now'), datetime('now'))",
42
+ [`resume-${alias}`, alias, nodeId, NET],
43
+ );
44
+ createSSEStream(alias, NET);
45
+ db.run(
46
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6)",
47
+ [`token-${alias}`, `hash-token-${alias}`, USER, NET, `node:${alias}`, nodeId],
48
+ );
49
+ }
50
+ }
51
+
52
+ function toolsFor(alias: string): Record<string, ToolHandler> {
53
+ const server = new McpServer({ name: "peer-reply-698", version: "0" }) as any;
54
+ const tools: Record<string, ToolHandler> = {};
55
+ const original = server.tool.bind(server);
56
+ server.tool = (name: string, description: string, schema: any, handler: ToolHandler) => {
57
+ tools[name] = handler;
58
+ return original(name, description, schema, handler);
59
+ };
60
+ registerTools(server, undefined, NET, USER, alias, true, `token-${alias}`);
61
+ return tools;
62
+ }
63
+
64
+ async function call(handler: ToolHandler, args: any) {
65
+ const result = await handler(args);
66
+ return JSON.parse(result.content[0].text);
67
+ }
68
+
69
+ async function callOrThrow(handler: ToolHandler, args: any) {
70
+ const payload = await call(handler, args);
71
+ if (payload?.ok === false) {
72
+ throw new CommHubError(`app-level rejection: ${payload.error}`, {
73
+ code: payload.error,
74
+ payload,
75
+ appLevel: true,
76
+ });
77
+ }
78
+ return payload;
79
+ }
80
+
81
+ async function dispatch(from: string, to: string, content: string): Promise<string> {
82
+ const sent = await call(toolsFor(from).send_task, { alias: to, task: content, priority: "normal" });
83
+ expect(sent.ok).toBe(true);
84
+ return sent.task_id ?? sent.message_id;
85
+ }
86
+
87
+ function task(taskId: string) {
88
+ return db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", taskId);
89
+ }
90
+
91
+ function replies(taskId: string) {
92
+ return db.all<any>("SELECT * FROM inbox WHERE in_reply_to = ?1 ORDER BY rowid", taskId);
93
+ }
94
+
95
+ function bindScheduledRun(taskId: string): { runId: string; scheduleId: string } {
96
+ const suffix = crypto.randomUUID();
97
+ const runId = `srun_${suffix}`;
98
+ const scheduleId = `sched_${suffix}`;
99
+ db.run(
100
+ `INSERT INTO scheduled_tasks
101
+ (schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
102
+ schedule_type, schedule_json, timezone, status, next_run_at)
103
+ VALUES (?1, ?2, 'peer reply schedule', ?3, ?4, 'scheduled peer task', 'normal',
104
+ 'interval', '{"type":"interval","every_seconds":3600}', 'UTC', 'active', datetime('now', '+1 hour'))`,
105
+ [scheduleId, NET, B_ID, B],
106
+ );
107
+ db.run(
108
+ `INSERT INTO scheduled_task_runs
109
+ (run_id, schedule_id, network_id, scheduled_for, task_id, status, completed_at)
110
+ VALUES (?1, ?2, ?3, datetime('now'), ?4, 'delivered', NULL)`,
111
+ [runId, scheduleId, NET, taskId],
112
+ );
113
+ return { runId, scheduleId };
114
+ }
115
+
116
+ beforeEach(() => { cleanup(); seed(); });
117
+ afterAll(cleanup);
118
+
119
+ describe("#698 atomic peer reply", () => {
120
+ test("exact bound token works even when sessions.node_id is NULL", async () => {
121
+ const taskId = await dispatch(A, B, "session row is not identity");
122
+ db.run("UPDATE sessions SET node_id = NULL WHERE alias = ?1 AND network_id = ?2", [B, NET]);
123
+ const result = await call(toolsFor(B).send_peer_reply, {
124
+ alias: A, text: "bound-token wins", in_reply_to: taskId, status: "replied",
125
+ });
126
+ expect(result.ok).toBe(true);
127
+ expect(task(taskId).status).toBe("replied");
128
+ });
129
+
130
+ test("a rollback heartbeat clears the current-session capability before any peer write", async () => {
131
+ const tools = toolsFor(A);
132
+ await call(tools.report_status, {
133
+ resume_id: `resume-${A}`, alias: A, status: "idle", node_id: A_ID,
134
+ config_snapshot: { config_update_capable: true, peer_reply_inbox_capable: true },
135
+ });
136
+ expect(db.get<any>(
137
+ "SELECT peer_reply_inbox_capable FROM sessions WHERE alias = ?1 AND network_id = ?2",
138
+ A, NET,
139
+ ).peer_reply_inbox_capable).toBe(1);
140
+ await call(tools.report_status, {
141
+ resume_id: `resume-${A}`, alias: A, status: "idle", node_id: A_ID,
142
+ config_snapshot: { config_update_capable: true },
143
+ });
144
+ expect(db.get<any>(
145
+ "SELECT peer_reply_inbox_capable FROM sessions WHERE alias = ?1 AND network_id = ?2",
146
+ A, NET,
147
+ ).peer_reply_inbox_capable).toBe(0);
148
+
149
+ const taskId = await dispatch(A, B, "recipient rolled back");
150
+ const result = await call(toolsFor(B).send_peer_reply, {
151
+ alias: A, text: "must use legacy", in_reply_to: taskId, status: "replied",
152
+ });
153
+ expect(result).toEqual(expect.objectContaining({ ok: false, error: "peer_reply_unsupported" }));
154
+ expect(task(taskId).status).toBe("delivered");
155
+ expect(replies(taskId)).toHaveLength(0);
156
+ });
157
+
158
+ test("a capable but disconnected recipient commits atomically for reconnect drain", async () => {
159
+ const taskId = await dispatch(A, B, "recipient disconnected");
160
+ __resetSSEClientsForTest();
161
+ const result = await call(toolsFor(B).send_peer_reply, {
162
+ alias: A, text: "stored for reconnect", in_reply_to: taskId, status: "replied",
163
+ });
164
+ expect(result).toEqual(expect.objectContaining({ ok: true }));
165
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "replied", result: "stored for reconnect" }));
166
+ expect(replies(taskId)).toHaveLength(1);
167
+ });
168
+
169
+ test("recipient capability is bound to the exact canonical alias, not another session on the same node", async () => {
170
+ const taskId = await dispatch(A, B, "same node must not lend capability across aliases");
171
+ db.run(
172
+ "UPDATE sessions SET peer_reply_inbox_capable = 0 WHERE alias = ?1 AND network_id = ?2",
173
+ [A, NET],
174
+ );
175
+ db.run(
176
+ `INSERT INTO sessions
177
+ (resume_id, alias, status, node_id, network_id, peer_reply_inbox_capable, updated_at, last_seen_at)
178
+ VALUES ('resume-wrong-alias-698', 'wrong-alias-698', 'idle', ?1, ?2, 1,
179
+ datetime('now', '+1 minute'), datetime('now', '+1 minute'))`,
180
+ [A_ID, NET],
181
+ );
182
+ const result = await call(toolsFor(B).send_peer_reply, {
183
+ alias: A, text: "must not borrow wrong alias capability", in_reply_to: taskId, status: "replied",
184
+ });
185
+ expect(result).toEqual(expect.objectContaining({ ok: false, error: "peer_reply_unsupported" }));
186
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "delivered", result: null }));
187
+ expect(replies(taskId)).toHaveLength(0);
188
+ });
189
+
190
+ test("origin rename during work routes the terminal reply to the canonical alias and stable node", async () => {
191
+ const taskId = await dispatch(A, B, "origin will rename");
192
+ const renamed = `${A}-renamed`;
193
+ db.run(
194
+ `INSERT INTO rename_txn (txn_id, network_id, old_alias, new_alias, status, committed_at)
195
+ VALUES (?1, ?2, ?3, ?4, 'committed', datetime('now'))`,
196
+ [`rtxn-${crypto.randomUUID()}`, NET, A, renamed],
197
+ );
198
+ db.run("UPDATE nodes SET alias = ?1 WHERE node_id = ?2 AND network_id = ?3", [renamed, A_ID, NET]);
199
+ db.run("UPDATE sessions SET alias = ?1 WHERE node_id = ?2 AND network_id = ?3", [renamed, A_ID, NET]);
200
+ eventBus.emit("rename-committed", {
201
+ networkId: NET, old_alias: A, new_alias: renamed, node_id: A_ID,
202
+ });
203
+ const result = await call(toolsFor(B).send_peer_reply, {
204
+ alias: A, text: "reply after rename", in_reply_to: taskId, status: "replied",
205
+ });
206
+ expect(result.ok).toBe(true);
207
+ expect(task(taskId).status).toBe("replied");
208
+ expect(replies(taskId)).toEqual([
209
+ expect.objectContaining({ session_name: renamed, node_id: A_ID, requires_response: "none" }),
210
+ ]);
211
+ });
212
+
213
+ test("legacy caller or recipient capability downgrade returns unsupported with zero writes", async () => {
214
+ for (const mode of ["caller-unbound", "recipient-downgraded", "legacy-task-origin"] as const) {
215
+ cleanup(); seed();
216
+ const taskId = await dispatch(A, B, `compat-${mode}`);
217
+ if (mode === "caller-unbound") {
218
+ db.run("UPDATE api_tokens SET bound_node_id = NULL WHERE token_id = ?1", `token-${B}`);
219
+ } else if (mode === "recipient-downgraded") {
220
+ db.run("UPDATE sessions SET peer_reply_inbox_capable = 0 WHERE node_id = ?1 AND network_id = ?2", [A_ID, NET]);
221
+ } else {
222
+ db.run("UPDATE tasks SET from_node_id = NULL WHERE task_id = ?1", taskId);
223
+ }
224
+ const result = await call(toolsFor(B).send_peer_reply, {
225
+ alias: A, text: "must fall back", in_reply_to: taskId, status: "replied",
226
+ });
227
+ expect(result).toEqual(expect.objectContaining({
228
+ ok: false,
229
+ error: mode === "legacy-task-origin" ? "peer_reply_origin_not_node" : "peer_reply_unsupported",
230
+ reply_queued: false,
231
+ }));
232
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "delivered", result: null }));
233
+ expect(replies(taskId)).toHaveLength(0);
234
+ }
235
+ });
236
+
237
+ test("old Hub or legacy recipient falls back once and terminalizes the original", async () => {
238
+ for (const mode of ["old-hub", "legacy-recipient"] as const) {
239
+ cleanup(); seed();
240
+ const taskId = await dispatch(A, B, `mixed-${mode}`);
241
+ if (mode === "legacy-recipient") {
242
+ db.run("UPDATE sessions SET peer_reply_inbox_capable = 0 WHERE node_id = ?1 AND network_id = ?2", [A_ID, NET]);
243
+ }
244
+ const tools = toolsFor(B);
245
+ let atomicCalls = 0;
246
+ let legacyReplyCalls = 0;
247
+ const routed = await sendPeerReplyCompatible({
248
+ target: A, text: `fallback-${mode}`, taskId, failed: false, fromAlias: B,
249
+ }, {
250
+ sendAtomic: async (args) => {
251
+ atomicCalls++;
252
+ if (mode === "old-hub") {
253
+ throw new CommHubError("JSON-RPC error: -32601: unknown tool send_peer_reply", { code: -32601 });
254
+ }
255
+ return callOrThrow(tools.send_peer_reply, {
256
+ alias: args.target, text: args.text, in_reply_to: args.taskId, status: "replied",
257
+ });
258
+ },
259
+ sendLegacyReply: async (args) => {
260
+ legacyReplyCalls++;
261
+ return callOrThrow(tools.send_reply, {
262
+ alias: args.target, text: args.text, in_reply_to: args.taskId, status: "replied",
263
+ });
264
+ },
265
+ });
266
+ expect(routed.route).toBe("legacy-reply");
267
+ expect([atomicCalls, legacyReplyCalls]).toEqual([1, 1]);
268
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "replied", result: `fallback-${mode}` }));
269
+ expect(replies(taskId)).toHaveLength(1);
270
+ expect(db.get<{ n: number }>(
271
+ "SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?1 AND requires_response = 'reply'",
272
+ taskId,
273
+ )?.n).toBe(0);
274
+ }
275
+ });
276
+
277
+ test("node-id rotation falls back to alias-bound terminal send_reply", async () => {
278
+ const taskId = await dispatch(A, B, "rotate B after dispatch");
279
+ const rotatedNodeId = `${B_ID}-rotated`;
280
+ db.run(
281
+ "INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, config_snapshot, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?2, ?3, 'host-rotated', ?4, datetime('now'), datetime('now'), 'active')",
282
+ [rotatedNodeId, B, NET, JSON.stringify({ peer_reply_inbox_capable: true })],
283
+ );
284
+ db.run("UPDATE api_tokens SET bound_node_id = ?1 WHERE token_id = ?2", [rotatedNodeId, `token-${B}`]);
285
+ db.run("UPDATE sessions SET node_id = ?1 WHERE alias = ?2 AND network_id = ?3", [rotatedNodeId, B, NET]);
286
+
287
+ const tools = toolsFor(B);
288
+ let atomicCalls = 0;
289
+ let legacyReplyCalls = 0;
290
+ const routed = await sendPeerReplyCompatible({
291
+ target: A, text: "result after identity rotation", taskId, failed: false, fromAlias: B,
292
+ }, {
293
+ sendAtomic: async (args) => {
294
+ atomicCalls++;
295
+ return callOrThrow(tools.send_peer_reply, {
296
+ alias: args.target, text: args.text, in_reply_to: args.taskId, status: "replied",
297
+ });
298
+ },
299
+ sendLegacyReply: async (args) => {
300
+ legacyReplyCalls++;
301
+ return callOrThrow(tools.send_reply, {
302
+ alias: args.target, text: args.text, in_reply_to: args.taskId, status: "replied",
303
+ });
304
+ },
305
+ });
306
+
307
+ expect(routed.route).toBe("legacy-reply");
308
+ expect([atomicCalls, legacyReplyCalls]).toEqual([1, 1]);
309
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "replied", result: "result after identity rotation" }));
310
+ expect(replies(taskId)).toHaveLength(1);
311
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?1", taskId)?.n).toBe(0);
312
+ });
313
+
314
+ test("compatibility send_reply cannot terminalize another node's task or target a third party", async () => {
315
+ const taskId = await dispatch(A, B, "compatibility ownership boundary");
316
+
317
+ const foreign = await call(toolsFor(A).send_reply, {
318
+ alias: A, text: "foreign close attempt", in_reply_to: taskId, status: "replied",
319
+ });
320
+ expect(foreign).toEqual(expect.objectContaining({ ok: false, error: "reply_task_not_owned" }));
321
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "delivered", result: null }));
322
+ expect(replies(taskId)).toHaveLength(0);
323
+
324
+ const wrongTarget = await call(toolsFor(B).send_reply, {
325
+ alias: B, text: "wrong target attempt", in_reply_to: taskId, status: "replied",
326
+ });
327
+ expect(wrongTarget).toEqual(expect.objectContaining({ ok: false, error: "reply_target_mismatch" }));
328
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "delivered", result: null }));
329
+ expect(replies(taskId)).toHaveLength(0);
330
+
331
+ const valid = await call(toolsFor(B).send_reply, {
332
+ alias: A, text: "valid compatibility close", in_reply_to: taskId, status: "replied",
333
+ });
334
+ expect(valid.ok).toBe(true);
335
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "replied", result: "valid compatibility close" }));
336
+ expect(replies(taskId)).toHaveLength(1);
337
+ });
338
+
339
+ test("owner node terminalizes exact original and emits one no-response reply without a task row", async () => {
340
+ const taskId = await dispatch(A, B, "do work");
341
+ const result = await call(toolsFor(B).send_peer_reply, {
342
+ alias: A,
343
+ text: "done exactly once",
344
+ in_reply_to: taskId,
345
+ status: "replied",
346
+ });
347
+ expect(result.ok).toBe(true);
348
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "replied", result: "done exactly once" }));
349
+ expect(task(taskId).completed_at).toBeTruthy();
350
+ expect(replies(taskId)).toHaveLength(1);
351
+ expect(replies(taskId)[0]).toEqual(expect.objectContaining({
352
+ session_name: A,
353
+ node_id: A_ID,
354
+ type: "reply",
355
+ requires_response: "none",
356
+ content: "done exactly once",
357
+ }));
358
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?1", taskId)?.n).toBe(0);
359
+ });
360
+
361
+ test("terminal replay is rejected and cannot deliver twice", async () => {
362
+ const taskId = await dispatch(A, B, "idempotent work");
363
+ const handler = toolsFor(B).send_peer_reply;
364
+ expect((await call(handler, { alias: A, text: "first", in_reply_to: taskId, status: "replied" })).ok).toBe(true);
365
+ const replay = await call(handler, { alias: A, text: "second", in_reply_to: taskId, status: "replied" });
366
+ expect(replay).toEqual(expect.objectContaining({ ok: false, error: "reply_task_terminal", reply_queued: false }));
367
+ expect(task(taskId).result).toBe("first");
368
+ expect(replies(taskId)).toHaveLength(1);
369
+ });
370
+
371
+ test("all declared peer reply outcomes become exact terminal task states", async () => {
372
+ for (const status of ["replied", "failed", "cancelled"] as const) {
373
+ const taskId = await dispatch(A, B, `terminal-${status}`);
374
+ const result = await call(toolsFor(B).send_peer_reply, {
375
+ alias: A,
376
+ text: `result-${status}`,
377
+ in_reply_to: taskId,
378
+ status,
379
+ });
380
+ expect(result.ok).toBe(true);
381
+ expect(task(taskId)).toEqual(expect.objectContaining({
382
+ status,
383
+ result: `result-${status}`,
384
+ }));
385
+ expect(task(taskId).completed_at).toBeTruthy();
386
+ expect(replies(taskId)).toHaveLength(1);
387
+ expect(replies(taskId)[0].requires_response).toBe("none");
388
+ }
389
+ });
390
+
391
+ test("foreign node and wrong origin target are rejected with zero writes", async () => {
392
+ const taskId = await dispatch(A, B, "owned by B");
393
+ const foreign = await call(toolsFor(A).send_peer_reply, { alias: A, text: "forge", in_reply_to: taskId, status: "replied" });
394
+ expect(foreign).toEqual(expect.objectContaining({ ok: false, error: "reply_task_not_owned" }));
395
+ const wrongTarget = await call(toolsFor(B).send_peer_reply, { alias: B, text: "misroute", in_reply_to: taskId, status: "replied" });
396
+ expect(wrongTarget).toEqual(expect.objectContaining({ ok: false, error: "reply_target_mismatch" }));
397
+ expect(task(taskId).status).toBe("delivered");
398
+ expect(replies(taskId)).toHaveLength(0);
399
+ });
400
+
401
+ test("task update failure rolls back the reply inbox insert", async () => {
402
+ const taskId = await dispatch(A, B, "atomic failure");
403
+ db.exec(`CREATE TRIGGER test698_abort_terminal BEFORE UPDATE OF status ON tasks
404
+ WHEN OLD.task_id = '${taskId}' BEGIN SELECT RAISE(ABORT, 'test698 injected'); END`);
405
+ await expect(call(toolsFor(B).send_peer_reply, {
406
+ alias: A,
407
+ text: "must rollback",
408
+ in_reply_to: taskId,
409
+ status: "replied",
410
+ })).rejects.toThrow("test698 injected");
411
+ expect(task(taskId).status).toBe("delivered");
412
+ expect(task(taskId).result).toBeNull();
413
+ expect(replies(taskId)).toHaveLength(0);
414
+ });
415
+
416
+ test("node-token peer reply terminalizes the exact scheduled run in the same transaction", async () => {
417
+ const taskId = await dispatch(A, B, "scheduled work");
418
+ const { runId } = bindScheduledRun(taskId);
419
+ const result = await call(toolsFor(B).send_peer_reply, {
420
+ alias: A, text: "scheduled done", in_reply_to: taskId, status: "replied",
421
+ });
422
+ expect(result.ok).toBe(true);
423
+ expect(task(taskId).status).toBe("replied");
424
+ expect(db.get<any>("SELECT status, completed_at FROM scheduled_task_runs WHERE run_id = ?1", runId))
425
+ .toEqual(expect.objectContaining({ status: "replied" }));
426
+ });
427
+
428
+ test("scheduled-run write failure rolls back task and reply inbox together", async () => {
429
+ const taskId = await dispatch(A, B, "scheduled rollback");
430
+ const { runId } = bindScheduledRun(taskId);
431
+ db.exec(`CREATE TRIGGER test698_abort_run BEFORE UPDATE OF status ON scheduled_task_runs
432
+ WHEN OLD.run_id = '${runId}' BEGIN SELECT RAISE(ABORT, 'test698 injected run'); END`);
433
+ await expect(call(toolsFor(B).send_peer_reply, {
434
+ alias: A, text: "must all roll back", in_reply_to: taskId, status: "replied",
435
+ })).rejects.toThrow("test698 injected run");
436
+ expect(task(taskId).status).toBe("delivered");
437
+ expect(db.get<any>("SELECT status FROM scheduled_task_runs WHERE run_id = ?1", runId)?.status).toBe("delivered");
438
+ expect(replies(taskId)).toHaveLength(0);
439
+ });
440
+
441
+ test("three exchanges leave only dispatched work rows and no open tasks", async () => {
442
+ for (let i = 0; i < 3; i++) {
443
+ const taskId = await dispatch(A, B, `work-${i}`);
444
+ expect((await call(toolsFor(B).send_peer_reply, {
445
+ alias: A,
446
+ text: `result-${i}`,
447
+ in_reply_to: taskId,
448
+ status: "replied",
449
+ })).ok).toBe(true);
450
+ }
451
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE network_id = ?1", NET)?.n).toBe(3);
452
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE network_id = ?1 AND status IN ('created','delivered','acked','running')", NET)?.n).toBe(0);
453
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM inbox WHERE network_id = ?1 AND type = 'reply' AND requires_response = 'none'", NET)?.n).toBe(3);
454
+ });
455
+ });