@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.101
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 +92 -11
- package/package.json +8 -7
- 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 +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/agent-notice.ts +48 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -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 +188 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +493 -35
- 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/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +270 -1
- 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 +275 -0
- package/src/create-node-validate.ts +288 -0
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +349 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +984 -34
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +329 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -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/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2172
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-context-cost-http.test.ts +119 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/network-token-membership-http.test.ts +139 -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-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +72 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -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-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +242 -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 +431 -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/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/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- 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-due-reminders-http.test.ts +518 -0
- package/src/requirement-due-reminders.ts +369 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-comment-http.test.ts +165 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +344 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-status-participants-http.test.ts +220 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1761 -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 +222 -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 +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- 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-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +650 -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 +5038 -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/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/side-thread-command-transport.test.ts +312 -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 +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +413 -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-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +313 -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/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +211 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +195 -0
- package/src/tool-audience.ts +109 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +4955 -300
- package/src/update-provider.test.ts +234 -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 +138 -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
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// GET /api/requirements/projects 的每行 viewer_can.edit(app 任务页审计 L13)—— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
|
|
2
|
+
//
|
|
3
|
+
// 客户端据此只在新建 / 挪卡的项目下拉里列「能编辑」的项目。判据必须与真写卡时一致,所以这里对每个项目
|
|
4
|
+
// 都真建一张卡,断言「viewer_can.edit === 建卡成功」,而不是另抄一份规则来比。
|
|
5
|
+
// admin —— Hub 管理员 + 网络 owner:未归档项目全 true,归档的 false(projectRef 拒归档)
|
|
6
|
+
// alice —— scoped 成员:P1 只看 → false,P2 可改 → true(只列得到这两个)
|
|
7
|
+
// carol —— task_access='all' 的成员:未归档全 true
|
|
8
|
+
// vic —— scoped viewer,P3 只看:false(viewer 本来就不能写卡)
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { tmpdir } from "os";
|
|
14
|
+
import { register } from "./auth.js";
|
|
15
|
+
import { db } from "./db.js";
|
|
16
|
+
|
|
17
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-project-viewer-can-"));
|
|
18
|
+
let BASE = "";
|
|
19
|
+
let hub: any = null;
|
|
20
|
+
const PW = "ProjViewerCanPassw0rd!x";
|
|
21
|
+
let NET = "";
|
|
22
|
+
const U: Record<string, { token: string; id: string }> = {};
|
|
23
|
+
const P: Record<string, string> = {};
|
|
24
|
+
|
|
25
|
+
type R = { status: number; body: any };
|
|
26
|
+
async function send(token: string, method: string, path: string, payload?: unknown): Promise<R> {
|
|
27
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
28
|
+
method,
|
|
29
|
+
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
|
|
30
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
31
|
+
});
|
|
32
|
+
const text = await res.text();
|
|
33
|
+
let body: any = null;
|
|
34
|
+
try { body = JSON.parse(text); } catch {}
|
|
35
|
+
return { status: res.status, body };
|
|
36
|
+
}
|
|
37
|
+
const projects = async (who: string) => {
|
|
38
|
+
const r = await send(U[who].token, "GET", `/api/requirements/projects?network_id=${NET}`);
|
|
39
|
+
expect(r.status).toBe(200);
|
|
40
|
+
return Object.fromEntries((r.body.projects as any[]).map((p) => [p.name, p]));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
45
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
46
|
+
process.env.HOST = "127.0.0.1";
|
|
47
|
+
const a = register(`pvc_admin_${Date.now()}`, PW, undefined, "Admin");
|
|
48
|
+
expect(a.ok).toBe(true);
|
|
49
|
+
U.admin = { token: a.token!, id: a.user!.user_id };
|
|
50
|
+
NET = a.network_id!;
|
|
51
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.admin.id]);
|
|
52
|
+
const mod: any = await import("./server.js");
|
|
53
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
54
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
55
|
+
|
|
56
|
+
const mk = async (username: string, role: string) => {
|
|
57
|
+
expect((await send(U.admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
|
|
58
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
59
|
+
expect(login.status).toBe(200);
|
|
60
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
61
|
+
};
|
|
62
|
+
const stamp = Date.now();
|
|
63
|
+
U.alice = await mk(`pvc_alice_${stamp}`, "member");
|
|
64
|
+
U.carol = await mk(`pvc_carol_${stamp}`, "member");
|
|
65
|
+
U.vic = await mk(`pvc_vic_${stamp}`, "viewer");
|
|
66
|
+
db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, U.carol.id]);
|
|
67
|
+
|
|
68
|
+
for (const name of ["pvc-P1", "pvc-P2", "pvc-P3", "pvc-P4"]) {
|
|
69
|
+
const r = await send(U.admin.token, "POST", "/api/requirements/projects", { network_id: NET, name });
|
|
70
|
+
expect(r.status).toBe(201);
|
|
71
|
+
P[name] = r.body.project.id;
|
|
72
|
+
}
|
|
73
|
+
expect((await send(U.admin.token, "PATCH", `/api/requirements/projects/${P["pvc-P4"]}?network_id=${NET}`, { archived: true })).status).toBe(200);
|
|
74
|
+
expect((await send(U.admin.token, "PUT", `/api/networks/${NET}/members/${U.alice.id}/task-grants`, { task_access: "scoped", project_grants: [{ project_id: P["pvc-P1"] }, { project_id: P["pvc-P2"], can_edit: true }] })).status).toBe(200);
|
|
75
|
+
expect((await send(U.admin.token, "PUT", `/api/networks/${NET}/members/${U.vic.id}/task-grants`, { task_access: "scoped", project_grants: [P["pvc-P3"]] })).status).toBe(200);
|
|
76
|
+
}, 30_000);
|
|
77
|
+
|
|
78
|
+
afterAll(() => {
|
|
79
|
+
try { hub?.stop?.(true); } catch {}
|
|
80
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("GET /api/requirements/projects 的 viewer_can.edit", () => {
|
|
84
|
+
test("每个调用者的取值", async () => {
|
|
85
|
+
const edit = (rows: Record<string, any>) => Object.fromEntries(Object.entries(rows).map(([n, p]) => [n, p.viewer_can?.edit]));
|
|
86
|
+
expect(edit(await projects("admin"))).toEqual({ "pvc-P1": true, "pvc-P2": true, "pvc-P3": true, "pvc-P4": false });
|
|
87
|
+
expect(edit(await projects("carol"))).toEqual({ "pvc-P1": true, "pvc-P2": true, "pvc-P3": true, "pvc-P4": false });
|
|
88
|
+
expect(edit(await projects("alice"))).toEqual({ "pvc-P1": false, "pvc-P2": true });
|
|
89
|
+
expect(edit(await projects("vic"))).toEqual({ "pvc-P3": false });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("只加字段:其余键与之前逐字相同", async () => {
|
|
93
|
+
const row = (await projects("alice"))["pvc-P2"];
|
|
94
|
+
expect(Object.keys(row).sort()).toEqual(["archived", "color", "createdAt", "id", "name", "sort", "viewer_can"]);
|
|
95
|
+
expect(Object.keys(row.viewer_can)).toEqual(["edit"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("与真写卡一致:edit === 往这个项目建卡成功", async () => {
|
|
99
|
+
let checked = 0;
|
|
100
|
+
for (const who of ["admin", "carol", "alice", "vic"]) {
|
|
101
|
+
for (const [name, p] of Object.entries(await projects(who))) {
|
|
102
|
+
const r = await send(U[who].token, "POST", "/api/requirements", { network_id: NET, name: `pvc-card-${who}-${name}`, project_id: p.id });
|
|
103
|
+
expect({ who, name, created: r.status === 201 }).toEqual({ who, name, created: p.viewer_can.edit });
|
|
104
|
+
checked++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
expect(checked).toBe(11);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("授权改成可编辑后立即反映", async () => {
|
|
111
|
+
expect((await send(U.admin.token, "PUT", `/api/networks/${NET}/members/${U.alice.id}/task-grants`, { project_grants: [{ project_id: P["pvc-P1"], can_edit: true }, { project_id: P["pvc-P2"], can_edit: true }] })).status).toBe(200);
|
|
112
|
+
expect((await projects("alice"))["pvc-P1"].viewer_can.edit).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
package/src/push.ts
CHANGED
|
@@ -84,6 +84,40 @@ type SSEClient = {
|
|
|
84
84
|
keepaliveTimer?: ReturnType<typeof setInterval>;
|
|
85
85
|
/** Stable key for log/diagnostic context. */
|
|
86
86
|
key: string;
|
|
87
|
+
/** 多用户 Agent 权限:订阅者的 user_id(有的话),权限变化时据此断开重连重新鉴权。 */
|
|
88
|
+
subscriberUserId?: string;
|
|
89
|
+
/** 只推满足条件的事件(受限成员的网络观察流用)。缺省 = 全推。 */
|
|
90
|
+
eventFilter?: (event: Record<string, unknown>) => boolean;
|
|
91
|
+
/** #507 — set only on a node's OWN task stream (/events/<alias> opened with the
|
|
92
|
+
* node token bound to that alias). Dashboard monitors / master-token streams on
|
|
93
|
+
* the same key leave it unset and are never superseded or counted here. */
|
|
94
|
+
node?: NodeSubscriberInfo;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** #507 — what the Hub knows about one node-scoped SSE connection. No tokens. */
|
|
98
|
+
export type NodeSubscriberInfo = {
|
|
99
|
+
nodeId: string | null;
|
|
100
|
+
/** Optional, client-supplied (`X-Anet-Instance-Id` header or `?instance_id=`).
|
|
101
|
+
* null = the client did not say (every agent-node released so far). */
|
|
102
|
+
instanceId: string | null;
|
|
103
|
+
remote: string | null;
|
|
104
|
+
userAgent: string | null;
|
|
105
|
+
connectedAt: number;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** #507 — one supersede of a node-scoped connection by a newer one. */
|
|
109
|
+
export type NodeIdentityConflict = {
|
|
110
|
+
key: string;
|
|
111
|
+
alias: string;
|
|
112
|
+
networkId: string | null;
|
|
113
|
+
nodeId: string | null;
|
|
114
|
+
/** "same" = both connections carried the same instance id (a reconnect of the
|
|
115
|
+
* same process; not reported as a conflict). "different" = both carried ids and
|
|
116
|
+
* they differ (two live copies). "unknown" = at least one carried none. */
|
|
117
|
+
instanceMatch: "same" | "different" | "unknown";
|
|
118
|
+
superseded: NodeSubscriberInfo;
|
|
119
|
+
current: NodeSubscriberInfo;
|
|
120
|
+
at: number;
|
|
87
121
|
};
|
|
88
122
|
|
|
89
123
|
// 一个 session 可能有多个 SSE 连接(重连时短暂并存)
|
|
@@ -97,6 +131,45 @@ function clientKey(sessionName: string, networkId?: string | null): string {
|
|
|
97
131
|
return `${networkId || "global"}:${sessionName}`;
|
|
98
132
|
}
|
|
99
133
|
|
|
134
|
+
// ── #461 network observer channels ───────────────────────────────────
|
|
135
|
+
// Dashboard (or any network member) subscribes GET /events/network/:id
|
|
136
|
+
// and receives SUMMARY events for ALL new_task / new_reply traffic in
|
|
137
|
+
// that network — not just tasks it sent/received. Summary events carry
|
|
138
|
+
// ids + routing metadata only, never task/reply content.
|
|
139
|
+
//
|
|
140
|
+
// Key scheme: leading NUL byte makes an observer key impossible to
|
|
141
|
+
// forge from the session scheme above (`${networkId}:${sessionName}`)
|
|
142
|
+
// for any networkId the hub itself issues (net_* / "global"). Only a
|
|
143
|
+
// master-token caller could even inject a NUL into network_id via the
|
|
144
|
+
// legacy ?network_id= path, and master token is root already.
|
|
145
|
+
const OBSERVER_KEY_PREFIX = "\u0000netobs:";
|
|
146
|
+
const USER_KEY_PREFIX = "\u0000user:";
|
|
147
|
+
|
|
148
|
+
/** Printable form of `OBSERVER_KEY_PREFIX` — the shape emitted by
|
|
149
|
+
* `printableKey()` (below) and therefore the shape callers see via
|
|
150
|
+
* `getSSEStats().sessions`. Exported so downstream consumers (e.g.
|
|
151
|
+
* `/health` filter in server.ts) key off the same literal instead of
|
|
152
|
+
* each side re-writing the escape sequence — CR3 audit 4703b0e7
|
|
153
|
+
* found server.ts had `"netobs:"` (no `\\0`) which never matched and
|
|
154
|
+
* silently over-filtered observer keys. */
|
|
155
|
+
export const PRINTABLE_OBSERVER_KEY_PREFIX = "\\0netobs:";
|
|
156
|
+
|
|
157
|
+
function observerKey(networkId: string): string {
|
|
158
|
+
return `${OBSERVER_KEY_PREFIX}${networkId}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function userKey(networkId: string, userId: string): string {
|
|
162
|
+
return `${USER_KEY_PREFIX}${networkId}:${userId}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Render a client key for logs / stats. Observer keys embed a raw NUL
|
|
166
|
+
* byte (collision guard above); printing that verbatim makes grep treat
|
|
167
|
+
* hub logs as binary and breaks log shippers, and getSSEStats feeds
|
|
168
|
+
* /health. Display as a visible "\\0" escape instead. */
|
|
169
|
+
function printableKey(key: string): string {
|
|
170
|
+
return key.split("\u0000").join("\\0");
|
|
171
|
+
}
|
|
172
|
+
|
|
100
173
|
// ─────────────────────────────────────────────────────────────────────
|
|
101
174
|
// Close + dispatch helpers
|
|
102
175
|
// ─────────────────────────────────────────────────────────────────────
|
|
@@ -114,7 +187,14 @@ function closeClient(client: SSEClient, reason: string): void {
|
|
|
114
187
|
// Already closed by the runtime, or controller errored. Either way
|
|
115
188
|
// we're done with it.
|
|
116
189
|
}
|
|
117
|
-
console.log(`[${ts()}] SSE ✕ ${client.key} closed (reason=${reason})`);
|
|
190
|
+
console.log(`[${ts()}] SSE ✕ ${printableKey(client.key)} closed (reason=${reason})`);
|
|
191
|
+
if (client.node && reason !== SUPERSEDED_REASON && !hasLiveNodeClient(client.key)) {
|
|
192
|
+
notifyNodeDrained(client.key);
|
|
193
|
+
}
|
|
194
|
+
if (isUserStreamClient(client)) {
|
|
195
|
+
touchUserSeen(client.subscriberUserId!);
|
|
196
|
+
if (!isUserOnline(client.subscriberUserId!)) notifyPresence(client.subscriberUserId!, false);
|
|
197
|
+
}
|
|
118
198
|
}
|
|
119
199
|
|
|
120
200
|
/** Best-effort enqueue with backpressure guard. Returns:
|
|
@@ -213,10 +293,144 @@ function ensureLivenessSweep(): void {
|
|
|
213
293
|
// Public API
|
|
214
294
|
// ─────────────────────────────────────────────────────────────────────
|
|
215
295
|
|
|
216
|
-
/** 创建 SSE Response 并注册到 clients map
|
|
217
|
-
|
|
296
|
+
/** 创建 SSE Response 并注册到 clients map。
|
|
297
|
+
* #507 — pass `opts.node` only for a node's own stream (node token bound to this
|
|
298
|
+
* alias); such a stream supersedes any older node-scoped stream on the same key. */
|
|
299
|
+
export function createSSEStream(
|
|
300
|
+
sessionName: string,
|
|
301
|
+
networkId?: string | null,
|
|
302
|
+
opts: { node?: Omit<NodeSubscriberInfo, "connectedAt"> } = {},
|
|
303
|
+
): Response {
|
|
304
|
+
return createStreamForKey(
|
|
305
|
+
clientKey(sessionName, networkId),
|
|
306
|
+
{ type: "connected", session: sessionName, network_id: networkId ?? null },
|
|
307
|
+
opts.node ? { node: { ...opts.node, connectedAt: Date.now() } } : {},
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── #507 node identity conflict: at most ONE node-scoped subscriber per key ──
|
|
312
|
+
//
|
|
313
|
+
// Before #507 every connection on `${networkId}:${alias}` received every push. A
|
|
314
|
+
// node directory copied verbatim (same node token, same alias) and started
|
|
315
|
+
// elsewhere therefore got every new_task twice — both copies ran it — and either
|
|
316
|
+
// copy's shutdown report_status(offline) marked the node offline while the other
|
|
317
|
+
// was still connected.
|
|
318
|
+
//
|
|
319
|
+
// Policy: NEWEST CONNECTION WINS. Measured reconnect path (agent-node connectSSE →
|
|
320
|
+
// superviseChild): a restarted process's old socket is closed by the OS, so a
|
|
321
|
+
// legit restart never overlaps its own previous stream; an overlap only happens
|
|
322
|
+
// with (a) a half-open ghost the Hub has not reaped yet (up to STUCK_CLOSE_MS) or
|
|
323
|
+
// (b) a second live copy. In (a) the new connection is the real one — keeping the
|
|
324
|
+
// old would park tasks on a dead socket — so the newest must win. In (b) the
|
|
325
|
+
// loser reconnects ~1 s later and wins back: the copies flap, which is loud
|
|
326
|
+
// (log + node_identity_conflict event + /api/stats/sse) instead of silently
|
|
327
|
+
// double-executing. An "only if both alive > N s" rule cannot tell (a) from (b):
|
|
328
|
+
// a ghost looks alive to the Hub until it is reaped.
|
|
329
|
+
export const SUPERSEDED_REASON = "superseded_by_new_connection";
|
|
330
|
+
const REPLACED_REASON = "replaced_by_reconnect";
|
|
331
|
+
|
|
332
|
+
let nodeConflictListener: ((c: NodeIdentityConflict) => void) | null = null;
|
|
333
|
+
let nodeDrainedListener: ((alias: string, networkId: string | null) => void) | null = null;
|
|
334
|
+
|
|
335
|
+
/** Called for every supersede whose instances are not provably the same process. */
|
|
336
|
+
export function onNodeIdentityConflict(cb: ((c: NodeIdentityConflict) => void) | null): void {
|
|
337
|
+
nodeConflictListener = cb;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Called when the LAST node-scoped subscriber of a key goes away (not when it is
|
|
341
|
+
* superseded — the newer connection is still there). */
|
|
342
|
+
export function onNodeSubscribersDrained(cb: ((alias: string, networkId: string | null) => void) | null): void {
|
|
343
|
+
nodeDrainedListener = cb;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function splitClientKey(key: string): { alias: string; networkId: string | null } {
|
|
347
|
+
const i = key.indexOf(":");
|
|
348
|
+
const net = key.slice(0, i);
|
|
349
|
+
return { alias: key.slice(i + 1), networkId: net === "global" ? null : net };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function hasLiveNodeClient(key: string): boolean {
|
|
353
|
+
return (clients.get(key) ?? []).some((c) => !c.closed && !!c.node);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function notifyNodeDrained(key: string): void {
|
|
357
|
+
if (!nodeDrainedListener) return;
|
|
358
|
+
const { alias, networkId } = splitClientKey(key);
|
|
359
|
+
try { nodeDrainedListener(alias, networkId); } catch (e: any) {
|
|
360
|
+
console.log(`[${ts()}] node drained listener failed: ${e?.message || e}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Is a node-scoped (node-token) subscriber connected on this alias right now? */
|
|
365
|
+
export function hasNodeSubscriber(sessionName: string, networkId?: string | null): boolean {
|
|
366
|
+
return hasLiveNodeClient(clientKey(sessionName, networkId));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Keep only `keep` among the node-scoped clients of `key`; close the others with
|
|
370
|
+
* a final `node_connection_superseded` frame. Dashboard monitors are untouched. */
|
|
371
|
+
function enforceSingleNodeSubscriber(key: string, keep: SSEClient): void {
|
|
372
|
+
const arr = clients.get(key);
|
|
373
|
+
if (!arr || !keep.node) return;
|
|
374
|
+
for (const old of arr) {
|
|
375
|
+
if (old === keep || old.closed || !old.node) continue;
|
|
376
|
+
const a = old.node.instanceId, b = keep.node.instanceId;
|
|
377
|
+
const instanceMatch: NodeIdentityConflict["instanceMatch"] = a && b ? (a === b ? "same" : "different") : "unknown";
|
|
378
|
+
const reason = instanceMatch === "same" ? REPLACED_REASON : SUPERSEDED_REASON;
|
|
379
|
+
tryEnqueueBytes(old, old.encoder.encode(`data: ${JSON.stringify({
|
|
380
|
+
type: "node_connection_superseded",
|
|
381
|
+
reason,
|
|
382
|
+
instance_match: instanceMatch,
|
|
383
|
+
// The newer connection's network origin, so the loser's log says WHERE the
|
|
384
|
+
// other copy runs. Never a token.
|
|
385
|
+
by: { instance_id: keep.node.instanceId, remote: keep.node.remote },
|
|
386
|
+
})}\n\n`));
|
|
387
|
+
closeClient(old, reason);
|
|
388
|
+
if (instanceMatch !== "same" && nodeConflictListener) {
|
|
389
|
+
const { alias, networkId } = splitClientKey(key);
|
|
390
|
+
try {
|
|
391
|
+
nodeConflictListener({
|
|
392
|
+
key: printableKey(key), alias, networkId,
|
|
393
|
+
nodeId: keep.node.nodeId ?? old.node.nodeId,
|
|
394
|
+
instanceMatch, superseded: old.node, current: keep.node, at: Date.now(),
|
|
395
|
+
});
|
|
396
|
+
} catch (e: any) {
|
|
397
|
+
console.log(`[${ts()}] node conflict listener failed: ${e?.message || e}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
pruneClosed(key);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** #461 — 创建网络级观察者 SSE Response(dashboard 观察第三方流量)。
|
|
405
|
+
* Shares the exact same registration / backpressure / liveness
|
|
406
|
+
* machinery as session streams; only the key scheme differs. */
|
|
407
|
+
export function createNetworkObserverStream(
|
|
408
|
+
networkId: string,
|
|
409
|
+
opts: { subscriberUserId?: string; eventFilter?: (event: Record<string, unknown>) => boolean } = {},
|
|
410
|
+
): Response {
|
|
411
|
+
return createStreamForKey(
|
|
412
|
+
observerKey(networkId),
|
|
413
|
+
{ type: "connected", observer: true, network_id: networkId },
|
|
414
|
+
opts,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Desktop/user client SSE stream. Keyed by authenticated user_id, not
|
|
419
|
+
* alias/session, so Desktop does not need to mint a pseudo node token. */
|
|
420
|
+
export function createUserEventStream(networkId: string, userId: string): Response {
|
|
421
|
+
return createStreamForKey(
|
|
422
|
+
userKey(networkId, userId),
|
|
423
|
+
{ type: "connected", user: true, network_id: networkId, user_id: userId },
|
|
424
|
+
{ subscriberUserId: userId },
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function createStreamForKey(
|
|
429
|
+
key: string,
|
|
430
|
+
initialEvent: Record<string, unknown>,
|
|
431
|
+
opts: { subscriberUserId?: string; eventFilter?: (event: Record<string, unknown>) => boolean; node?: NodeSubscriberInfo } = {},
|
|
432
|
+
): Response {
|
|
218
433
|
const encoder = new TextEncoder();
|
|
219
|
-
const key = clientKey(sessionName, networkId);
|
|
220
434
|
let client: SSEClient;
|
|
221
435
|
|
|
222
436
|
const stream = new ReadableStream<Uint8Array>({
|
|
@@ -227,17 +441,24 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
|
|
|
227
441
|
stuckSince: null,
|
|
228
442
|
closed: false,
|
|
229
443
|
key,
|
|
444
|
+
...(opts.subscriberUserId ? { subscriberUserId: opts.subscriberUserId } : {}),
|
|
445
|
+
...(opts.eventFilter ? { eventFilter: opts.eventFilter } : {}),
|
|
446
|
+
...(opts.node ? { node: opts.node } : {}),
|
|
230
447
|
};
|
|
231
448
|
|
|
449
|
+
const presenceUser = isUserStreamClient(client) ? client.subscriberUserId! : null;
|
|
450
|
+
const wasOnline = presenceUser ? isUserOnline(presenceUser) : true;
|
|
232
451
|
if (!clients.has(key)) clients.set(key, []);
|
|
233
452
|
clients.get(key)!.push(client);
|
|
234
|
-
|
|
453
|
+
if (presenceUser) touchUserSeen(presenceUser);
|
|
454
|
+
console.log(`[${ts()}] SSE ← ${printableKey(key)} connected (${clients.get(key)!.length} clients)`);
|
|
455
|
+
if (client.node) enforceSingleNodeSubscriber(key, client);
|
|
235
456
|
|
|
236
457
|
// Send initial connected frame through the backpressure guard so
|
|
237
458
|
// even the first byte respects the contract.
|
|
238
459
|
tryEnqueueBytes(
|
|
239
460
|
client,
|
|
240
|
-
encoder.encode(`data: ${JSON.stringify(
|
|
461
|
+
encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`),
|
|
241
462
|
);
|
|
242
463
|
|
|
243
464
|
// Periodic keepalive doubles as a half-open probe — if the
|
|
@@ -249,6 +470,7 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
|
|
|
249
470
|
return;
|
|
250
471
|
}
|
|
251
472
|
const result = tryEnqueueBytes(client, encoder.encode(`: keepalive\n\n`));
|
|
473
|
+
if (result === "ok" && isUserStreamClient(client)) touchUserSeen(client.subscriberUserId!);
|
|
252
474
|
if (result === "dead") {
|
|
253
475
|
// closeClient already cleared the timer; just make sure we
|
|
254
476
|
// also prune from the map.
|
|
@@ -258,6 +480,8 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
|
|
|
258
480
|
(client.keepaliveTimer as any)?.unref?.();
|
|
259
481
|
|
|
260
482
|
ensureLivenessSweep();
|
|
483
|
+
// 在线状态的广播放在 connected 帧之后:订阅方先拿到自己的 connected,再看到别人的变化。
|
|
484
|
+
if (presenceUser && !wasOnline) notifyPresence(presenceUser, true);
|
|
261
485
|
},
|
|
262
486
|
cancel() {
|
|
263
487
|
// Consumer side hangup — straightforward path, mark closed and
|
|
@@ -280,7 +504,12 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
|
|
|
280
504
|
|
|
281
505
|
return new Response(stream, {
|
|
282
506
|
headers: {
|
|
283
|
-
|
|
507
|
+
// #426: explicit charset so legacy Windows clients (PowerShell 5.1's
|
|
508
|
+
// Invoke-RestMethod / Invoke-WebRequest default to ISO-8859-1 when
|
|
509
|
+
// Content-Type omits the charset) decode UTF-8 payloads correctly.
|
|
510
|
+
// Hub itself was already writing clean UTF-8 bytes; the client-side
|
|
511
|
+
// 双重编码样式 mojibake was the header telling the client to guess.
|
|
512
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
284
513
|
"Cache-Control": "no-cache, no-transform",
|
|
285
514
|
"Connection": "keep-alive",
|
|
286
515
|
},
|
|
@@ -294,7 +523,7 @@ function pruneClosed(key: string): void {
|
|
|
294
523
|
const alive = arr.filter((c) => !c.closed);
|
|
295
524
|
if (alive.length === 0) {
|
|
296
525
|
clients.delete(key);
|
|
297
|
-
console.log(`[${ts()}] SSE ✕ ${key} disconnected (0 remaining)`);
|
|
526
|
+
console.log(`[${ts()}] SSE ✕ ${printableKey(key)} disconnected (0 remaining)`);
|
|
298
527
|
} else if (alive.length !== arr.length) {
|
|
299
528
|
clients.set(key, alive);
|
|
300
529
|
}
|
|
@@ -312,9 +541,16 @@ function rekeyClient(oldSessionName: string, newSessionName: string, networkId?:
|
|
|
312
541
|
for (const c of oldClients) c.key = newKey;
|
|
313
542
|
|
|
314
543
|
const existing = clients.get(newKey) || [];
|
|
315
|
-
|
|
544
|
+
const merged = existing.concat(oldClients);
|
|
545
|
+
clients.set(newKey, merged);
|
|
316
546
|
clients.delete(oldKey);
|
|
317
547
|
console.log(`[${ts()}] SSE ↔ rekey ${oldKey} → ${newKey} (${oldClients.length} clients)`);
|
|
548
|
+
// #507 — a rename can merge two node-scoped streams onto one key; newest wins.
|
|
549
|
+
const nodeClients = merged.filter((c) => !c.closed && c.node);
|
|
550
|
+
if (nodeClients.length > 1) {
|
|
551
|
+
const newest = nodeClients.reduce((a, b) => (b.node!.connectedAt >= a.node!.connectedAt ? b : a));
|
|
552
|
+
enforceSingleNodeSubscriber(newKey, newest);
|
|
553
|
+
}
|
|
318
554
|
return oldClients.length;
|
|
319
555
|
}
|
|
320
556
|
|
|
@@ -327,6 +563,17 @@ eventBus.on("rename-committed", (event: RenameCommittedEvent) => {
|
|
|
327
563
|
});
|
|
328
564
|
|
|
329
565
|
/** 推送事件给指定 session 的所有 SSE 连接 */
|
|
566
|
+
/** 这个 alias 上此刻是否真的有活着的 SSE 订阅者。
|
|
567
|
+
*
|
|
568
|
+
* 这是投递可达性的**实测**,区别于 resolveDeliveryTarget 里那个基于
|
|
569
|
+
* last_seen_at 的**猜测**。一个安静但连着的会话(例如遵守「停发心跳」
|
|
570
|
+
* 规则的 MCP 会话)在时间戳判据下是 offline,在这里是 true。 */
|
|
571
|
+
export function hasSubscribers(sessionName: string, networkId?: string | null): boolean {
|
|
572
|
+
const arr = clients.get(clientKey(sessionName, networkId));
|
|
573
|
+
if (!arr) return false;
|
|
574
|
+
return arr.some((c) => !c.closed);
|
|
575
|
+
}
|
|
576
|
+
|
|
330
577
|
export function pushEvent(sessionName: string, event: Record<string, unknown>, networkId?: string | null): void {
|
|
331
578
|
const key = clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined));
|
|
332
579
|
const arr = clients.get(key);
|
|
@@ -347,11 +594,183 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>, n
|
|
|
347
594
|
if (needPrune) pruneClosed(key);
|
|
348
595
|
}
|
|
349
596
|
|
|
597
|
+
/** #461 — 推送网络级摘要事件给该网络的所有观察者连接。
|
|
598
|
+
*
|
|
599
|
+
* Callers pass ROUTING METADATA ONLY (task_id / from / to / status /
|
|
600
|
+
* priority / message_id) — never task or reply content. The event is
|
|
601
|
+
* stamped with `network_id` + `scope: "network"` here so observers can
|
|
602
|
+
* always tell which network the event belongs to and that it came from
|
|
603
|
+
* the observer stream rather than their personal channel.
|
|
604
|
+
*
|
|
605
|
+
* No-op when networkId is falsy: legacy null-network traffic has no
|
|
606
|
+
* observer stream (the route requires an explicit network id). */
|
|
607
|
+
export function pushNetworkObserverEvent(
|
|
608
|
+
networkId: string | null | undefined,
|
|
609
|
+
event: Record<string, unknown>,
|
|
610
|
+
): void {
|
|
611
|
+
if (!networkId) return;
|
|
612
|
+
const key = observerKey(networkId);
|
|
613
|
+
const arr = clients.get(key);
|
|
614
|
+
if (!arr || arr.length === 0) return;
|
|
615
|
+
|
|
616
|
+
const data = `data: ${JSON.stringify({ ...event, network_id: networkId, scope: "network" })}\n\n`;
|
|
617
|
+
let needPrune = false;
|
|
618
|
+
|
|
619
|
+
for (const c of arr) {
|
|
620
|
+
if (c.closed) {
|
|
621
|
+
needPrune = true;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (c.eventFilter) {
|
|
625
|
+
let pass = false;
|
|
626
|
+
try { pass = c.eventFilter(event); } catch { pass = false; }
|
|
627
|
+
if (!pass) continue;
|
|
628
|
+
}
|
|
629
|
+
const result = tryEnqueueBytes(c, c.encoder.encode(data));
|
|
630
|
+
if (result === "dead") needPrune = true;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (needPrune) pruneClosed(key);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Push an event to every active Desktop/web client for one user in one
|
|
637
|
+
* network. No-op for falsy network/user ids; auth must be enforced by
|
|
638
|
+
* the caller before invoking this helper. */
|
|
639
|
+
/** 这个用户此刻是否真的有活着的 SSE 订阅者。
|
|
640
|
+
*
|
|
641
|
+
* 与 alias 版 `hasSubscribers` 同形状:投递可达性的**实测**,
|
|
642
|
+
* 调用方据此把「送到了」和「没人听」区分开,而不是一律报成功。 */
|
|
643
|
+
export function hasUserSubscribers(
|
|
644
|
+
networkId: string | null | undefined,
|
|
645
|
+
userId: string | null | undefined,
|
|
646
|
+
): boolean {
|
|
647
|
+
if (!networkId || !userId) return false;
|
|
648
|
+
const arr = clients.get(userKey(networkId, userId));
|
|
649
|
+
if (!arr) return false;
|
|
650
|
+
return arr.some((c) => !c.closed);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function pushUserEvent(
|
|
654
|
+
networkId: string | null | undefined,
|
|
655
|
+
userId: string | null | undefined,
|
|
656
|
+
event: Record<string, unknown>,
|
|
657
|
+
): void {
|
|
658
|
+
if (!networkId || !userId) return;
|
|
659
|
+
const key = userKey(networkId, userId);
|
|
660
|
+
const arr = clients.get(key);
|
|
661
|
+
if (!arr || arr.length === 0) return;
|
|
662
|
+
|
|
663
|
+
const data = `data: ${JSON.stringify({ ...event, network_id: networkId, user_id: userId, scope: "user" })}\n\n`;
|
|
664
|
+
let needPrune = false;
|
|
665
|
+
|
|
666
|
+
for (const c of arr) {
|
|
667
|
+
if (c.closed) {
|
|
668
|
+
needPrune = true;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
const result = tryEnqueueBytes(c, c.encoder.encode(data));
|
|
672
|
+
if (result === "dead") needPrune = true;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (needPrune) pruneClosed(key);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ── 人类成员在线状态(人员列表的在线点)──────────────────────────────────
|
|
679
|
+
// 判据就是上面这张用户流(/events/users/me)注册表:此刻有活着的用户流 = 在线。按**用户**算,
|
|
680
|
+
// 不按网络 —— 桌面端同一时刻只连当前网络的用户流,按网络算会把正在别的网络里的人画成离线。
|
|
681
|
+
// last_seen 只在内存:连上、每次 keepalive 成功、断开时刷新。Hub 重启后清空(未知 = null,
|
|
682
|
+
// 调用方不要把 null 画成「很久以前」)。
|
|
683
|
+
|
|
684
|
+
const userLastSeen = new Map<string, number>();
|
|
685
|
+
let presenceListener: ((userId: string, online: boolean, lastSeenAt: string | null) => void) | null = null;
|
|
686
|
+
|
|
687
|
+
function isUserStreamClient(client: SSEClient): boolean {
|
|
688
|
+
return !!client.subscriberUserId && client.key.startsWith(USER_KEY_PREFIX);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function touchUserSeen(userId: string): void {
|
|
692
|
+
userLastSeen.set(userId, Date.now());
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function isUserOnline(userId: string): boolean {
|
|
696
|
+
for (const [key, arr] of clients) {
|
|
697
|
+
if (!key.startsWith(USER_KEY_PREFIX)) continue;
|
|
698
|
+
if (arr.some((c) => !c.closed && c.subscriberUserId === userId)) return true;
|
|
699
|
+
}
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function lastSeenIso(userId: string): string | null {
|
|
704
|
+
const ms = userLastSeen.get(userId);
|
|
705
|
+
return ms === undefined ? null : new Date(ms).toISOString();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function notifyPresence(userId: string, online: boolean): void {
|
|
709
|
+
if (!presenceListener) return;
|
|
710
|
+
try {
|
|
711
|
+
presenceListener(userId, online, lastSeenIso(userId));
|
|
712
|
+
} catch (e: any) {
|
|
713
|
+
console.log(`[${ts()}] presence listener failed: ${e?.message || e}`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** 用户的第一条用户流连上 / 最后一条断开时回调(server.ts 用它向同网络成员推 member_presence)。 */
|
|
718
|
+
export function onUserPresenceChange(cb: ((userId: string, online: boolean, lastSeenAt: string | null) => void) | null): void {
|
|
719
|
+
presenceListener = cb;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** 一批用户此刻的在线状态。鉴权由调用方负责。 */
|
|
723
|
+
export function getUserPresence(userIds: readonly string[]): Map<string, { online: boolean; last_seen_at: string | null }> {
|
|
724
|
+
const online = new Set<string>();
|
|
725
|
+
for (const [key, arr] of clients) {
|
|
726
|
+
if (!key.startsWith(USER_KEY_PREFIX)) continue;
|
|
727
|
+
for (const c of arr) if (!c.closed && c.subscriberUserId) online.add(c.subscriberUserId);
|
|
728
|
+
}
|
|
729
|
+
const out = new Map<string, { online: boolean; last_seen_at: string | null }>();
|
|
730
|
+
for (const uid of userIds) out.set(uid, { online: online.has(uid), last_seen_at: lastSeenIso(uid) });
|
|
731
|
+
return out;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** 推给某网络里所有连着用户流的人(可排除一个)。鉴权由调用方负责。 */
|
|
735
|
+
export function pushUserEventToNetwork(networkId: string, event: Record<string, unknown>, exceptUserId?: string): number {
|
|
736
|
+
const prefix = `${USER_KEY_PREFIX}${networkId}:`;
|
|
737
|
+
const recipients: string[] = [];
|
|
738
|
+
for (const key of clients.keys()) {
|
|
739
|
+
if (!key.startsWith(prefix)) continue;
|
|
740
|
+
const uid = key.slice(prefix.length);
|
|
741
|
+
if (uid && uid !== exceptUserId) recipients.push(uid);
|
|
742
|
+
}
|
|
743
|
+
for (const uid of recipients) pushUserEvent(networkId, uid, event);
|
|
744
|
+
return recipients.length;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* 多用户 Agent 权限:某用户在某网络的授权 / 成员资格变了,断开他在这个网络里**订阅时带了身份**
|
|
749
|
+
* 的流(网络观察流、用户流),客户端重连时按新权限重新鉴权。返回断开的条数。
|
|
750
|
+
* Agent 频道(/events/<alias>)受限成员本来就订阅不了,不需要这里处理。
|
|
751
|
+
*/
|
|
752
|
+
export function closeUserStreamsInNetwork(networkId: string, userId: string): number {
|
|
753
|
+
let closed = 0;
|
|
754
|
+
for (const key of [observerKey(networkId), userKey(networkId, userId)]) {
|
|
755
|
+
const arr = clients.get(key);
|
|
756
|
+
if (!arr) continue;
|
|
757
|
+
for (const c of arr) {
|
|
758
|
+
if (!c.closed && c.subscriberUserId === userId) {
|
|
759
|
+
closeClient(c, "access-changed");
|
|
760
|
+
closed++;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
pruneClosed(key);
|
|
764
|
+
}
|
|
765
|
+
return closed;
|
|
766
|
+
}
|
|
767
|
+
|
|
350
768
|
export function __resetSSEClientsForTest(): void {
|
|
351
769
|
for (const arr of clients.values()) {
|
|
352
770
|
for (const c of arr) closeClient(c, "reset-for-test");
|
|
353
771
|
}
|
|
354
772
|
clients.clear();
|
|
773
|
+
userLastSeen.clear();
|
|
355
774
|
if (livenessSweepTimer) {
|
|
356
775
|
clearInterval(livenessSweepTimer);
|
|
357
776
|
livenessSweepTimer = null;
|
|
@@ -365,7 +784,7 @@ export function getSSEStats(): { total: number; sessions: Record<string, number>
|
|
|
365
784
|
for (const [name, arr] of clients) {
|
|
366
785
|
const alive = arr.filter((c) => !c.closed);
|
|
367
786
|
if (alive.length === 0) continue;
|
|
368
|
-
sessions[name] = alive.length;
|
|
787
|
+
sessions[printableKey(name)] = alive.length;
|
|
369
788
|
total += alive.length;
|
|
370
789
|
}
|
|
371
790
|
return { total, sessions };
|