@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71
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 +130 -10
- 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-delete-network-http.test.ts +177 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access.ts +461 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +167 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +423 -29
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -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-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter-translate.test.ts +112 -0
- package/src/db-adapter.ts +246 -83
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +799 -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 +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -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 +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +149 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -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/pg-worker.ts +87 -0
- package/src/push.ts +204 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +899 -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 +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +10 -1
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +625 -0
- package/src/scheduled-tasks.ts +597 -0
- package/src/send-dedup-request-id.test.ts +229 -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 +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4385 -0
- package/src/shared/external-schedule-contract.ts +60 -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 +233 -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 +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -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 +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3446 -257
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { maybeGzipResponse, wantsGzip, isCompressibleResponse, trimLightTask, GZIP_MIN_BYTES, LIGHT_TASK_MAX_CHARS } from "./http-gzip";
|
|
3
|
+
|
|
4
|
+
const big = JSON.stringify({ sessions: Array.from({ length: 200 }, (_, i) => ({ alias: `节点${i}`, task: "x".repeat(50) })) });
|
|
5
|
+
const json = (body: string, extra: Record<string, string> = {}) =>
|
|
6
|
+
new Response(body, { status: 200, headers: { "Content-Type": "application/json; charset=utf-8", ...extra } });
|
|
7
|
+
const reqWith = (ae?: string, method = "GET") =>
|
|
8
|
+
new Request("http://hub/api/status?light=1", { method, headers: ae === undefined ? {} : { "Accept-Encoding": ae } });
|
|
9
|
+
|
|
10
|
+
test("wantsGzip reads Accept-Encoding, including q-values", () => {
|
|
11
|
+
expect(wantsGzip(reqWith("gzip, deflate, br"))).toBe(true);
|
|
12
|
+
expect(wantsGzip(reqWith("br;q=1.0, gzip;q=0.8"))).toBe(true);
|
|
13
|
+
expect(wantsGzip(reqWith("identity"))).toBe(false);
|
|
14
|
+
expect(wantsGzip(reqWith("gzip;q=0"))).toBe(false);
|
|
15
|
+
expect(wantsGzip(reqWith())).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("a large JSON body is gzipped only when the client asked, and round-trips", async () => {
|
|
19
|
+
const out = await maybeGzipResponse(reqWith("gzip"), json(big));
|
|
20
|
+
expect(out!.headers.get("Content-Encoding")).toBe("gzip");
|
|
21
|
+
expect(out!.headers.get("Vary")).toContain("Accept-Encoding");
|
|
22
|
+
const bytes = new Uint8Array(await out!.arrayBuffer());
|
|
23
|
+
expect(bytes.byteLength).toBeLessThan(big.length / 3);
|
|
24
|
+
expect(Number(out!.headers.get("Content-Length"))).toBe(bytes.byteLength);
|
|
25
|
+
expect(new TextDecoder().decode(Bun.gunzipSync(bytes))).toBe(big);
|
|
26
|
+
expect(out!.headers.get("Content-Type")).toContain("application/json");
|
|
27
|
+
|
|
28
|
+
const plain = json(big);
|
|
29
|
+
expect(await maybeGzipResponse(reqWith(), plain)).toBe(plain); // untouched object
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("small, streamed, already-encoded, SSE, 204 and HEAD responses are left alone", async () => {
|
|
33
|
+
const small = json("{\"ok\":true}");
|
|
34
|
+
const outSmall = await maybeGzipResponse(reqWith("gzip"), small);
|
|
35
|
+
expect(outSmall!.headers.get("Content-Encoding")).toBeNull();
|
|
36
|
+
expect(await outSmall!.text()).toBe("{\"ok\":true}");
|
|
37
|
+
expect(GZIP_MIN_BYTES).toBe(1024);
|
|
38
|
+
|
|
39
|
+
const sse = new Response(new ReadableStream(), { headers: { "Content-Type": "text/event-stream" } });
|
|
40
|
+
expect(isCompressibleResponse(sse)).toBe(false);
|
|
41
|
+
expect(await maybeGzipResponse(reqWith("gzip"), sse)).toBe(sse);
|
|
42
|
+
|
|
43
|
+
const encoded = json(big, { "Content-Encoding": "br" });
|
|
44
|
+
expect(await maybeGzipResponse(reqWith("gzip"), encoded)).toBe(encoded);
|
|
45
|
+
|
|
46
|
+
const noContent = new Response(null, { status: 204 });
|
|
47
|
+
expect(await maybeGzipResponse(reqWith("gzip"), noContent)).toBe(noContent);
|
|
48
|
+
|
|
49
|
+
const head = json(big);
|
|
50
|
+
expect(await maybeGzipResponse(reqWith("gzip", "HEAD"), head)).toBe(head);
|
|
51
|
+
|
|
52
|
+
// /api/files downloads: Range replies and attachments keep their exact byte lengths (CI #509/#514 caught this)
|
|
53
|
+
const partial = new Response("x".repeat(4096), { status: 206, headers: { "Content-Type": "text/plain", "Content-Range": "bytes 0-4095/8192" } });
|
|
54
|
+
expect(await maybeGzipResponse(reqWith("gzip"), partial)).toBe(partial);
|
|
55
|
+
const attachment = new Response("y".repeat(4096), { status: 200, headers: { "Content-Type": "text/plain", "Content-Disposition": "attachment; filename=\"a.txt\"" } });
|
|
56
|
+
expect(await maybeGzipResponse(reqWith("gzip"), attachment)).toBe(attachment);
|
|
57
|
+
|
|
58
|
+
const binary = new Response(new Uint8Array(4096), { headers: { "Content-Type": "image/png" } });
|
|
59
|
+
expect(await maybeGzipResponse(reqWith("gzip"), binary)).toBe(binary);
|
|
60
|
+
|
|
61
|
+
expect(await maybeGzipResponse(reqWith("gzip"), undefined)).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("trimLightTask keeps a one-line preview under the cap", () => {
|
|
65
|
+
expect(trimLightTask(null)).toBeNull();
|
|
66
|
+
expect(trimLightTask(" ")).toBeNull();
|
|
67
|
+
expect(trimLightTask("a\n b")).toBe("a b");
|
|
68
|
+
const long = "字".repeat(LIGHT_TASK_MAX_CHARS + 40);
|
|
69
|
+
const t = trimLightTask(long)!;
|
|
70
|
+
expect(t.endsWith("…")).toBe(true);
|
|
71
|
+
expect([...t].length).toBe(LIGHT_TASK_MAX_CHARS + 1);
|
|
72
|
+
expect(trimLightTask("short")).toBe("short");
|
|
73
|
+
});
|
package/src/http-gzip.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// 2026-09-16(Vincent:「发一个消息都要被吞一段时间」「打开窗口历史很久才出来」):
|
|
2
|
+
// 桌面端经 RELAY 连 hub,链路实测只有 ~13 KB/s;/api/status?light=1 128 KB 要 17 s,
|
|
3
|
+
// 一页 20 条任务 41 KB 要 5.6 s,而 hub 从不压缩。gzip 后 128 KB → 31 KB、137 KB → 49 KB。
|
|
4
|
+
// 只在客户端明确 `Accept-Encoding: gzip` 时压;text/event-stream(SSE)与流式 body 一律不碰。
|
|
5
|
+
export const GZIP_MIN_BYTES = 1024;
|
|
6
|
+
|
|
7
|
+
const COMPRESSIBLE = /^(application\/json|text\/(plain|html|css|javascript)|application\/javascript)\b/i;
|
|
8
|
+
|
|
9
|
+
export function wantsGzip(req: Request): boolean {
|
|
10
|
+
const ae = req.headers.get("accept-encoding") || "";
|
|
11
|
+
for (const part of ae.split(",")) {
|
|
12
|
+
const [coding, ...params] = part.trim().split(";").map((x) => x.trim());
|
|
13
|
+
if (coding.toLowerCase() !== "gzip") continue;
|
|
14
|
+
const q = params.find((x) => /^q=/i.test(x));
|
|
15
|
+
if (!q) return true;
|
|
16
|
+
const n = Number(q.slice(2));
|
|
17
|
+
return Number.isFinite(n) && n > 0;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isCompressibleResponse(res: Response): boolean {
|
|
23
|
+
// 只压完整的 200:206 Partial Content / Content-Range / 附件下载(Content-Disposition)都有自己的
|
|
24
|
+
// 字节语义(Range 客户端按 Content-Length 数字节),压了就错 —— CI 的 /api/files Range 测试抓到的。
|
|
25
|
+
if (res.status !== 200 || !res.body) return false;
|
|
26
|
+
if (res.headers.get("content-encoding") || res.headers.get("content-range") || res.headers.get("content-disposition")) return false;
|
|
27
|
+
const ct = res.headers.get("content-type") || "";
|
|
28
|
+
if (/text\/event-stream/i.test(ct)) return false;
|
|
29
|
+
return COMPRESSIBLE.test(ct);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Gzip a finished response when the client asked for it and the body is worth it.
|
|
34
|
+
* Reads the body once; callers must not have consumed it. Anything streamed or
|
|
35
|
+
* not compressible is returned untouched (same object).
|
|
36
|
+
*/
|
|
37
|
+
export async function maybeGzipResponse(req: Request, res: Response | undefined | void): Promise<Response | undefined> {
|
|
38
|
+
if (!res) return res as undefined;
|
|
39
|
+
if (req.method === "HEAD" || !wantsGzip(req) || !isCompressibleResponse(res)) return res;
|
|
40
|
+
const raw = new Uint8Array(await res.arrayBuffer());
|
|
41
|
+
if (raw.byteLength < GZIP_MIN_BYTES) {
|
|
42
|
+
return new Response(raw, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
43
|
+
}
|
|
44
|
+
const gz = Bun.gzipSync(raw);
|
|
45
|
+
const headers = new Headers(res.headers);
|
|
46
|
+
headers.set("Content-Encoding", "gzip");
|
|
47
|
+
headers.set("Content-Length", String(gz.byteLength));
|
|
48
|
+
const vary = headers.get("Vary");
|
|
49
|
+
headers.set("Vary", vary && !/accept-encoding/i.test(vary) ? `${vary}, Accept-Encoding` : (vary || "Accept-Encoding"));
|
|
50
|
+
return new Response(gz, { status: res.status, statusText: res.statusText, headers });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 轻量列表里的 task 只给列表预览用;全文占了 light 载荷的一半以上。 */
|
|
54
|
+
export const LIGHT_TASK_MAX_CHARS = 160;
|
|
55
|
+
export function trimLightTask(task: unknown): string | null {
|
|
56
|
+
if (typeof task !== "string") return null;
|
|
57
|
+
const t = task.replace(/\s+/g, " ").trim();
|
|
58
|
+
if (!t) return null;
|
|
59
|
+
return t.length > LIGHT_TASK_MAX_CHARS ? `${t.slice(0, LIGHT_TASK_MAX_CHARS)}…` : t;
|
|
60
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseHubTimestamp } from "./hub-timestamp";
|
|
3
|
+
|
|
4
|
+
const RAW = "2026-08-30 21:11:24"; // sessions.last_seen_at 的真实取值
|
|
5
|
+
const TRUE_UTC = Date.UTC(2026, 7, 30, 21, 11, 24);
|
|
6
|
+
|
|
7
|
+
describe("parseHubTimestamp (#1650)", () => {
|
|
8
|
+
test("🔴 非 UTC 时区下也必须解出同一个 UTC 时刻", () => {
|
|
9
|
+
// `bun test` 把测试进程时区固定成 UTC(getTimezoneOffset() === 0)。
|
|
10
|
+
// 在 UTC 下,「补 Z」和「不补 Z」解析出**同一个数** —— 这个函数存在的那个
|
|
11
|
+
// 缺陷在测试进程里**结构上不可见**。所以这一条必须开 TZ≠UTC 的子进程。
|
|
12
|
+
// 实测:去掉补 Z,本文件其余各条**全绿**。
|
|
13
|
+
const child = `import { parseHubTimestamp } from ${JSON.stringify(import.meta.dir + "/hub-timestamp.ts")};
|
|
14
|
+
process.stdout.write(new Date().getTimezoneOffset() + "," + parseHubTimestamp(${JSON.stringify(RAW)}));`;
|
|
15
|
+
const r = Bun.spawnSync(["bun", "-e", child], { env: { ...process.env, TZ: "Asia/Shanghai" } });
|
|
16
|
+
const [offset, parsed] = r.stdout.toString().trim().split(",");
|
|
17
|
+
// 🔴 先断言控制生效:子进程真的不在 UTC。否则 TZ 被忽略时这条又变成空测试。
|
|
18
|
+
expect(Number(offset)).not.toBe(0);
|
|
19
|
+
expect(parsed).toBe(String(TRUE_UTC));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("已带 Z / ±HH:MM 的串不再追加时区", () => {
|
|
23
|
+
expect(parseHubTimestamp("2026-08-30T21:11:24Z")).toBe(TRUE_UTC);
|
|
24
|
+
expect(parseHubTimestamp("2026-08-30T22:11:24+01:00")).toBe(TRUE_UTC);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("拿不到就返回 null,不返回一个看起来合理的数", () => {
|
|
28
|
+
for (const bad of [undefined, null, "", " ", 0, 1788124284000, {}, [], "2026-08-30", "垃圾"]) {
|
|
29
|
+
expect(parseHubTimestamp(bad as unknown)).toBeNull();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("🔴 INTEGER 列(epoch 毫秒)不归它管 —— 传数字必须返回 null,而不是悄悄接受", () => {
|
|
34
|
+
// external_schedule_edits.* 是 INTEGER。如果有人把本函数用在那儿,
|
|
35
|
+
// 应当立刻拿到 null 而不是一个错的时刻。
|
|
36
|
+
expect(parseHubTimestamp(TRUE_UTC)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1650 — hub 的 TEXT 时间戳列是 SQLite `datetime('now')` 写的,
|
|
3
|
+
* 形如 `2026-08-30 21:11:24`:**UTC,但不带任何时区标记**。
|
|
4
|
+
*
|
|
5
|
+
* 🔴 `new Date(raw)` / `Date.parse(raw)` 对这种串按**本地时间**解析(ES 规范:
|
|
6
|
+
* 带时间的形式无偏移时视为本地时间),误差恰好等于主机时区偏移,**而且不报错**:
|
|
7
|
+
*
|
|
8
|
+
* TZ=UTC 误差 0h
|
|
9
|
+
* TZ=Asia/Shanghai 误差 -8h (看起来更陈旧)
|
|
10
|
+
* TZ=America/Los_Angeles 误差 +7h (看起来更新鲜 ← 朝好的一侧错)
|
|
11
|
+
*
|
|
12
|
+
* 🔴 **只对 TEXT 列用它。** 同一个字段名在不同表里可能是不同类型:
|
|
13
|
+
* sessions.last_seen_at / licenses.expires_at → TEXT(要用本函数)
|
|
14
|
+
* external_schedule_edits.expires_at 等 4 列 → INTEGER(epoch 毫秒,直接 new Date 就对)
|
|
15
|
+
* 按字段名判会判错 —— 见 #1650 里的更正。
|
|
16
|
+
*
|
|
17
|
+
* 与 agent-network 侧的同名函数是**有意的重复**:server 包不依赖 agent-network,
|
|
18
|
+
* 仓里也没有共享 lib。两边各自带测试。
|
|
19
|
+
*/
|
|
20
|
+
export function parseHubTimestamp(raw: unknown): number | null {
|
|
21
|
+
if (typeof raw !== "string") return null;
|
|
22
|
+
const t = raw.trim();
|
|
23
|
+
if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/.test(t)) return null;
|
|
24
|
+
const hasZone = /([Zz]|[+-]\d{2}:?\d{2})$/.test(t);
|
|
25
|
+
const ms = Date.parse(t.replace(" ", "T") + (hasZone ? "" : "Z"));
|
|
26
|
+
return Number.isFinite(ms) ? ms : null;
|
|
27
|
+
}
|
package/src/human-dm.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// 多用户账号:人与人私信(同一网络里的两个用户之间)。
|
|
2
|
+
//
|
|
3
|
+
// 投递复用 user_inbox(与 send_desktop_message 同一张表、同一条 /events/users/me 推送、同一套
|
|
4
|
+
// /api/messages?scope=user 未读与 ack),另外记下 sender_user_id —— 由 Hub 按发信人的用户令牌写入,
|
|
5
|
+
// 不是客户端声称的 from_session。会话记录按 (收件人, sender_user_id) 双向取,所以发信人看得到自己发的。
|
|
6
|
+
//
|
|
7
|
+
// 与 Agent 权限无关:受限成员(看不到任何 Agent 的成员)照样能和网络里的其他人私信。
|
|
8
|
+
|
|
9
|
+
import { db, uuidv4 } from "./db.js";
|
|
10
|
+
import { getUserNetworkRole } from "./auth.js";
|
|
11
|
+
import { isAgentRestricted } from "./agent-access.js";
|
|
12
|
+
import { pushUserEvent, hasUserSubscribers } from "./push.js";
|
|
13
|
+
import { existsSync, readFileSync } from "fs";
|
|
14
|
+
import { FILE_ID_REGEX, indexEntryPath, validateAttachments, validateIndexEntry } from "./uploads.js";
|
|
15
|
+
import { restrictedMemberCanUseFile } from "./restricted-files.js";
|
|
16
|
+
import { redactMessageRow } from "./redact-tokens.js";
|
|
17
|
+
|
|
18
|
+
export const HUMAN_DM_KIND = "human_dm";
|
|
19
|
+
const MAX_DM_CHARS = 10_000;
|
|
20
|
+
|
|
21
|
+
export type HumanDmSendInput = {
|
|
22
|
+
networkId: string;
|
|
23
|
+
sender: { userId: string; username: string };
|
|
24
|
+
toUserId?: unknown;
|
|
25
|
+
toUsername?: unknown;
|
|
26
|
+
message?: unknown;
|
|
27
|
+
attachments?: unknown;
|
|
28
|
+
clientRequestId?: unknown;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type HumanDmSendResult =
|
|
32
|
+
| { ok: true; message: Record<string, unknown>; delivered: boolean }
|
|
33
|
+
| { ok: false; error: string; status: number };
|
|
34
|
+
|
|
35
|
+
export function sendHumanDm(input: HumanDmSendInput): HumanDmSendResult {
|
|
36
|
+
const { networkId, sender } = input;
|
|
37
|
+
if (!getUserNetworkRole(sender.userId, networkId)) return { ok: false, error: "not a member of this network", status: 403 };
|
|
38
|
+
const text = typeof input.message === "string" ? input.message : "";
|
|
39
|
+
if (text.length > MAX_DM_CHARS) return { ok: false, error: "message_too_long", status: 400 };
|
|
40
|
+
|
|
41
|
+
const byId = typeof input.toUserId === "string" && input.toUserId
|
|
42
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE user_id = ?1", input.toUserId)
|
|
43
|
+
: null;
|
|
44
|
+
const byName = typeof input.toUsername === "string" && input.toUsername
|
|
45
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE username = ?1", input.toUsername)
|
|
46
|
+
: null;
|
|
47
|
+
if (byId && byName && byId.user_id !== byName.user_id) return { ok: false, error: "dm_target_mismatch", status: 400 };
|
|
48
|
+
const target = byId ?? byName;
|
|
49
|
+
// 不存在与不在本网络同一个错误,不给用户名探测留差异。
|
|
50
|
+
if (!target || !getUserNetworkRole(target.user_id, networkId)) return { ok: false, error: "dm_target_not_in_network", status: 404 };
|
|
51
|
+
if (target.user_id === sender.userId) return { ok: false, error: "dm_to_self", status: 400 };
|
|
52
|
+
|
|
53
|
+
const attachments = validateAttachments(input.attachments);
|
|
54
|
+
if (!attachments.ok) return { ok: false, error: "bad_attachments", status: 400 };
|
|
55
|
+
if (!text.trim() && attachments.attachments.length === 0) return { ok: false, error: "message_required", status: 400 };
|
|
56
|
+
const restricted = isAgentRestricted(sender.userId, networkId);
|
|
57
|
+
for (const a of attachments.attachments) {
|
|
58
|
+
const fileId = (a as { file_id?: string }).file_id;
|
|
59
|
+
if (!fileId) continue;
|
|
60
|
+
const usable = restricted
|
|
61
|
+
? restrictedMemberCanUseFile(sender.userId, sender.username, networkId, fileId)
|
|
62
|
+
: fileInNetwork(networkId, fileId);
|
|
63
|
+
if (!usable) return { ok: false, error: "attachment_not_accessible", status: 403 };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const clientRequestId = typeof input.clientRequestId === "string" && /^[A-Za-z0-9._-]{1,80}$/.test(input.clientRequestId)
|
|
67
|
+
? input.clientRequestId : null;
|
|
68
|
+
// 同一个气泡重试:按 (发信人, client_request_id) 定出同一个 message_id,重投不产生第二条。
|
|
69
|
+
const messageId = clientRequestId
|
|
70
|
+
? `dm_${sender.userId}_${clientRequestId}`.slice(0, 120)
|
|
71
|
+
: `dm_${uuidv4()}`;
|
|
72
|
+
const metaJson = attachments.attachments.length ? JSON.stringify({ attachments: attachments.attachments }) : null;
|
|
73
|
+
|
|
74
|
+
db.transaction(() => {
|
|
75
|
+
db.run(
|
|
76
|
+
// created_at 带毫秒:同一秒里一来一回的两条私信要能排出先后(秒级的 datetime('now') 排不出)。
|
|
77
|
+
`INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, sender_user_id, created_at)
|
|
78
|
+
VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, 'info', ?7, ?8, strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
79
|
+
ON CONFLICT(message_id) DO NOTHING`,
|
|
80
|
+
[messageId, networkId, target.user_id, sender.username, HUMAN_DM_KIND, text, metaJson, sender.userId],
|
|
81
|
+
);
|
|
82
|
+
db.run(
|
|
83
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
84
|
+
VALUES (?1, ?2, 'human_dm_sent', 'user', ?3, ?4, ?5)`,
|
|
85
|
+
[sender.userId, sender.username, target.user_id, JSON.stringify({ message_id: messageId, attachments: attachments.attachments.length }), networkId],
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
const row = db.get<Record<string, unknown>>(
|
|
89
|
+
`SELECT message_id, network_id, user_id, sender_user_id, from_session, kind, content, meta_json, acked, created_at
|
|
90
|
+
FROM user_inbox WHERE message_id = ?1`,
|
|
91
|
+
messageId,
|
|
92
|
+
)!;
|
|
93
|
+
// 推送在提交之后(与 send_desktop_message 同一条规矩:回滚的消息不能先被看见)。
|
|
94
|
+
const delivered = hasUserSubscribers(networkId, target.user_id);
|
|
95
|
+
pushUserEvent(networkId, target.user_id, {
|
|
96
|
+
type: "desktop_message",
|
|
97
|
+
message_id: messageId,
|
|
98
|
+
kind: HUMAN_DM_KIND,
|
|
99
|
+
from: sender.username,
|
|
100
|
+
from_user_id: sender.userId,
|
|
101
|
+
title: null,
|
|
102
|
+
message: text,
|
|
103
|
+
severity: "info",
|
|
104
|
+
created_at: new Date().toISOString(),
|
|
105
|
+
...(metaJson ? { meta: JSON.parse(metaJson) } : {}),
|
|
106
|
+
});
|
|
107
|
+
return { ok: true, message: redactMessageRow(row), delivered };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 不受限的成员带附件:文件必须属于这个网络(防把别的网络的 file_id 塞进来)。 */
|
|
111
|
+
function fileInNetwork(networkId: string, fileId: string): boolean {
|
|
112
|
+
if (!FILE_ID_REGEX.test(fileId)) return false;
|
|
113
|
+
const path = indexEntryPath(fileId);
|
|
114
|
+
if (!path || !existsSync(path)) return false;
|
|
115
|
+
try {
|
|
116
|
+
const entry = JSON.parse(readFileSync(path, "utf8"));
|
|
117
|
+
return validateIndexEntry(entry) && entry.file_id === fileId && entry.network_id === networkId;
|
|
118
|
+
} catch { return false; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 我和某人的私信记录(双向),新的在前。before = 上一页最后一条的 created_at。 */
|
|
122
|
+
export function listDmThread(networkId: string, meUserId: string, otherUserId: string, limit = 50, before?: string | null) {
|
|
123
|
+
const params: unknown[] = [networkId, meUserId, otherUserId];
|
|
124
|
+
let sql = `SELECT message_id, network_id, user_id, sender_user_id, from_session, kind, content, meta_json, acked, created_at
|
|
125
|
+
FROM user_inbox
|
|
126
|
+
WHERE network_id = ?1 AND kind = '${HUMAN_DM_KIND}'
|
|
127
|
+
AND ((user_id = ?2 AND sender_user_id = ?3) OR (user_id = ?3 AND sender_user_id = ?2))`;
|
|
128
|
+
if (before) { params.push(before); sql += ` AND created_at < ?${params.length}`; }
|
|
129
|
+
params.push(Math.max(1, Math.min(limit, 200)));
|
|
130
|
+
sql += ` ORDER BY created_at DESC, rowid DESC LIMIT ?${params.length}`;
|
|
131
|
+
return db.all<Record<string, unknown>>(sql, ...params).map((row) => ({
|
|
132
|
+
...redactMessageRow(row),
|
|
133
|
+
direction: row.sender_user_id === meUserId ? "out" : "in",
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 我在这个网络里的私信会话:每个对方一行,最后一条时间 + 我这边的未读数。 */
|
|
138
|
+
export function listDmThreads(networkId: string, meUserId: string) {
|
|
139
|
+
return db.all<{ other_user_id: string; last_at: string; unread: number }>(
|
|
140
|
+
`SELECT other_user_id, MAX(created_at) AS last_at, SUM(unread) AS unread FROM (
|
|
141
|
+
SELECT sender_user_id AS other_user_id, created_at, CASE WHEN acked = 0 THEN 1 ELSE 0 END AS unread
|
|
142
|
+
FROM user_inbox WHERE network_id = ?1 AND user_id = ?2 AND kind = '${HUMAN_DM_KIND}' AND sender_user_id IS NOT NULL
|
|
143
|
+
UNION ALL
|
|
144
|
+
SELECT user_id AS other_user_id, created_at, 0 AS unread
|
|
145
|
+
FROM user_inbox WHERE network_id = ?1 AND sender_user_id = ?2 AND kind = '${HUMAN_DM_KIND}'
|
|
146
|
+
) GROUP BY other_user_id ORDER BY last_at DESC`,
|
|
147
|
+
networkId, meUserId,
|
|
148
|
+
).map((r) => ({ other_user_id: r.other_user_id, last_at: r.last_at, unread: Number(r.unread ?? 0) }));
|
|
149
|
+
}
|
|
@@ -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-data.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
|
+
});
|