@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.41
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/README.md +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +107 -13
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +80 -1
- package/src/create-node-validate.ts +49 -5
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +654 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +731 -0
- package/src/health-redaction.test.ts +637 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2187
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/node-id-alias.test.ts +147 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/observer-avatar-http.test.ts +424 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/push.ts +163 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +212 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +479 -0
- package/src/scheduled-tasks.ts +549 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +3664 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +952 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +471 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +200 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +2994 -191
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-read-http.test.ts +220 -0
- package/src/user-inbox-schema.test.ts +53 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// SSE `inbox_count` contract for the message-shaped events.
|
|
2
|
+
//
|
|
3
|
+
// Background: `new_task` has shipped a real unacked count since #1304,
|
|
4
|
+
// but `new_message` / `new_reply` carried NO count at all and
|
|
5
|
+
// `broadcast` carried a hardcoded `inbox_count: 1`. A client that
|
|
6
|
+
// renders "N new" from the SSE payload therefore never moved for plain
|
|
7
|
+
// messages and replies, and showed a constant 1 for broadcasts.
|
|
8
|
+
//
|
|
9
|
+
// Gate discipline: every assertion below expects a count that is NOT 1
|
|
10
|
+
// (pre-seeded unacked rows push the expected value to 2 or 3). A
|
|
11
|
+
// hardcoded `1` — the exact defect being fixed — fails these tests
|
|
12
|
+
// rather than passing them. Asserting `=== 1` would have been a fake
|
|
13
|
+
// gate that the old code satisfied.
|
|
14
|
+
//
|
|
15
|
+
// Run: COMMHUB_DB=/tmp/inbox-count-sse.db bun test src/inbox-count-sse.test.ts
|
|
16
|
+
|
|
17
|
+
import { afterAll, beforeEach, afterEach, describe, expect, test } from "bun:test";
|
|
18
|
+
import { db, uuidv4 } from "./db.js";
|
|
19
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
20
|
+
import { registerTools } from "./tools.js";
|
|
21
|
+
import { __resetSSEClientsForTest, createSSEStream } from "./push.js";
|
|
22
|
+
import { pendingInboxCount } from "./inbox-count.js";
|
|
23
|
+
|
|
24
|
+
const NET = "net_inboxcount";
|
|
25
|
+
const OWNER = "u_inboxcount_owner";
|
|
26
|
+
const RECV = "peer-inboxcount-recv"; // receives messages/replies
|
|
27
|
+
const OTHER = "peer-inboxcount-other"; // second broadcast recipient
|
|
28
|
+
const SENDER = "peer-inboxcount-send";
|
|
29
|
+
// #1440 ① — a broadcast target the lifecycle guard skips.
|
|
30
|
+
const STOPPED = "peer-inboxcount-stopped";
|
|
31
|
+
const ALIASES = [RECV, OTHER, SENDER];
|
|
32
|
+
|
|
33
|
+
type ToolHandler = (args: any, extra?: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
|
|
34
|
+
|
|
35
|
+
function cleanup() {
|
|
36
|
+
for (const t of ["tasks", "inbox", "task_events"]) {
|
|
37
|
+
try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
|
|
38
|
+
}
|
|
39
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET]); } catch {}
|
|
40
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
|
|
41
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
42
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
43
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [OWNER]); } catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function seed() {
|
|
47
|
+
db.run(
|
|
48
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
49
|
+
VALUES (?1, ?1, 'x', 'user', datetime('now'))`,
|
|
50
|
+
[OWNER],
|
|
51
|
+
);
|
|
52
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET, OWNER]);
|
|
53
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [OWNER, NET]);
|
|
54
|
+
for (const alias of ALIASES) {
|
|
55
|
+
db.run(
|
|
56
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
|
|
57
|
+
VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'), datetime('now'), 'active')`,
|
|
58
|
+
[`node_${alias}`, alias, NET, `host-${alias}`],
|
|
59
|
+
);
|
|
60
|
+
db.run(
|
|
61
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
|
|
62
|
+
VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
|
|
63
|
+
[`res_${alias}`, alias, `node_${alias}`, NET],
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
// A session that broadcast will select as a target, but whose node is
|
|
67
|
+
// NOT active — assertNodeActive skips it, so it gets no inbox row.
|
|
68
|
+
db.run(
|
|
69
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
|
|
70
|
+
VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'), datetime('now'), 'stopped')`,
|
|
71
|
+
[`node_${STOPPED}`, STOPPED, NET, `host-${STOPPED}`],
|
|
72
|
+
);
|
|
73
|
+
db.run(
|
|
74
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
|
|
75
|
+
VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
|
|
76
|
+
[`res_${STOPPED}`, STOPPED, `node_${STOPPED}`, NET],
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
beforeEach(() => { cleanup(); seed(); });
|
|
81
|
+
afterEach(() => { __resetSSEClientsForTest(); });
|
|
82
|
+
afterAll(cleanup);
|
|
83
|
+
|
|
84
|
+
function buildHandlers(alias: string): Record<string, ToolHandler> {
|
|
85
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
86
|
+
const tools: Record<string, ToolHandler> = {};
|
|
87
|
+
const origTool = server.tool.bind(server);
|
|
88
|
+
server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => {
|
|
89
|
+
tools[name] = handler;
|
|
90
|
+
return origTool(name, _desc, schema, handler);
|
|
91
|
+
};
|
|
92
|
+
registerTools(server, undefined, NET, OWNER, alias, true, null);
|
|
93
|
+
return tools;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function call(handler: ToolHandler, args: any): Promise<Record<string, any>> {
|
|
97
|
+
const r = await handler(args);
|
|
98
|
+
return JSON.parse(r.content[0].text);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Read the next SSE data frame, with a hard timeout so a silent stream
|
|
102
|
+
* fails instead of hanging. Skips keepalive comment frames. */
|
|
103
|
+
async function readFrame(
|
|
104
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
105
|
+
timeoutMs = 2_000,
|
|
106
|
+
): Promise<Record<string, any>> {
|
|
107
|
+
const decoder = new TextDecoder();
|
|
108
|
+
let buf = "";
|
|
109
|
+
const deadline = Date.now() + timeoutMs;
|
|
110
|
+
while (Date.now() < deadline) {
|
|
111
|
+
const result = await Promise.race([
|
|
112
|
+
reader.read(),
|
|
113
|
+
new Promise<never>((_, rej) =>
|
|
114
|
+
setTimeout(() => rej(new Error(`no SSE frame within ${timeoutMs}ms`)), deadline - Date.now()),
|
|
115
|
+
),
|
|
116
|
+
]);
|
|
117
|
+
if (result.done) throw new Error("SSE stream ended unexpectedly");
|
|
118
|
+
buf += decoder.decode(result.value, { stream: true });
|
|
119
|
+
const sep = buf.indexOf("\n\n");
|
|
120
|
+
if (sep === -1) continue;
|
|
121
|
+
const rawFrame = buf.slice(0, sep);
|
|
122
|
+
buf = buf.slice(sep + 2);
|
|
123
|
+
const dataLine = rawFrame.split("\n").find((l) => l.startsWith("data: "));
|
|
124
|
+
if (!dataLine) continue;
|
|
125
|
+
return JSON.parse(dataLine.slice(6));
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`no SSE frame within ${timeoutMs}ms`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Subscribe and swallow the initial `connected` frame. */
|
|
131
|
+
async function subscribe(alias: string) {
|
|
132
|
+
const reader = createSSEStream(alias, NET).body!.getReader();
|
|
133
|
+
await readFrame(reader);
|
|
134
|
+
return reader;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Pre-existing unacked inbox rows, so the expected count is never 1. */
|
|
138
|
+
function seedUnacked(alias: string, n: number, type = "message") {
|
|
139
|
+
for (let i = 0; i < n; i++) {
|
|
140
|
+
db.run(
|
|
141
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
|
|
142
|
+
VALUES (?1, ?2, ?3, ?4, 'normal', ?5, ?6, ?7)`,
|
|
143
|
+
[uuidv4(), alias, `node_${alias}`, type, `backlog ${i}`, SENDER, NET],
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function makeTask(from: string, to: string, content: string, status = "delivered"): string {
|
|
149
|
+
const id = uuidv4();
|
|
150
|
+
db.run(
|
|
151
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
|
|
152
|
+
VALUES (?1, ?2, ?3, 'normal', ?4, ?5, 'reply', datetime('now'), ?6)`,
|
|
153
|
+
[id, from, to, status, content, NET],
|
|
154
|
+
);
|
|
155
|
+
return id;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
describe("SSE inbox_count", () => {
|
|
159
|
+
test("new_message carries the recipient's real unacked count, not a hardcoded 1", async () => {
|
|
160
|
+
seedUnacked(RECV, 2);
|
|
161
|
+
const reader = await subscribe(RECV);
|
|
162
|
+
|
|
163
|
+
const tools = buildHandlers(SENDER);
|
|
164
|
+
const res = await call(tools.send_message, { alias: RECV, message: "hello", network_id: NET });
|
|
165
|
+
expect(res.ok ?? true).not.toBe(false);
|
|
166
|
+
|
|
167
|
+
const evt = await readFrame(reader);
|
|
168
|
+
expect(evt.type).toBe("new_message");
|
|
169
|
+
// 2 seeded + this one. A hardcoded 1 — or a missing field — fails here.
|
|
170
|
+
expect(evt.inbox_count).toBe(3);
|
|
171
|
+
expect(evt.inbox_count).toBe(pendingInboxCount(RECV, NET));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("new_reply carries the reply target's real unacked count", async () => {
|
|
175
|
+
seedUnacked(RECV, 1);
|
|
176
|
+
const taskId = makeTask(RECV, SENDER, "please do the thing");
|
|
177
|
+
const reader = await subscribe(RECV);
|
|
178
|
+
|
|
179
|
+
const tools = buildHandlers(SENDER);
|
|
180
|
+
const res = await call(tools.send_reply, {
|
|
181
|
+
alias: RECV,
|
|
182
|
+
text: "done",
|
|
183
|
+
in_reply_to: taskId,
|
|
184
|
+
status: "completed",
|
|
185
|
+
network_id: NET,
|
|
186
|
+
});
|
|
187
|
+
expect(res.ok).toBe(true);
|
|
188
|
+
|
|
189
|
+
const evt = await readFrame(reader);
|
|
190
|
+
expect(evt.type).toBe("new_reply");
|
|
191
|
+
// 1 seeded + the reply row. Not 1, and not absent.
|
|
192
|
+
expect(evt.inbox_count).toBe(2);
|
|
193
|
+
expect(evt.inbox_count).toBe(pendingInboxCount(RECV, NET));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("broadcast counts per recipient instead of shipping a constant 1", async () => {
|
|
197
|
+
// Asymmetric backlogs: if the payload were hardcoded, both
|
|
198
|
+
// recipients would report the same number. They must not.
|
|
199
|
+
seedUnacked(RECV, 2);
|
|
200
|
+
const recvReader = await subscribe(RECV);
|
|
201
|
+
const otherReader = await subscribe(OTHER);
|
|
202
|
+
|
|
203
|
+
const tools = buildHandlers(SENDER);
|
|
204
|
+
await call(tools.broadcast, { message: "all hands", network_id: NET });
|
|
205
|
+
|
|
206
|
+
const recvEvt = await readFrame(recvReader);
|
|
207
|
+
const otherEvt = await readFrame(otherReader);
|
|
208
|
+
expect(recvEvt.type).toBe("broadcast");
|
|
209
|
+
expect(otherEvt.type).toBe("broadcast");
|
|
210
|
+
|
|
211
|
+
// RECV: 2 seeded + the broadcast row. OTHER: only the broadcast row.
|
|
212
|
+
expect(recvEvt.inbox_count).toBe(3);
|
|
213
|
+
expect(otherEvt.inbox_count).toBe(1);
|
|
214
|
+
expect(recvEvt.inbox_count).not.toBe(otherEvt.inbox_count);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("acked rows are excluded — the count tracks what is still unread", async () => {
|
|
218
|
+
seedUnacked(RECV, 3);
|
|
219
|
+
db.run("UPDATE inbox SET acked = 1 WHERE session_name = ?1 AND network_id = ?2", [RECV, NET]);
|
|
220
|
+
const reader = await subscribe(RECV);
|
|
221
|
+
|
|
222
|
+
const tools = buildHandlers(SENDER);
|
|
223
|
+
await call(tools.send_message, { alias: RECV, message: "after the catch-up", network_id: NET });
|
|
224
|
+
|
|
225
|
+
const evt = await readFrame(reader);
|
|
226
|
+
// 3 acked rows contribute nothing; only the new one is outstanding.
|
|
227
|
+
expect(evt.inbox_count).toBe(1);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// #1440 ② — `retry_task` and `reassign_task` re-queue a task and push
|
|
232
|
+
// `new_task`, but both shipped a hardcoded `inbox_count: 1` while the
|
|
233
|
+
// hub already knew the real number. Same defect family as the
|
|
234
|
+
// `broadcast` case above; same gate discipline — the expected counts
|
|
235
|
+
// here are 3, so a hardcoded 1 cannot satisfy them.
|
|
236
|
+
describe("#1440 new_task inbox_count on requeue paths", () => {
|
|
237
|
+
test("retry_task carries the real unacked count, not a hardcoded 1", async () => {
|
|
238
|
+
seedUnacked(RECV, 2);
|
|
239
|
+
const taskId = makeTask(SENDER, RECV, "flaky job", "failed");
|
|
240
|
+
const reader = await subscribe(RECV);
|
|
241
|
+
|
|
242
|
+
const tools = buildHandlers(SENDER);
|
|
243
|
+
const res = await call(tools.retry_task, { task_id: taskId, network_id: NET });
|
|
244
|
+
expect(res.ok).toBe(true);
|
|
245
|
+
|
|
246
|
+
const evt = await readFrame(reader);
|
|
247
|
+
expect(evt.type).toBe("new_task");
|
|
248
|
+
// 2 seeded + the re-queued task row.
|
|
249
|
+
expect(evt.inbox_count).toBe(3);
|
|
250
|
+
expect(evt.inbox_count).toBe(pendingInboxCount(RECV, NET));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("reassign_task carries the NEW assignee's real unacked count", async () => {
|
|
254
|
+
seedUnacked(RECV, 2);
|
|
255
|
+
const taskId = makeTask(SENDER, OTHER, "moved job");
|
|
256
|
+
const reader = await subscribe(RECV);
|
|
257
|
+
|
|
258
|
+
const tools = buildHandlers(SENDER);
|
|
259
|
+
const res = await call(tools.reassign_task, { task_id: taskId, new_alias: RECV, network_id: NET });
|
|
260
|
+
expect(res.ok).toBe(true);
|
|
261
|
+
expect(res.reassigned_to).toBe(RECV);
|
|
262
|
+
|
|
263
|
+
const evt = await readFrame(reader);
|
|
264
|
+
expect(evt.type).toBe("new_task");
|
|
265
|
+
// 2 seeded on the new assignee + the row reassign just wrote.
|
|
266
|
+
expect(evt.inbox_count).toBe(3);
|
|
267
|
+
expect(evt.inbox_count).toBe(pendingInboxCount(RECV, NET));
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// #1440 ① — the broadcast push loop walked every candidate target,
|
|
272
|
+
// including the ones assertNodeActive had just skipped. Those nodes
|
|
273
|
+
// were told "you have a broadcast" while their inbox got no row for it.
|
|
274
|
+
// Before #1439 the hardcoded `inbox_count: 1` hid this: everyone got
|
|
275
|
+
// the same digit, so a spurious event was indistinguishable from a real
|
|
276
|
+
// one.
|
|
277
|
+
describe("#1440 broadcast delivery set", () => {
|
|
278
|
+
test("a lifecycle-skipped node gets no inbox row AND no broadcast event", async () => {
|
|
279
|
+
const activeReader = await subscribe(OTHER);
|
|
280
|
+
const stoppedReader = await subscribe(STOPPED);
|
|
281
|
+
|
|
282
|
+
const tools = buildHandlers(SENDER);
|
|
283
|
+
const res = await call(tools.broadcast, { message: "all hands", network_id: NET });
|
|
284
|
+
|
|
285
|
+
// The active recipient is served — this proves the broadcast ran, so
|
|
286
|
+
// the absence checked below is "not sent", not "not yet sent"
|
|
287
|
+
// (pushEvent is synchronous inside the handler).
|
|
288
|
+
const activeEvt = await readFrame(activeReader);
|
|
289
|
+
expect(activeEvt.type).toBe("broadcast");
|
|
290
|
+
|
|
291
|
+
// The skipped node has no row...
|
|
292
|
+
const rows = db.get<{ cnt: number }>(
|
|
293
|
+
"SELECT COUNT(*) AS cnt FROM inbox WHERE session_name = ?1 AND type = 'broadcast'",
|
|
294
|
+
STOPPED,
|
|
295
|
+
);
|
|
296
|
+
expect(rows?.cnt).toBe(0);
|
|
297
|
+
// ...and must therefore receive no event announcing one.
|
|
298
|
+
await expect(readFrame(stoppedReader, 300)).rejects.toThrow(/no SSE frame/);
|
|
299
|
+
|
|
300
|
+
// message_ids covers only the recipients that actually got a row:
|
|
301
|
+
// RECV + OTHER + SENDER, never STOPPED.
|
|
302
|
+
expect(res.message_ids).toHaveLength(3);
|
|
303
|
+
// #1440 ③ — `recipients` counted candidates, not deliveries, so it
|
|
304
|
+
// disagreed with the documented invariant
|
|
305
|
+
// ("message_ids.length === recipients", docs-site/docs/api/rest.md)
|
|
306
|
+
// exactly when a node was skipped. 4 sessions, 3 delivered.
|
|
307
|
+
expect(res.recipients).toBe(res.message_ids.length);
|
|
308
|
+
expect(res.recipients).toBe(3);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unacked inbox rows for `alias` — the number a client should show as
|
|
5
|
+
* "new". This is the count `new_task` has shipped since #1304; it is
|
|
6
|
+
* lifted here verbatim so every SSE event that carries `inbox_count`
|
|
7
|
+
* derives it the same way instead of hardcoding a digit.
|
|
8
|
+
*
|
|
9
|
+
* The SQL and its network scoping are unchanged from the `new_task`
|
|
10
|
+
* sites (tools.ts / server.ts): falsy `networkId` means "no network
|
|
11
|
+
* filter", matching both `addScope()` and the inline `if (taskNetId)`
|
|
12
|
+
* guard those sites already use.
|
|
13
|
+
*
|
|
14
|
+
* The `?? 1` fallback only fires if the COUNT itself returns no row,
|
|
15
|
+
* which SQLite does not do. It is not a default count: it is the
|
|
16
|
+
* fail-closed direction for this gate. Every caller pushes right after
|
|
17
|
+
* inserting an inbox row, so the floor is genuinely 1, and under-
|
|
18
|
+
* reporting an unread (badge says 0, a message is sitting there) is the
|
|
19
|
+
* failure this event exists to prevent.
|
|
20
|
+
*/
|
|
21
|
+
export function pendingInboxCount(alias: string, networkId?: string | null): number {
|
|
22
|
+
const params: any[] = [alias];
|
|
23
|
+
let sql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
24
|
+
if (networkId) {
|
|
25
|
+
sql += " AND network_id = ?2";
|
|
26
|
+
params.push(networkId);
|
|
27
|
+
}
|
|
28
|
+
return db.get<{ cnt: number }>(sql, ...params)?.cnt ?? 1;
|
|
29
|
+
}
|