@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.100
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-kdf-migration.test.ts +378 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +554 -38
- 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 +830 -0
- package/src/config-apply-validate.test.ts +352 -0
- package/src/config-apply-validate.ts +249 -0
- 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/cross-tenant-injection.test.ts +110 -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 +1268 -42
- 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 -2128
- 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 +390 -0
- package/src/requirement-due-reminders.ts +310 -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.test.ts +450 -0
- package/src/retention.ts +281 -0
- 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-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 +641 -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/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -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 +5426 -275
- 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,258 @@
|
|
|
1
|
+
// 每个路由的耗时统计(2026-09-30):生产 hub 平均 ~24% CPU,但没有任何按路由的计时,也没有访问日志,
|
|
2
|
+
// 说不清是谁在吃 CPU。这里在 fetch 入口按路由形状累计次数、耗时、状态、字节,
|
|
3
|
+
// GET /api/stats/routes(管理员 / master)按时间窗汇总。只在内存里,重启清空;不记 query 值、不记请求体;调用者只记粗粒度类别(见下文 callerClass)。
|
|
4
|
+
//
|
|
5
|
+
// 2026-10-01 —— 原来是 4096 条样本的环:生产上 212 s 就写满,?minutes=10 实际只覆盖三分半钟。
|
|
6
|
+
// 改成按分钟分桶的累计器:每个桶里每个路由一行(次数 / 总耗时 / 最大值 / 字节 / 5xx / 耗时直方图),
|
|
7
|
+
// 保留 ROUTE_BUCKET_RETENTION 个桶(24 小时),窗口多长就真覆盖多长。p95 从直方图取(桶上沿,不超过 max)。
|
|
8
|
+
export const ROUTE_BUCKET_MS = 60_000;
|
|
9
|
+
export const ROUTE_BUCKET_RETENTION = 1440;
|
|
10
|
+
/** 每个分钟桶里最多这么多个不同的路由;再多的记到 ROUTE_OVERFLOW_KEY,防止怪路径 / 怪工具名把内存撑大。 */
|
|
11
|
+
export const ROUTE_MAX_KEYS_PER_BUCKET = 256;
|
|
12
|
+
export const ROUTE_OVERFLOW_KEY = "(other)";
|
|
13
|
+
|
|
14
|
+
// 耗时直方图:第 i 格的上沿 = HIST_BASE_MS · 2^(i/2),0.05 ms … ~52 s;最后一格收下更慢的。
|
|
15
|
+
const HIST_BASE_MS = 0.05;
|
|
16
|
+
const HIST_BINS = 41;
|
|
17
|
+
function histBin(ms: number): number {
|
|
18
|
+
if (!(ms > HIST_BASE_MS)) return 0;
|
|
19
|
+
return Math.min(HIST_BINS - 1, Math.ceil(2 * Math.log2(ms / HIST_BASE_MS)));
|
|
20
|
+
}
|
|
21
|
+
const histUpper = (bin: number) => HIST_BASE_MS * 2 ** (bin / 2);
|
|
22
|
+
|
|
23
|
+
type Agg = { count: number; totalMs: number; maxMs: number; bytes: number; errors: number; hist: Uint32Array; callers?: Map<string, CallerAgg> };
|
|
24
|
+
type CallerAgg = { count: number; bytes: number };
|
|
25
|
+
type Bucket = { minute: number; routes: Map<string, Agg> };
|
|
26
|
+
// 按 minute 升序;新的一分钟追加在尾部,超出保留期从头部丢。
|
|
27
|
+
const buckets: Bucket[] = [];
|
|
28
|
+
|
|
29
|
+
/** 路由的形状:id / 别名 / 文件名这类段换成 :id,几个会改变载荷形状的参数保留(light / view / scope / changes)。 */
|
|
30
|
+
const SHAPE_PARAMS = ["changes", "light", "scope", "view"];
|
|
31
|
+
export function routeKey(method: string, url: URL): string {
|
|
32
|
+
const segments = url.pathname.split("/").map((seg, i) => {
|
|
33
|
+
if (i < 2 || !seg) return seg;
|
|
34
|
+
return /[0-9%]/.test(seg) || /[^\x20-\x7e]/.test(seg) || seg.length > 24 ? ":id" : seg;
|
|
35
|
+
});
|
|
36
|
+
const shape = SHAPE_PARAMS.filter(k => url.searchParams.has(k)).map(k => `${k}=${url.searchParams.get(k)}`).join("&");
|
|
37
|
+
return `${method} ${segments.join("/")}${shape ? `?${shape}` : ""}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 处理器给某个请求补一段标签(比如 POST /mcp 的 JSON-RPC 方法名),recordRouteTiming 拼到路由形状后面。
|
|
41
|
+
const labels = new WeakMap<Request, string>();
|
|
42
|
+
export function labelRoute(req: Request, label: string): void {
|
|
43
|
+
labels.set(req, label);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const MCP_METHOD_RE = /^[A-Za-z][A-Za-z0-9_./-]{0,63}$/;
|
|
47
|
+
const MCP_TOOL_RE = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
48
|
+
/**
|
|
49
|
+
* POST /mcp 的标签:JSON-RPC 方法名,tools/call 再带工具名,如 "tools/call report_status"。
|
|
50
|
+
* 只读 method 和 params.name 两个字段,别的(尤其 arguments)一概不碰;形状不对的一律折成 "?",
|
|
51
|
+
* 批量请求记 "batch"。名字只放行短的 ASCII 标识符,防止拿请求体造出任意多、任意长的路由键。
|
|
52
|
+
*/
|
|
53
|
+
export function mcpRouteLabel(body: unknown): string {
|
|
54
|
+
if (Array.isArray(body)) return "batch";
|
|
55
|
+
if (!body || typeof body !== "object") return "?";
|
|
56
|
+
const method = (body as { method?: unknown }).method;
|
|
57
|
+
if (typeof method !== "string" || !MCP_METHOD_RE.test(method)) return "?";
|
|
58
|
+
if (method !== "tools/call") return method;
|
|
59
|
+
const params = (body as { params?: unknown }).params;
|
|
60
|
+
const name = params && typeof params === "object" ? (params as { name?: unknown }).name : undefined;
|
|
61
|
+
return `tools/call ${typeof name === "string" && MCP_TOOL_RE.test(name) ? name : "?"}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── 调用方分类(2026-10-01)────────────────────────────────────────────────────────────
|
|
65
|
+
// 生产上全量 GET /api/status 每秒 ~2.8 次,但大多数连接经 frp 隧道进来,来源全是 127.0.0.1,也没有访问日志,
|
|
66
|
+
// 看不出是谁在拉。每个请求再记一个粗粒度的「调用方类别」= 令牌种类 + User-Agent 家族,
|
|
67
|
+
// GET /api/stats/routes?by=caller 按路由给出各类别的次数 / 字节。
|
|
68
|
+
// 🔴 只记类别:不记令牌、用户 / 节点 id、IP、完整 UA。UA 只认白名单里的产品名(其余一律 "other"),
|
|
69
|
+
// 版本号只放行数字 + 一段预发布标记,浏览器一律折成 "browser"。
|
|
70
|
+
/** 每个分钟桶里每个路由最多这么多个调用方类别;再多的记到 ROUTE_OVERFLOW_KEY。汇总输出也只给前这么多个。 */
|
|
71
|
+
export const ROUTE_MAX_CALLERS_PER_ROUTE = 20;
|
|
72
|
+
|
|
73
|
+
let masterToken = "";
|
|
74
|
+
/** 启动时把 COMMHUB_AUTH_TOKEN 交进来,用于把它认成 "master";只做相等比较,不输出。 */
|
|
75
|
+
export function setRouteCallerMasterToken(token: string | undefined): void {
|
|
76
|
+
masterToken = token || "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 取令牌走 server.ts 的 requestToken()(启动时交进来),与鉴权同一个优先级:先 Authorization,再 ?token=。
|
|
80
|
+
// 这里不自己读 ?token=(query-token-ratchet 只许减少直接读的地方)。没交进来时只看 Authorization 头。
|
|
81
|
+
let readToken: (req: Request) => string = req => req.headers.get("authorization")?.replace("Bearer ", "") || "";
|
|
82
|
+
/** 返回原来的读取函数(测试用来还原)。 */
|
|
83
|
+
export function setRouteCallerTokenReader(reader: (req: Request) => string): (req: Request) => string {
|
|
84
|
+
const previous = readToken;
|
|
85
|
+
readToken = reader;
|
|
86
|
+
return previous;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 令牌种类:ntok_ → node,utok_ → user,等于 master 令牌 → master,别的非空令牌 → token,没有 → anon。 */
|
|
90
|
+
export function callerTokenKind(req: Request): string {
|
|
91
|
+
const token = readToken(req);
|
|
92
|
+
if (!token) return "anon";
|
|
93
|
+
if (token.startsWith("ntok_")) return "node";
|
|
94
|
+
if (token.startsWith("utok_")) return "user";
|
|
95
|
+
if (masterToken && token === masterToken) return "master";
|
|
96
|
+
return "token";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// UA 的第一个产品名(小写)→ 输出用的名字。只有这里列出的名字会原样出现在统计里。
|
|
100
|
+
const UA_PRODUCTS = new Map([
|
|
101
|
+
"agent-node", "agent-network", "anet", "agent-network-desktop", "agent-network-dashboard", "commhub-server", "tauri-plugin-http",
|
|
102
|
+
"claude-code", "node", "undici", "node-fetch", "axios", "got", "bun", "deno", "okhttp", "cfnetwork", "dalvik",
|
|
103
|
+
"curl", "wget", "python-requests", "python-httpx", "python-urllib", "aiohttp", "go-http-client", "reqwest",
|
|
104
|
+
"postmanruntime", "httpie", "powershell",
|
|
105
|
+
].map(n => [n, n]));
|
|
106
|
+
const UA_HEAD_RE = /^([A-Za-z][A-Za-z0-9._-]{0,40})(?:\/(\S{1,64}))?/;
|
|
107
|
+
const UA_VERSION_RE = /^\d{1,6}(?:\.\d{1,6}){0,2}(?:-[A-Za-z]{1,16}(?:\.\d{1,6})?)?/;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* User-Agent 家族:"agent-node/2.5.0-preview.88"、"node"、"Bun/1.2.3" → "bun/1.2.3"、"okhttp/4.12.0"、
|
|
111
|
+
* "curl/8.5.0";Mozilla/… → "browser";没有 UA → "none";不在白名单 → "other"。
|
|
112
|
+
* iOS 的 RN fetch 形如 "<App>/<build> CFNetwork/… Darwin/…",第一个产品名是 app 自己的,认 CFNetwork 记成 "cfnetwork"。
|
|
113
|
+
*/
|
|
114
|
+
export function userAgentFamily(ua: string | null): string {
|
|
115
|
+
const s = (ua || "").trim();
|
|
116
|
+
if (!s) return "none";
|
|
117
|
+
if (s.startsWith("Mozilla/")) return "browser";
|
|
118
|
+
const head = UA_HEAD_RE.exec(s);
|
|
119
|
+
let name = head ? UA_PRODUCTS.get(head[1].toLowerCase()) : undefined;
|
|
120
|
+
let rawVersion = head?.[2];
|
|
121
|
+
if (!name) {
|
|
122
|
+
if (!/\sCFNetwork\//.test(s)) return "other";
|
|
123
|
+
name = "cfnetwork";
|
|
124
|
+
rawVersion = /\sCFNetwork\/(\S{1,64})/.exec(s)?.[1];
|
|
125
|
+
}
|
|
126
|
+
const version = rawVersion ? UA_VERSION_RE.exec(rawVersion)?.[0] : undefined;
|
|
127
|
+
return version ? `${name}/${version}` : name;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 一个请求的调用方类别,如 "node agent-node/2.5.0-preview.88"、"user browser"、"anon none"。 */
|
|
131
|
+
export function callerClass(req: Request): string {
|
|
132
|
+
return `${callerTokenKind(req)} ${userAgentFamily(req.headers.get("user-agent"))}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 记一次请求;原样返回 res,方便套在 fetch 处理器的 return 上。WebSocket 升级(res 为空)不记。 */
|
|
136
|
+
export function recordRouteTiming<T extends Response | undefined>(req: Request, started: number, res: T, now: number = Date.now()): T {
|
|
137
|
+
if (!res) return res;
|
|
138
|
+
let url: URL;
|
|
139
|
+
try { url = new URL(req.url); } catch { return res; }
|
|
140
|
+
const label = labels.get(req);
|
|
141
|
+
const route = label ? `${routeKey(req.method, url)} ${label}` : routeKey(req.method, url);
|
|
142
|
+
const length = Number(res.headers.get("content-length"));
|
|
143
|
+
const bytes = Number.isFinite(length) && res.headers.has("content-length") ? length : 0;
|
|
144
|
+
addSample(route, performance.now() - started, res.status, bytes, now, callerClass(req));
|
|
145
|
+
return res;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function addSample(route: string, ms: number, status: number, bytes: number, now: number, caller: string): void {
|
|
149
|
+
const minute = Math.floor(now / ROUTE_BUCKET_MS);
|
|
150
|
+
let bucket = buckets[buckets.length - 1];
|
|
151
|
+
if (!bucket || minute > bucket.minute) {
|
|
152
|
+
buckets.push(bucket = { minute, routes: new Map() });
|
|
153
|
+
const oldest = minute - ROUTE_BUCKET_RETENTION + 1;
|
|
154
|
+
let drop = 0;
|
|
155
|
+
while (drop < buckets.length && buckets[drop].minute < oldest) drop++;
|
|
156
|
+
if (drop) buckets.splice(0, drop);
|
|
157
|
+
} else if (minute < bucket.minute) {
|
|
158
|
+
// 时间戳比最新的桶早(时钟回拨,或测试里乱序记):放回它自己的那一分钟;比保留期还早的丢掉。
|
|
159
|
+
if (minute < bucket.minute - ROUTE_BUCKET_RETENTION + 1) return;
|
|
160
|
+
let i = buckets.length - 1;
|
|
161
|
+
while (i > 0 && buckets[i - 1].minute >= minute) i--;
|
|
162
|
+
if (buckets[i].minute !== minute) buckets.splice(i, 0, { minute, routes: new Map() });
|
|
163
|
+
bucket = buckets[i];
|
|
164
|
+
}
|
|
165
|
+
let agg = bucket.routes.get(route);
|
|
166
|
+
if (!agg) {
|
|
167
|
+
if (bucket.routes.size >= ROUTE_MAX_KEYS_PER_BUCKET) route = ROUTE_OVERFLOW_KEY;
|
|
168
|
+
agg = bucket.routes.get(route);
|
|
169
|
+
if (!agg) bucket.routes.set(route, agg = { count: 0, totalMs: 0, maxMs: 0, bytes: 0, errors: 0, hist: new Uint32Array(HIST_BINS) });
|
|
170
|
+
}
|
|
171
|
+
agg.count++;
|
|
172
|
+
agg.totalMs += ms;
|
|
173
|
+
if (ms > agg.maxMs) agg.maxMs = ms;
|
|
174
|
+
agg.bytes += bytes;
|
|
175
|
+
if (status >= 500) agg.errors++;
|
|
176
|
+
agg.hist[histBin(ms)]++;
|
|
177
|
+
const callers = agg.callers ??= new Map();
|
|
178
|
+
let c = callers.get(caller);
|
|
179
|
+
if (!c) {
|
|
180
|
+
if (callers.size >= ROUTE_MAX_CALLERS_PER_ROUTE) caller = ROUTE_OVERFLOW_KEY;
|
|
181
|
+
c = callers.get(caller);
|
|
182
|
+
if (!c) callers.set(caller, c = { count: 0, bytes: 0 });
|
|
183
|
+
}
|
|
184
|
+
c.count++;
|
|
185
|
+
c.bytes += bytes;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export type RouteStat = { route: string; count: number; total_ms: number; avg_ms: number; p95_ms: number; max_ms: number; bytes: number; errors: number; callers?: CallerStat[] };
|
|
189
|
+
export type CallerStat = { class: string; count: number; bytes: number };
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 最近 windowMs 内的请求按路由汇总,按总耗时降序。粒度是分钟桶:窗口起点落在哪个桶,就从那个桶算起。
|
|
193
|
+
* oldest_at 是窗口里最早一个有数据的桶的起点 —— 它比 now - window_ms 晚很多,说明 hub 是那之后才起来的。
|
|
194
|
+
*/
|
|
195
|
+
export function routeStats(windowMs: number, now: number = Date.now(), opts: { byCaller?: boolean } = {}): { window_ms: number; samples: number; oldest_at: string | null; routes: RouteStat[] } {
|
|
196
|
+
const fromMinute = Math.floor((now - windowMs) / ROUTE_BUCKET_MS);
|
|
197
|
+
const merged = new Map<string, Agg>();
|
|
198
|
+
let oldest: number | null = null;
|
|
199
|
+
for (const bucket of buckets) {
|
|
200
|
+
if (bucket.minute < fromMinute) continue;
|
|
201
|
+
if (oldest === null) oldest = bucket.minute * ROUTE_BUCKET_MS;
|
|
202
|
+
for (const [route, a] of bucket.routes) {
|
|
203
|
+
let m = merged.get(route);
|
|
204
|
+
if (!m) merged.set(route, m = { count: 0, totalMs: 0, maxMs: 0, bytes: 0, errors: 0, hist: new Uint32Array(HIST_BINS) });
|
|
205
|
+
m.count += a.count;
|
|
206
|
+
m.totalMs += a.totalMs;
|
|
207
|
+
if (a.maxMs > m.maxMs) m.maxMs = a.maxMs;
|
|
208
|
+
m.bytes += a.bytes;
|
|
209
|
+
m.errors += a.errors;
|
|
210
|
+
for (let i = 0; i < HIST_BINS; i++) m.hist[i] += a.hist[i];
|
|
211
|
+
if (opts.byCaller && a.callers) {
|
|
212
|
+
const mc = m.callers ??= new Map();
|
|
213
|
+
for (const [cls, c] of a.callers) {
|
|
214
|
+
const t = mc.get(cls);
|
|
215
|
+
if (t) { t.count += c.count; t.bytes += c.bytes; } else mc.set(cls, { count: c.count, bytes: c.bytes });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const round = (n: number) => Math.round(n * 10) / 10;
|
|
221
|
+
const routes = [...merged].map(([route, a]): RouteStat => {
|
|
222
|
+
const rank = Math.min(a.count, Math.floor(a.count * 0.95) + 1);
|
|
223
|
+
let seen = 0, bin = 0;
|
|
224
|
+
for (; bin < HIST_BINS - 1; bin++) { seen += a.hist[bin]; if (seen >= rank) break; }
|
|
225
|
+
return {
|
|
226
|
+
route,
|
|
227
|
+
count: a.count,
|
|
228
|
+
total_ms: round(a.totalMs),
|
|
229
|
+
avg_ms: round(a.totalMs / a.count),
|
|
230
|
+
p95_ms: round(Math.min(histUpper(bin), a.maxMs)),
|
|
231
|
+
max_ms: round(a.maxMs),
|
|
232
|
+
bytes: a.bytes,
|
|
233
|
+
errors: a.errors,
|
|
234
|
+
...(opts.byCaller ? { callers: callerStats(a.callers) } : {}),
|
|
235
|
+
};
|
|
236
|
+
}).sort((a, b) => b.total_ms - a.total_ms);
|
|
237
|
+
return { window_ms: windowMs, samples: routes.reduce((n, r) => n + r.count, 0), oldest_at: oldest === null ? null : new Date(oldest).toISOString(), routes };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 按次数降序;各分钟桶的类别合起来可能超过上限,超出的并进 ROUTE_OVERFLOW_KEY。 */
|
|
241
|
+
function callerStats(callers: Map<string, CallerAgg> | undefined): CallerStat[] {
|
|
242
|
+
let other: CallerStat | undefined;
|
|
243
|
+
const named: CallerStat[] = [];
|
|
244
|
+
for (const [cls, c] of callers ?? []) {
|
|
245
|
+
if (cls === ROUTE_OVERFLOW_KEY) other = { class: cls, count: c.count, bytes: c.bytes };
|
|
246
|
+
else named.push({ class: cls, count: c.count, bytes: c.bytes });
|
|
247
|
+
}
|
|
248
|
+
named.sort((a, b) => b.count - a.count || b.bytes - a.bytes || (a.class < b.class ? -1 : 1));
|
|
249
|
+
if (!other && named.length <= ROUTE_MAX_CALLERS_PER_ROUTE) return named;
|
|
250
|
+
other ??= { class: ROUTE_OVERFLOW_KEY, count: 0, bytes: 0 };
|
|
251
|
+
for (const r of named.splice(ROUTE_MAX_CALLERS_PER_ROUTE - 1)) { other.count += r.count; other.bytes += r.bytes; }
|
|
252
|
+
return [...named, other];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Test-only. */
|
|
256
|
+
export function __resetRouteTimingForTest(): void {
|
|
257
|
+
buckets.length = 0;
|
|
258
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Duplicate-alias node rows vs the node pull/ack paths (field report).
|
|
2
|
+
//
|
|
3
|
+
// Re-creating a node under the same alias leaves the old `nodes` row behind, so
|
|
4
|
+
// one alias can own several rows in a network. The pull/ack tools used to
|
|
5
|
+
// resolve the caller by `WHERE alias = ? AND network_id = ?` + `db.get`, i.e.
|
|
6
|
+
// the first row SQLite yields — the stale one. Field shape: the client queued a
|
|
7
|
+
// rules-file read for the live node, the node pulled with its own (bound)
|
|
8
|
+
// token, the hub looked under the stale node_id, answered `request: null`, and
|
|
9
|
+
// the read timed out with only `[rules-file] doorbell received` in the node log.
|
|
10
|
+
//
|
|
11
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/rules-dup.db bun test src/rules-file-duplicate-alias.test.ts
|
|
12
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
14
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
15
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
import { db } from "./db.js";
|
|
17
|
+
import { registerTools } from "./tools.js";
|
|
18
|
+
|
|
19
|
+
const NET = "net_rules_dup";
|
|
20
|
+
const USER = "u_rules_dup_owner";
|
|
21
|
+
const ALIAS = "rules-dup-node";
|
|
22
|
+
// Inserted first → the row a plain `db.get` on the alias returns.
|
|
23
|
+
const STALE_ID = "node_rules_dup_stale";
|
|
24
|
+
const LIVE_ID = "node_rules_dup_live";
|
|
25
|
+
const STALE_TOKEN = "tok_rules_dup_stale";
|
|
26
|
+
const LIVE_TOKEN = "tok_rules_dup_live";
|
|
27
|
+
const UNBOUND_TOKEN = "tok_rules_dup_unbound";
|
|
28
|
+
|
|
29
|
+
function cleanup() {
|
|
30
|
+
try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [NET]); } catch {}
|
|
31
|
+
try { db.run("DELETE FROM node_config_updates WHERE network_id = ?1", [NET]); } catch {}
|
|
32
|
+
try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [NET]); } catch {}
|
|
33
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET]); } catch {}
|
|
34
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
|
|
35
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET]); } catch {}
|
|
36
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
37
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
38
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function seedWorld(opts: { liveSession?: boolean } = {}) {
|
|
42
|
+
db.run(
|
|
43
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
44
|
+
[USER, USER],
|
|
45
|
+
);
|
|
46
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET, NET, USER]);
|
|
47
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, NET]);
|
|
48
|
+
// Stale row first (older created_at AND lower rowid), live row second.
|
|
49
|
+
db.run(
|
|
50
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, '2026-09-16 01:40:40', '2026-09-16 01:40:40')`,
|
|
51
|
+
[STALE_ID, ALIAS, ALIAS, NET],
|
|
52
|
+
);
|
|
53
|
+
db.run(
|
|
54
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, '2026-09-16 01:42:46', '2026-09-16 01:42:46')`,
|
|
55
|
+
[LIVE_ID, ALIAS, ALIAS, NET],
|
|
56
|
+
);
|
|
57
|
+
for (const [tok, bound, hash] of [
|
|
58
|
+
[STALE_TOKEN, STALE_ID, "hash_rules_dup_stale"],
|
|
59
|
+
[LIVE_TOKEN, LIVE_ID, "hash_rules_dup_live"],
|
|
60
|
+
[UNBOUND_TOKEN, null, "hash_rules_dup_unbound"],
|
|
61
|
+
] as const) {
|
|
62
|
+
db.run(
|
|
63
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at, bound_node_id) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL, ?6)`,
|
|
64
|
+
[tok, USER, NET, `node:${ALIAS}`, hash, bound],
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (opts.liveSession !== false) {
|
|
68
|
+
db.run(
|
|
69
|
+
`INSERT INTO sessions (resume_id, alias, network_id, node_id, status, updated_at) VALUES (?1, ?2, ?3, ?4, 'idle', datetime('now'))`,
|
|
70
|
+
[`sdk-${LIVE_ID}`, ALIAS, NET, LIVE_ID],
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type Identity = { alias: string; isNetworkToken: boolean; tokenId: string };
|
|
76
|
+
const asUser: Identity = { alias: USER, isNetworkToken: false, tokenId: "tok_rules_dup_user" };
|
|
77
|
+
const asLive: Identity = { alias: ALIAS, isNetworkToken: true, tokenId: LIVE_TOKEN };
|
|
78
|
+
const asStale: Identity = { alias: ALIAS, isNetworkToken: true, tokenId: STALE_TOKEN };
|
|
79
|
+
const asUnbound: Identity = { alias: ALIAS, isNetworkToken: true, tokenId: UNBOUND_TOKEN };
|
|
80
|
+
|
|
81
|
+
async function connect(id: Identity) {
|
|
82
|
+
const server = new McpServer({ name: "rules-dup-test", version: "1" });
|
|
83
|
+
registerTools(server, undefined, NET, USER, id.alias, id.isNetworkToken, id.tokenId);
|
|
84
|
+
const client = new Client({ name: "rules-dup-client", version: "1" });
|
|
85
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
86
|
+
await server.connect(st);
|
|
87
|
+
await client.connect(ct);
|
|
88
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
89
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
90
|
+
const first = r.content?.[0];
|
|
91
|
+
if (!first || first.type !== "text") throw new Error(`no text result from ${name}`);
|
|
92
|
+
return JSON.parse(first.text);
|
|
93
|
+
};
|
|
94
|
+
const close = async () => { await client.close(); await server.close(); };
|
|
95
|
+
return { call, close };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function queuedNodeId(requestId: string): string | undefined {
|
|
99
|
+
return db.get<{ node_id: string }>("SELECT node_id FROM node_rules_requests WHERE request_id = ?1", requestId)?.node_id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
beforeEach(() => { cleanup(); });
|
|
103
|
+
afterAll(cleanup);
|
|
104
|
+
|
|
105
|
+
describe("duplicate alias — rules file pull/ack", () => {
|
|
106
|
+
test("field case: read queued for the live node_id is pulled and acked by the live node's bound token", async () => {
|
|
107
|
+
seedWorld();
|
|
108
|
+
const u = await connect(asUser);
|
|
109
|
+
const n = await connect(asLive);
|
|
110
|
+
try {
|
|
111
|
+
const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
|
|
112
|
+
expect(enq).toMatchObject({ ok: true, op: "read" });
|
|
113
|
+
const pulled = await n.call("get_rules_file_request", {});
|
|
114
|
+
expect(pulled).toEqual({ ok: true, request: { request_id: enq.request_id, op: "read" } });
|
|
115
|
+
const ack = await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "AGENTS.md", exists: true, content: "# live" });
|
|
116
|
+
expect(ack).toMatchObject({ ok: true, status: "done" });
|
|
117
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "done", content: "# live" });
|
|
118
|
+
} finally { await u.close(); await n.close(); }
|
|
119
|
+
}, 20_000);
|
|
120
|
+
|
|
121
|
+
test("a token bound to the stale row can neither pull nor ack the live node's request", async () => {
|
|
122
|
+
seedWorld();
|
|
123
|
+
const u = await connect(asUser);
|
|
124
|
+
const stale = await connect(asStale);
|
|
125
|
+
try {
|
|
126
|
+
const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
|
|
127
|
+
expect(await stale.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
128
|
+
expect(await stale.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "# forged" }))
|
|
129
|
+
.toEqual({ ok: true, ignored: "unknown_or_foreign_request" });
|
|
130
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
|
|
131
|
+
} finally { await u.close(); await stale.close(); }
|
|
132
|
+
}, 20_000);
|
|
133
|
+
|
|
134
|
+
test("the live token does not pull a request queued for the stale row", async () => {
|
|
135
|
+
seedWorld();
|
|
136
|
+
const u = await connect(asUser);
|
|
137
|
+
const n = await connect(asLive);
|
|
138
|
+
try {
|
|
139
|
+
await u.call("read_node_rules_file", { node_id: STALE_ID });
|
|
140
|
+
expect(await n.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
141
|
+
} finally { await u.close(); await n.close(); }
|
|
142
|
+
}, 20_000);
|
|
143
|
+
|
|
144
|
+
test("client targeting by alias queues under the row the alias's session points at", async () => {
|
|
145
|
+
seedWorld();
|
|
146
|
+
const u = await connect(asUser);
|
|
147
|
+
try {
|
|
148
|
+
const enq = await u.call("read_node_rules_file", { alias: ALIAS });
|
|
149
|
+
expect(enq).toMatchObject({ ok: true, op: "read" });
|
|
150
|
+
expect(queuedNodeId(enq.request_id)).toBe(LIVE_ID);
|
|
151
|
+
const sk = await u.call("list_node_skills", { alias: ALIAS });
|
|
152
|
+
expect(sk).toMatchObject({ ok: true });
|
|
153
|
+
expect(queuedNodeId(sk.request_id)).toBe(LIVE_ID);
|
|
154
|
+
} finally { await u.close(); }
|
|
155
|
+
}, 20_000);
|
|
156
|
+
|
|
157
|
+
test("without a session row, alias targeting falls back to the newest row", async () => {
|
|
158
|
+
seedWorld({ liveSession: false });
|
|
159
|
+
const u = await connect(asUser);
|
|
160
|
+
try {
|
|
161
|
+
const enq = await u.call("read_node_rules_file", { alias: ALIAS });
|
|
162
|
+
expect(queuedNodeId(enq.request_id)).toBe(LIVE_ID);
|
|
163
|
+
} finally { await u.close(); }
|
|
164
|
+
}, 20_000);
|
|
165
|
+
|
|
166
|
+
test("the session mapping wins over recency: alias targeting picks the older row when the session points at it", async () => {
|
|
167
|
+
seedWorld({ liveSession: false });
|
|
168
|
+
db.run(
|
|
169
|
+
`INSERT INTO sessions (resume_id, alias, network_id, node_id, status, updated_at) VALUES (?1, ?2, ?3, ?4, 'idle', datetime('now'))`,
|
|
170
|
+
[`sdk-${STALE_ID}`, ALIAS, NET, STALE_ID],
|
|
171
|
+
);
|
|
172
|
+
const u = await connect(asUser);
|
|
173
|
+
try {
|
|
174
|
+
const enq = await u.call("read_node_rules_file", { alias: ALIAS });
|
|
175
|
+
expect(queuedNodeId(enq.request_id)).toBe(STALE_ID);
|
|
176
|
+
} finally { await u.close(); }
|
|
177
|
+
}, 20_000);
|
|
178
|
+
|
|
179
|
+
test("a token bound to a node whose row is gone does not fall back to another row with its alias", async () => {
|
|
180
|
+
seedWorld();
|
|
181
|
+
db.run("UPDATE api_tokens SET bound_node_id = 'node_rules_dup_deleted' WHERE token_id = ?1", [STALE_TOKEN]);
|
|
182
|
+
const u = await connect(asUser);
|
|
183
|
+
const stale = await connect(asStale);
|
|
184
|
+
try {
|
|
185
|
+
const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
|
|
186
|
+
expect(await stale.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
187
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
|
|
188
|
+
} finally { await u.close(); await stale.close(); }
|
|
189
|
+
}, 20_000);
|
|
190
|
+
|
|
191
|
+
test("an unbound legacy token resolves its alias to the session-mapped row", async () => {
|
|
192
|
+
seedWorld();
|
|
193
|
+
const u = await connect(asUser);
|
|
194
|
+
const n = await connect(asUnbound);
|
|
195
|
+
try {
|
|
196
|
+
const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
|
|
197
|
+
expect(await n.call("get_rules_file_request", {})).toEqual({ ok: true, request: { request_id: enq.request_id, op: "read" } });
|
|
198
|
+
} finally { await u.close(); await n.close(); }
|
|
199
|
+
}, 20_000);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("duplicate alias — config update pull/ack", () => {
|
|
203
|
+
function queueUpdate(nodeId: string, updateId: string) {
|
|
204
|
+
db.run(
|
|
205
|
+
`INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, '{"model":"m"}', 'hot', 0, 'pending', ?4, 'tok_rules_dup_user')`,
|
|
206
|
+
[updateId, nodeId, NET, Date.now()],
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
test("the live node's bound token pulls and acks its own pending update", async () => {
|
|
211
|
+
seedWorld();
|
|
212
|
+
queueUpdate(LIVE_ID, "cu_rules_dup_live");
|
|
213
|
+
const n = await connect(asLive);
|
|
214
|
+
try {
|
|
215
|
+
const pulled = await n.call("get_config_update", {});
|
|
216
|
+
expect(pulled.update?.update_id).toBe("cu_rules_dup_live");
|
|
217
|
+
const ack = await n.call("ack_config_update", { update_id: "cu_rules_dup_live", status: "applied", new_revision: 1 });
|
|
218
|
+
expect(ack.ok).toBe(true);
|
|
219
|
+
expect(ack.ignored).toBeUndefined();
|
|
220
|
+
expect(db.get<{ status: string }>("SELECT status FROM node_config_updates WHERE update_id = 'cu_rules_dup_live'")?.status).toBe("applied");
|
|
221
|
+
} finally { await n.close(); }
|
|
222
|
+
}, 20_000);
|
|
223
|
+
|
|
224
|
+
test("a token bound to the stale row cannot pull or ack the live node's update", async () => {
|
|
225
|
+
seedWorld();
|
|
226
|
+
queueUpdate(LIVE_ID, "cu_rules_dup_live2");
|
|
227
|
+
const stale = await connect(asStale);
|
|
228
|
+
try {
|
|
229
|
+
expect((await stale.call("get_config_update", {})).update).toBeNull();
|
|
230
|
+
await stale.call("ack_config_update", { update_id: "cu_rules_dup_live2", status: "applied", new_revision: 1 });
|
|
231
|
+
expect(db.get<{ status: string }>("SELECT status FROM node_config_updates WHERE update_id = 'cu_rules_dup_live2'")?.status).toBe("pending");
|
|
232
|
+
} finally { await stale.close(); }
|
|
233
|
+
}, 20_000);
|
|
234
|
+
});
|