@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.131
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 +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -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 +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -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-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -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 +933 -36
- 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 +330 -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-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -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-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -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 +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -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 +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -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 +101 -0
- package/src/node-id-alias.test.ts +190 -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 +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -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-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -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/project-dir-warn-clear-http.test.ts +132 -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 +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -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 +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -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-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -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 +349 -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 +259 -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 +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -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 +130 -0
- package/src/rest-identity.ts +139 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -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/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -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 +766 -0
- package/src/scoped-secrets.ts +124 -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 +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -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 +264 -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 +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-auth-origin-http.test.ts +93 -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 +315 -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-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -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 +140 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-sender-http.test.ts +143 -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 +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3940 -325
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- 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,190 @@
|
|
|
1
|
+
// #1281 — 子节点生命周期工具的参数名统一(node_id ⇄ child_node_id)。
|
|
2
|
+
//
|
|
3
|
+
// 分叉现状(origin/main 2976adb2):stop_node / start_node / delete_node 用
|
|
4
|
+
// `child_node_id`,restart_node / update_node_config 用 `node_id`,指向同一个
|
|
5
|
+
// 子节点。调用方按一个工具的习惯给另一个传就吃 -32602。修复:5 个工具都同时
|
|
6
|
+
// 接受两个名字(canonical = node_id,child_node_id 兼容 alias),解析收敛到一个
|
|
7
|
+
// 模块级 helper resolveNodeIdArg。
|
|
8
|
+
//
|
|
9
|
+
// 两向 witnessed-red(本文件即判据):
|
|
10
|
+
// · schema 层:每个工具的**历史用名**始终 parse 成功(证明没破已发布契约),
|
|
11
|
+
// **新增别名**在修复前 parse 失败(缺必填字段)、修复后成功。
|
|
12
|
+
// · handler 层:resolveNodeIdArg 纯函数 —— 二选一至少一必填、都传须相等。
|
|
13
|
+
// · 真实 handler 路径:stop_node 用 node_id / child_node_id 都能穿到
|
|
14
|
+
// resolveDaemonForChild(不再被 node_id_required 挡),都传冲突 → node_id_conflict。
|
|
15
|
+
//
|
|
16
|
+
// 对 pre-fix 见红的证明方式(评审可复现):把 tools.ts 里任一工具的
|
|
17
|
+
// `...NODE_ID_ALIAS_FIELDS` 还原成原来的单名字段(如 stop_node 的
|
|
18
|
+
// `child_node_id: z.string()...regex(...)`),本文件对应工具的「新增别名 parse 成功」
|
|
19
|
+
// 断言立即变红 —— 证明该断言有判别力,不是恒真。
|
|
20
|
+
|
|
21
|
+
import { describe, expect, test, beforeAll } from "bun:test";
|
|
22
|
+
import { mkdtempSync } from "fs";
|
|
23
|
+
import { join } from "path";
|
|
24
|
+
import { tmpdir } from "os";
|
|
25
|
+
|
|
26
|
+
process.env.COMMHUB_DB = process.env.COMMHUB_DB || (mkdtempSync(join(tmpdir(), "anet-1281-")) + "/commhub.db");
|
|
27
|
+
|
|
28
|
+
import { z } from "zod/v4";
|
|
29
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
30
|
+
import { registerTools, resolveNodeIdArg } from "./tools.js";
|
|
31
|
+
|
|
32
|
+
type ToolHandler = (args: any) => Promise<{ content: { type: "text"; text: string }[] }>;
|
|
33
|
+
|
|
34
|
+
// Capture BOTH the raw zod shape and the handler for every tool, so the
|
|
35
|
+
// schema-layer assertions run against the exact object server.tool received.
|
|
36
|
+
function buildCaptured() {
|
|
37
|
+
const schemas: Record<string, any> = {};
|
|
38
|
+
const handlers: Record<string, ToolHandler> = {};
|
|
39
|
+
const server = new McpServer({ name: "t1281", version: "0" }) as any;
|
|
40
|
+
const origTool = server.tool.bind(server);
|
|
41
|
+
server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => {
|
|
42
|
+
schemas[name] = schema; handlers[name] = handler;
|
|
43
|
+
return origTool(name, _desc, schema, handler);
|
|
44
|
+
};
|
|
45
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
46
|
+
if (origRegisterTool) {
|
|
47
|
+
server.registerTool = (name: string, cfg: any, handler: ToolHandler) => {
|
|
48
|
+
schemas[name] = cfg?.inputSchema ?? cfg; handlers[name] = handler;
|
|
49
|
+
return origRegisterTool(name, cfg, handler);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
registerTools(server, undefined, null, "user_1281", null, false, null);
|
|
53
|
+
return { schemas, handlers };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let SCHEMAS: Record<string, any> = {};
|
|
57
|
+
let HANDLERS: Record<string, ToolHandler> = {};
|
|
58
|
+
beforeAll(() => { const c = buildCaptured(); SCHEMAS = c.schemas; HANDLERS = c.handlers; });
|
|
59
|
+
|
|
60
|
+
async function call(name: string, args: any) {
|
|
61
|
+
const r = await HANDLERS[name](args);
|
|
62
|
+
return JSON.parse(r.content[0].text);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Per tool: the historically-required id name, the newly-added alias, and
|
|
66
|
+
// the OTHER required fields the shape needs so a safeParse isolates the id
|
|
67
|
+
// field rather than tripping on an unrelated missing field.
|
|
68
|
+
const TOOLS = [
|
|
69
|
+
{ name: "stop_node", historic: "child_node_id", added: "node_id", extra: {} },
|
|
70
|
+
{ name: "start_node", historic: "child_node_id", added: "node_id", extra: {} },
|
|
71
|
+
{ name: "delete_node", historic: "child_node_id", added: "node_id", extra: { confirm_alias: "some-alias" } },
|
|
72
|
+
{ name: "restart_node", historic: "node_id", added: "child_node_id", extra: {} },
|
|
73
|
+
{ name: "update_node_config", historic: "node_id", added: "child_node_id", extra: { base_revision: 0, patch: {} } },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const VALID_ID = "node_test_1281";
|
|
77
|
+
|
|
78
|
+
describe("#1281 schema layer — every lifecycle tool accepts BOTH node_id and child_node_id", () => {
|
|
79
|
+
for (const t of TOOLS) {
|
|
80
|
+
test(`${t.name}: historic name '${t.historic}' still parses (published contract intact)`, () => {
|
|
81
|
+
const shape = z.object(SCHEMAS[t.name]);
|
|
82
|
+
const r = shape.safeParse({ [t.historic]: VALID_ID, ...t.extra });
|
|
83
|
+
expect(r.success).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test(`${t.name}: added alias '${t.added}' parses (was -32602 pre-#1281 → RED without the fix)`, () => {
|
|
87
|
+
const shape = z.object(SCHEMAS[t.name]);
|
|
88
|
+
const r = shape.safeParse({ [t.added]: VALID_ID, ...t.extra });
|
|
89
|
+
expect(r.success).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test(`${t.name}: both names present + equal parses (schema layer permissive; conflict caught in handler)`, () => {
|
|
93
|
+
const shape = z.object(SCHEMAS[t.name]);
|
|
94
|
+
const r = shape.safeParse({ node_id: VALID_ID, child_node_id: VALID_ID, ...t.extra });
|
|
95
|
+
expect(r.success).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("#1281 resolveNodeIdArg — the single resolution helper", () => {
|
|
101
|
+
test("node_id only → resolves to node_id", () => {
|
|
102
|
+
expect(resolveNodeIdArg({ node_id: "node_a" })).toEqual({ ok: true, node_id: "node_a" });
|
|
103
|
+
});
|
|
104
|
+
test("child_node_id only → resolves to it (back-compat)", () => {
|
|
105
|
+
expect(resolveNodeIdArg({ child_node_id: "node_b" })).toEqual({ ok: true, node_id: "node_b" });
|
|
106
|
+
});
|
|
107
|
+
test("both present + EQUAL → resolves (no conflict)", () => {
|
|
108
|
+
expect(resolveNodeIdArg({ node_id: "node_c", child_node_id: "node_c" })).toEqual({ ok: true, node_id: "node_c" });
|
|
109
|
+
});
|
|
110
|
+
test("both present + DIFFERENT → node_id_conflict (catches A/B fat-finger)", () => {
|
|
111
|
+
const r = resolveNodeIdArg({ node_id: "node_a", child_node_id: "node_b" });
|
|
112
|
+
expect(r.ok).toBe(false);
|
|
113
|
+
expect((r as any).error).toBe("node_id_conflict");
|
|
114
|
+
});
|
|
115
|
+
test("neither present → node_id_required", () => {
|
|
116
|
+
const r = resolveNodeIdArg({});
|
|
117
|
+
expect(r.ok).toBe(false);
|
|
118
|
+
expect((r as any).error).toBe("node_id_required");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("#1281 real handler path — stop_node routes both names to the same place", () => {
|
|
123
|
+
// stop_node calls resolveNodeIdArg FIRST, then resolveDaemonForChild. A
|
|
124
|
+
// fabricated id has no create-request row → daemon_not_resolvable. The
|
|
125
|
+
// point is that BOTH names get PAST the id gate to that same downstream
|
|
126
|
+
// error (proving the alias flows through), and the two error branches fire.
|
|
127
|
+
test("node_id (new name) flows through → daemon_not_resolvable, NOT node_id_required", async () => {
|
|
128
|
+
const r = await call("stop_node", { node_id: VALID_ID });
|
|
129
|
+
expect(r.ok).toBe(false);
|
|
130
|
+
expect(r.error).toBe("daemon_not_resolvable");
|
|
131
|
+
});
|
|
132
|
+
test("child_node_id (old name) flows through → daemon_not_resolvable (back-compat intact)", async () => {
|
|
133
|
+
const r = await call("stop_node", { child_node_id: VALID_ID });
|
|
134
|
+
expect(r.ok).toBe(false);
|
|
135
|
+
expect(r.error).toBe("daemon_not_resolvable");
|
|
136
|
+
});
|
|
137
|
+
test("neither → node_id_required (before any daemon resolution)", async () => {
|
|
138
|
+
const r = await call("stop_node", { force: true });
|
|
139
|
+
expect(r.ok).toBe(false);
|
|
140
|
+
expect(r.error).toBe("node_id_required");
|
|
141
|
+
});
|
|
142
|
+
test("both differ → node_id_conflict (before any daemon resolution)", async () => {
|
|
143
|
+
const r = await call("stop_node", { node_id: "node_a", child_node_id: "node_b" });
|
|
144
|
+
expect(r.ok).toBe(false);
|
|
145
|
+
expect(r.error).toBe("node_id_conflict");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("app#196 —— 「解析不到 daemon」的两种情况必须给不同的话", () => {
|
|
150
|
+
// 🔴 生产实测(2026-08-28):218 个节点里 207 个是 `n_…`(有人直接
|
|
151
|
+
// `anet node start` 起的),它们**根本没有 daemon**。原先这两种情况
|
|
152
|
+
// 打印同一句 `pass daemon_node_id explicitly` —— 对那 207 个来说,
|
|
153
|
+
// **这句建议的前提是假的**,照着做只会走进死路。
|
|
154
|
+
const HAND_STARTED = "n_73687f17"; // 手工起的节点,真实形状
|
|
155
|
+
const DAEMON_CHILD = "node_test_1281"; // daemon 建的子节点
|
|
156
|
+
|
|
157
|
+
test("手工起的节点 → not_daemon_managed,并且**不**建议传 daemon_node_id", async () => {
|
|
158
|
+
const r = await call("stop_node", { node_id: HAND_STARTED });
|
|
159
|
+
expect(r.ok).toBe(false);
|
|
160
|
+
expect(r.error).toBe("not_daemon_managed");
|
|
161
|
+
// 🔴 这一条是本组的核心:旧文案的那句建议**不能**再出现在这条路径上。
|
|
162
|
+
expect(r.message).not.toContain("pass daemon_node_id explicitly");
|
|
163
|
+
// 而且要告诉他真正能走的那条路。
|
|
164
|
+
expect(r.message).toContain("anet node stop");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("daemon 建的子节点(无 create-request 行) → 仍是 daemon_not_resolvable", async () => {
|
|
168
|
+
const r = await call("stop_node", { node_id: DAEMON_CHILD });
|
|
169
|
+
expect(r.ok).toBe(false);
|
|
170
|
+
expect(r.error).toBe("daemon_not_resolvable");
|
|
171
|
+
// Board #625: an explicit daemon id no longer substitutes for authority.
|
|
172
|
+
expect(r.message).not.toContain("pass daemon_node_id explicitly");
|
|
173
|
+
expect(r.message).toContain("adopt this node");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("delete_node 走同一条判别(两个调用点不能只改一个)", async () => {
|
|
177
|
+
const r = await call("delete_node", { node_id: HAND_STARTED, confirm_alias: "x" });
|
|
178
|
+
expect(r.ok).toBe(false);
|
|
179
|
+
expect(r.error).toBe("not_daemon_managed");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// 🔴 反向见证:两种情况必须**真的不同**。若有人把判别改成恒定一支,
|
|
183
|
+
// 上面三条里至少一条会红 —— 但那还不够明显,所以这里直接断言两者不等。
|
|
184
|
+
test("两种情况的 error 与 message 都不相同", async () => {
|
|
185
|
+
const a = await call("stop_node", { node_id: HAND_STARTED });
|
|
186
|
+
const b = await call("stop_node", { node_id: DAEMON_CHILD });
|
|
187
|
+
expect(a.error).not.toBe(b.error);
|
|
188
|
+
expect(a.message).not.toBe(b.message);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// #507 — two live copies of one node (directory copied verbatim: same node token,
|
|
2
|
+
// same alias) used to hold two SSE subscriptions on `${net}:${alias}`. Every new
|
|
3
|
+
// task was pushed to BOTH (each copy ran it), and either copy's shutdown
|
|
4
|
+
// report_status(offline) took the node offline under the other → alias_offline.
|
|
5
|
+
//
|
|
6
|
+
// Pinned here, through the real HTTP entrances (GET /events/:alias with the node
|
|
7
|
+
// token, POST /mcp report_status, POST /api/task, GET /api/stats/sse):
|
|
8
|
+
// 1. newest connection wins: the older node stream gets node_connection_superseded
|
|
9
|
+
// (reason superseded_by_new_connection) and is closed; /api/stats/sse shows 1.
|
|
10
|
+
// 2. a new task reaches exactly ONE node subscriber; a dashboard monitor on the
|
|
11
|
+
// same channel is untouched and still sees it.
|
|
12
|
+
// 3. node_identity_conflict reaches the network observer stream and
|
|
13
|
+
// /api/stats/sse.identity_conflicts, with both remotes and no token.
|
|
14
|
+
// 4. offline only when the last node connection leaves; tasks in between are
|
|
15
|
+
// delivered, not alias_offline.
|
|
16
|
+
// 5. same instance id = a reconnect (replaced_by_reconnect, no conflict);
|
|
17
|
+
// different ids = instance_match "different"; another node's token watching
|
|
18
|
+
// this alias never supersedes it; a plain single-node stop is offline at once.
|
|
19
|
+
//
|
|
20
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/node-identity-conflict-http.test.ts
|
|
21
|
+
// PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
|
|
22
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
23
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
27
|
+
import { db } from "./db.js";
|
|
28
|
+
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-node-conflict-"));
|
|
30
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
31
|
+
if (!activeDbPath) throw new Error("node-identity-conflict requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
32
|
+
|
|
33
|
+
const stamp = Date.now();
|
|
34
|
+
let server: any;
|
|
35
|
+
let base = "";
|
|
36
|
+
let ownerToken = "", ownerId = "", NET = "";
|
|
37
|
+
const nodes: Record<string, { alias: string; nodeId: string; token: string }> = {};
|
|
38
|
+
|
|
39
|
+
type Sub = { events: any[]; done: boolean; abort: () => void; ready: Promise<void> };
|
|
40
|
+
|
|
41
|
+
function subscribe(path: string, token: string, headers: Record<string, string> = {}): Sub {
|
|
42
|
+
const ctrl = new AbortController();
|
|
43
|
+
const sub: Sub = { events: [], done: false, abort: () => ctrl.abort(), ready: Promise.resolve() };
|
|
44
|
+
let markReady!: () => void;
|
|
45
|
+
sub.ready = new Promise<void>((r) => { markReady = r; });
|
|
46
|
+
(async () => {
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream", ...headers }, signal: ctrl.signal });
|
|
49
|
+
if (!res.ok || !res.body) throw new Error(`subscribe ${path} → ${res.status}`);
|
|
50
|
+
const reader = res.body.getReader();
|
|
51
|
+
const dec = new TextDecoder();
|
|
52
|
+
let buf = "";
|
|
53
|
+
while (true) {
|
|
54
|
+
const { done, value } = await reader.read();
|
|
55
|
+
if (done) break;
|
|
56
|
+
buf += dec.decode(value, { stream: true });
|
|
57
|
+
const lines = buf.split("\n"); buf = lines.pop() || "";
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
if (!line.startsWith("data: ")) continue;
|
|
60
|
+
const ev = JSON.parse(line.slice(6));
|
|
61
|
+
sub.events.push(ev);
|
|
62
|
+
if (ev.type === "connected") markReady();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch { /* aborted */ }
|
|
66
|
+
sub.done = true;
|
|
67
|
+
markReady();
|
|
68
|
+
})();
|
|
69
|
+
return sub;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function until(cond: () => boolean, what: string, ms = 3000) {
|
|
73
|
+
const t0 = Date.now();
|
|
74
|
+
while (!cond()) {
|
|
75
|
+
if (Date.now() - t0 > ms) throw new Error(`timed out waiting for: ${what}`);
|
|
76
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const settle = () => new Promise((r) => setTimeout(r, 150));
|
|
80
|
+
|
|
81
|
+
async function api(token: string, method: string, path: string, body?: unknown) {
|
|
82
|
+
const res = await fetch(`${base}${path}`, {
|
|
83
|
+
method,
|
|
84
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
85
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
86
|
+
});
|
|
87
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function tool(token: string, name: string, args: Record<string, unknown>) {
|
|
91
|
+
const res = await fetch(`${base}/mcp`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
94
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
95
|
+
});
|
|
96
|
+
const raw = await res.text();
|
|
97
|
+
const data = raw.split("\n").filter((x) => x.startsWith("data:"));
|
|
98
|
+
const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
99
|
+
const text = payload.result.content[0].text;
|
|
100
|
+
if (!text.startsWith("{")) throw new Error(`${name}: ${text}`);
|
|
101
|
+
return JSON.parse(text);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const reportStatus = (n: { alias: string; nodeId: string; token: string }, status: string) =>
|
|
105
|
+
tool(n.token, "report_status", { resume_id: `sdk-${n.nodeId}`, alias: n.alias, status, node_id: n.nodeId, network_id: NET });
|
|
106
|
+
const storedStatus = (alias: string) =>
|
|
107
|
+
db.get<{ status: string }>("SELECT status FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, NET)?.status;
|
|
108
|
+
let seq = 0;
|
|
109
|
+
const sendTask = (alias: string) => api(ownerToken, "POST", "/api/task", { alias, task: `conflict task ${stamp} #${++seq}`, network_id: NET });
|
|
110
|
+
const nodeStream = (n: { alias: string; token: string }, headers: Record<string, string> = {}) =>
|
|
111
|
+
subscribe(`/events/${encodeURIComponent(n.alias)}`, n.token, headers);
|
|
112
|
+
const newTasks = (s: Sub) => s.events.filter((e) => e.type === "new_task");
|
|
113
|
+
const sseCount = async (alias: string) => (await api(nodes.a.token, "GET", "/api/stats/sse")).body.sessions[`${NET}:${alias}`] ?? 0;
|
|
114
|
+
|
|
115
|
+
beforeAll(async () => {
|
|
116
|
+
process.env.COMMHUB_UPLOADS_DIR = join(dir, "uploads");
|
|
117
|
+
process.env.HOST = "127.0.0.1";
|
|
118
|
+
const owner = register(`nic_owner_${stamp}`, "NodeConflictOwner123!", undefined, "seed");
|
|
119
|
+
ownerToken = owner.token!; ownerId = owner.user!.user_id; NET = owner.network_id!;
|
|
120
|
+
for (const key of ["a", "b", "c", "d", "e"]) {
|
|
121
|
+
const alias = `nic-${key}-${stamp}`;
|
|
122
|
+
const nodeId = `n_nic_${key}_${stamp}`;
|
|
123
|
+
const minted = createNetworkTokenForNode(ownerId, NET, alias, nodeId);
|
|
124
|
+
expect(minted.ok).toBe(true);
|
|
125
|
+
nodes[key] = { alias, nodeId, token: minted.token! };
|
|
126
|
+
}
|
|
127
|
+
const mod: any = await import("./server.js");
|
|
128
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
129
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
130
|
+
for (const n of Object.values(nodes)) expect((await reportStatus(n, "idle")).ok).toBe(true);
|
|
131
|
+
}, 30_000);
|
|
132
|
+
|
|
133
|
+
afterAll(() => {
|
|
134
|
+
try { server?.stop?.(true); } catch {}
|
|
135
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("#507 two copies of one node (same token, same alias)", () => {
|
|
139
|
+
const subs: Record<string, Sub> = {};
|
|
140
|
+
|
|
141
|
+
test("newest connection wins: the older node stream is told why and closed", async () => {
|
|
142
|
+
const n = nodes.a;
|
|
143
|
+
subs.observer = subscribe(`/events/network/${NET}`, ownerToken);
|
|
144
|
+
subs.monitor = subscribe(`/events/${encodeURIComponent(n.alias)}?network_id=${NET}`, ownerToken);
|
|
145
|
+
await subs.observer.ready; await subs.monitor.ready;
|
|
146
|
+
subs.copyA = nodeStream(n, { "User-Agent": "copy-A" });
|
|
147
|
+
await subs.copyA.ready;
|
|
148
|
+
expect(await sseCount(n.alias)).toBe(2); // copy A + dashboard monitor
|
|
149
|
+
|
|
150
|
+
subs.copyB = nodeStream(n, { "User-Agent": "copy-B" });
|
|
151
|
+
await subs.copyB.ready;
|
|
152
|
+
await until(() => subs.copyA.done, "copy A's stream to be closed");
|
|
153
|
+
const notice = subs.copyA.events.find((e) => e.type === "node_connection_superseded");
|
|
154
|
+
expect(notice).toBeTruthy();
|
|
155
|
+
expect(notice.reason).toBe("superseded_by_new_connection");
|
|
156
|
+
expect(notice.instance_match).toBe("unknown");
|
|
157
|
+
expect(subs.copyB.done).toBe(false);
|
|
158
|
+
expect(subs.monitor.done).toBe(false); // a monitor is never superseded
|
|
159
|
+
expect(await sseCount(n.alias)).toBe(2); // copy B + monitor
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("a new task reaches exactly one node subscriber (the monitor still sees it)", async () => {
|
|
163
|
+
const r = await sendTask(nodes.a.alias);
|
|
164
|
+
expect(r.status).toBe(200);
|
|
165
|
+
expect(r.body.ok).toBe(true);
|
|
166
|
+
await until(() => newTasks(subs.copyB).length >= 1 && newTasks(subs.monitor).length >= 1, "new_task on copy B + monitor");
|
|
167
|
+
await settle();
|
|
168
|
+
expect(newTasks(subs.copyB).length).toBe(1);
|
|
169
|
+
expect(newTasks(subs.copyA).length).toBe(0);
|
|
170
|
+
expect(newTasks(subs.monitor).length).toBe(1);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("node_identity_conflict reaches the observer stream and /api/stats/sse, with remotes and no token", async () => {
|
|
174
|
+
await until(() => subs.observer.events.some((e) => e.type === "node_identity_conflict"), "observer conflict event");
|
|
175
|
+
const ev = subs.observer.events.find((e) => e.type === "node_identity_conflict");
|
|
176
|
+
expect(ev.alias).toBe(nodes.a.alias);
|
|
177
|
+
expect(ev.node_id).toBe(nodes.a.nodeId);
|
|
178
|
+
expect(ev.policy).toBe("newest_connection_wins");
|
|
179
|
+
expect(ev.superseded.user_agent).toBe("copy-A");
|
|
180
|
+
expect(ev.current.user_agent).toBe("copy-B");
|
|
181
|
+
expect(ev.superseded.remote).toMatch(/^127\.0\.0\.1:\d+$/);
|
|
182
|
+
expect(ev.current.remote).toMatch(/^127\.0\.0\.1:\d+$/);
|
|
183
|
+
expect(ev.superseded.remote).not.toBe(ev.current.remote);
|
|
184
|
+
const stats = await api(nodes.a.token, "GET", "/api/stats/sse");
|
|
185
|
+
const mine = stats.body.identity_conflicts.filter((c: any) => c.alias === nodes.a.alias);
|
|
186
|
+
expect(mine.length).toBe(1);
|
|
187
|
+
expect(mine[0].network_id).toBe(NET);
|
|
188
|
+
const raw = JSON.stringify(stats.body) + JSON.stringify(ev);
|
|
189
|
+
expect(raw).not.toContain("ntok_");
|
|
190
|
+
expect(raw).not.toContain(nodes.a.token);
|
|
191
|
+
const audit = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'node_identity_conflict' AND target_id = ?1", nodes.a.nodeId);
|
|
192
|
+
expect(audit?.n).toBe(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("offline only when the last node connection leaves; tasks in between are delivered", async () => {
|
|
196
|
+
const n = nodes.a;
|
|
197
|
+
// Copy A (the superseded one) shuts down and says so.
|
|
198
|
+
const fromA = await reportStatus(n, "offline");
|
|
199
|
+
expect(fromA.ok).toBe(true);
|
|
200
|
+
expect(fromA.offline_deferred).toBe(true);
|
|
201
|
+
expect(storedStatus(n.alias)).not.toBe("offline");
|
|
202
|
+
const r = await sendTask(n.alias);
|
|
203
|
+
expect(r.status).toBe(200);
|
|
204
|
+
expect(r.body.ok).toBe(true);
|
|
205
|
+
expect(r.body.error).toBeUndefined();
|
|
206
|
+
await until(() => newTasks(subs.copyB).length >= 2, "second new_task on copy B");
|
|
207
|
+
|
|
208
|
+
// Copy B stops too: it reports offline while its stream is still open, then exits.
|
|
209
|
+
const fromB = await reportStatus(n, "offline");
|
|
210
|
+
expect(fromB.offline_deferred).toBe(true);
|
|
211
|
+
expect(storedStatus(n.alias)).not.toBe("offline");
|
|
212
|
+
subs.copyB.abort();
|
|
213
|
+
await until(() => storedStatus(n.alias) === "offline", "deferred offline applied once the last node stream closed");
|
|
214
|
+
expect((await sendTask(n.alias)).body.error).toBe("alias_offline");
|
|
215
|
+
subs.monitor.abort(); subs.observer.abort();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("#507 what is and is not a conflict", () => {
|
|
220
|
+
test("same instance id = a reconnect of the same process: replaced, no conflict recorded", async () => {
|
|
221
|
+
const n = nodes.b;
|
|
222
|
+
const first = nodeStream(n, { "X-Anet-Instance-Id": "inst-b-1" });
|
|
223
|
+
await first.ready;
|
|
224
|
+
const second = nodeStream(n, { "X-Anet-Instance-Id": "inst-b-1" });
|
|
225
|
+
await second.ready;
|
|
226
|
+
await until(() => first.done, "old stream replaced");
|
|
227
|
+
expect(first.events.find((e) => e.type === "node_connection_superseded")?.reason).toBe("replaced_by_reconnect");
|
|
228
|
+
const stats = await api(n.token, "GET", "/api/stats/sse");
|
|
229
|
+
expect(stats.body.identity_conflicts.filter((c: any) => c.alias === n.alias)).toEqual([]);
|
|
230
|
+
// No conflict → a stop is exactly as before: offline at once.
|
|
231
|
+
const r = await reportStatus(n, "offline");
|
|
232
|
+
expect(r.offline_deferred).toBeUndefined();
|
|
233
|
+
expect(storedStatus(n.alias)).toBe("offline");
|
|
234
|
+
second.abort();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("different instance ids are reported as instance_match=different", async () => {
|
|
238
|
+
const n = nodes.c;
|
|
239
|
+
const first = nodeStream(n, { "X-Anet-Instance-Id": "inst-c-host1" });
|
|
240
|
+
await first.ready;
|
|
241
|
+
const second = subscribe(`/events/${encodeURIComponent(n.alias)}?instance_id=inst-c-host2`, n.token);
|
|
242
|
+
await second.ready;
|
|
243
|
+
await until(() => first.done, "old stream superseded");
|
|
244
|
+
const stats = await api(n.token, "GET", "/api/stats/sse");
|
|
245
|
+
const mine = stats.body.identity_conflicts.filter((c: any) => c.alias === n.alias);
|
|
246
|
+
expect(mine.length).toBe(1);
|
|
247
|
+
expect(mine[0].instance_match).toBe("different");
|
|
248
|
+
expect(mine[0].superseded.instance_id).toBe("inst-c-host1");
|
|
249
|
+
expect(mine[0].current.instance_id).toBe("inst-c-host2");
|
|
250
|
+
second.abort();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("another node's token watching this alias never supersedes the node", async () => {
|
|
254
|
+
const n = nodes.d;
|
|
255
|
+
const own = nodeStream(n);
|
|
256
|
+
await own.ready;
|
|
257
|
+
const watcher = subscribe(`/events/${encodeURIComponent(n.alias)}`, nodes.e.token);
|
|
258
|
+
await watcher.ready;
|
|
259
|
+
await settle();
|
|
260
|
+
expect(own.done).toBe(false);
|
|
261
|
+
expect(own.events.some((e) => e.type === "node_connection_superseded")).toBe(false);
|
|
262
|
+
watcher.abort();
|
|
263
|
+
// And a plain single-node stop (no conflict) is offline immediately, as before.
|
|
264
|
+
const r = await reportStatus(n, "offline");
|
|
265
|
+
expect(r.offline_deferred).toBeUndefined();
|
|
266
|
+
expect(storedStatus(n.alias)).toBe("offline");
|
|
267
|
+
own.abort();
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// #507 — two live processes holding the same node identity (a node directory
|
|
2
|
+
// copied verbatim and started elsewhere: same node token, same alias).
|
|
3
|
+
//
|
|
4
|
+
// push.ts enforces "at most one node-scoped SSE subscriber per (network, alias)",
|
|
5
|
+
// newest connection wins (policy + rationale next to enforceSingleNodeSubscriber).
|
|
6
|
+
// This module is the Hub-side bookkeeping around that:
|
|
7
|
+
//
|
|
8
|
+
// 1. node_identity_conflict — every supersede between connections that are not
|
|
9
|
+
// provably the same process is logged, written to audit_log, pushed to the
|
|
10
|
+
// network observer stream (dashboard) and kept in a small ring that
|
|
11
|
+
// GET /api/stats/sse returns. Carries both connections' remote info; never
|
|
12
|
+
// a token.
|
|
13
|
+
//
|
|
14
|
+
// 2. offline while another copy is still connected — a copy's shutdown
|
|
15
|
+
// report_status(offline) used to mark the node offline even though the other
|
|
16
|
+
// copy was connected, so new tasks got alias_offline. While a conflict is
|
|
17
|
+
// recent for that alias and a node-scoped subscriber is still connected, the
|
|
18
|
+
// offline is DEFERRED: stored status stays as it was, and the offline is
|
|
19
|
+
// applied only when the last node-scoped subscriber leaves (or dropped when the
|
|
20
|
+
// surviving copy reports a fresh status). Outside a conflict window nothing
|
|
21
|
+
// changes: a normal single-node stop marks offline immediately, as before.
|
|
22
|
+
|
|
23
|
+
import { db, logAudit } from "./db.js";
|
|
24
|
+
import {
|
|
25
|
+
hasNodeSubscriber,
|
|
26
|
+
onNodeIdentityConflict,
|
|
27
|
+
onNodeSubscribersDrained,
|
|
28
|
+
pushEvent,
|
|
29
|
+
pushNetworkObserverEvent,
|
|
30
|
+
type NodeIdentityConflict,
|
|
31
|
+
} from "./push.js";
|
|
32
|
+
|
|
33
|
+
/** How long after a conflict an offline report is treated as "maybe the other copy". */
|
|
34
|
+
export const CONFLICT_WINDOW_MS = 10 * 60 * 1000;
|
|
35
|
+
const RING_MAX = 50;
|
|
36
|
+
/** Observer/audit emission is rate-limited per alias (logs are not). A flapping
|
|
37
|
+
* pair supersedes about once a second; one event a minute is enough to see it. */
|
|
38
|
+
const EMIT_EVERY_MS = 60 * 1000;
|
|
39
|
+
|
|
40
|
+
const ring: NodeIdentityConflict[] = [];
|
|
41
|
+
const lastConflictAt = new Map<string, number>();
|
|
42
|
+
const lastEmittedAt = new Map<string, number>();
|
|
43
|
+
const pendingOffline = new Map<string, { resumeId: string; at: number }>();
|
|
44
|
+
|
|
45
|
+
const k = (alias: string, networkId: string | null | undefined) => `${networkId || "global"}:${alias}`;
|
|
46
|
+
|
|
47
|
+
function publicInfo(i: NodeIdentityConflict["current"]) {
|
|
48
|
+
return {
|
|
49
|
+
instance_id: i.instanceId,
|
|
50
|
+
remote: i.remote,
|
|
51
|
+
user_agent: i.userAgent,
|
|
52
|
+
connected_at: new Date(i.connectedAt).toISOString(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function conflictPayload(c: NodeIdentityConflict) {
|
|
57
|
+
return {
|
|
58
|
+
type: "node_identity_conflict",
|
|
59
|
+
alias: c.alias,
|
|
60
|
+
node_id: c.nodeId,
|
|
61
|
+
instance_match: c.instanceMatch,
|
|
62
|
+
policy: "newest_connection_wins",
|
|
63
|
+
superseded: publicInfo(c.superseded),
|
|
64
|
+
current: publicInfo(c.current),
|
|
65
|
+
at: new Date(c.at).toISOString(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function handleConflict(c: NodeIdentityConflict): void {
|
|
70
|
+
const key = k(c.alias, c.networkId);
|
|
71
|
+
lastConflictAt.set(key, c.at);
|
|
72
|
+
ring.push(c);
|
|
73
|
+
if (ring.length > RING_MAX) ring.splice(0, ring.length - RING_MAX);
|
|
74
|
+
const payload = conflictPayload(c);
|
|
75
|
+
console.warn(`[node_identity_conflict] ${c.key} node_id=${c.nodeId ?? "?"} instance_match=${c.instanceMatch} ` +
|
|
76
|
+
`superseded=${c.superseded.remote ?? "?"}(${c.superseded.instanceId ?? "no-instance-id"}) ` +
|
|
77
|
+
`by=${c.current.remote ?? "?"}(${c.current.instanceId ?? "no-instance-id"}) — ` +
|
|
78
|
+
`two processes are using this node's identity (copied node directory?); newest connection kept`);
|
|
79
|
+
const last = lastEmittedAt.get(key) ?? 0;
|
|
80
|
+
if (c.at - last < EMIT_EVERY_MS) return;
|
|
81
|
+
lastEmittedAt.set(key, c.at);
|
|
82
|
+
pushNetworkObserverEvent(c.networkId, payload);
|
|
83
|
+
logAudit(null, null, "node_identity_conflict", "node", c.nodeId ?? c.alias, JSON.stringify(payload), c.current.remote ?? undefined, c.networkId ?? undefined);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function handleDrained(alias: string, networkId: string | null): void {
|
|
87
|
+
const key = k(alias, networkId);
|
|
88
|
+
const pending = pendingOffline.get(key);
|
|
89
|
+
if (!pending) return;
|
|
90
|
+
pendingOffline.delete(key);
|
|
91
|
+
try {
|
|
92
|
+
db.run(
|
|
93
|
+
"UPDATE sessions SET status = 'offline', updated_at = datetime('now') WHERE resume_id = ?1 AND alias = ?2 AND network_id = ?3",
|
|
94
|
+
[pending.resumeId, alias, networkId ?? "default"],
|
|
95
|
+
);
|
|
96
|
+
console.log(`[node_identity_conflict] ${key} last node connection gone → deferred offline applied`);
|
|
97
|
+
pushEvent(alias, { type: "status_update", alias, status: "offline", progress: null, host: null, process_telemetry: null }, networkId);
|
|
98
|
+
} catch (e: any) {
|
|
99
|
+
console.log(`[node_identity_conflict] deferred offline failed for ${key}: ${e?.message || e}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
onNodeIdentityConflict(handleConflict);
|
|
104
|
+
onNodeSubscribersDrained(handleDrained);
|
|
105
|
+
|
|
106
|
+
/** report_status hook. Returns true when an offline report must NOT be stored now
|
|
107
|
+
* (another copy of this node is still connected); it is then applied when the last
|
|
108
|
+
* node-scoped subscriber leaves. Any non-offline report clears a pending offline. */
|
|
109
|
+
export function deferOfflineIfAnotherCopyConnected(
|
|
110
|
+
alias: string,
|
|
111
|
+
networkId: string | null | undefined,
|
|
112
|
+
status: string,
|
|
113
|
+
resumeId: string,
|
|
114
|
+
now = Date.now(),
|
|
115
|
+
): boolean {
|
|
116
|
+
const key = k(alias, networkId);
|
|
117
|
+
if (status !== "offline") {
|
|
118
|
+
pendingOffline.delete(key);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const conflictAt = lastConflictAt.get(key);
|
|
122
|
+
if (conflictAt === undefined || now - conflictAt > CONFLICT_WINDOW_MS) return false;
|
|
123
|
+
if (!hasNodeSubscriber(alias, networkId)) return false;
|
|
124
|
+
pendingOffline.set(key, { resumeId, at: now });
|
|
125
|
+
console.warn(`[node_identity_conflict] ${key} report_status(offline) deferred: a node connection is still open ` +
|
|
126
|
+
`and a second copy was seen ${Math.round((now - conflictAt) / 1000)}s ago`);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Recent conflicts, newest last, optionally limited to one network. */
|
|
131
|
+
export function recentNodeIdentityConflicts(networkId?: string | null) {
|
|
132
|
+
return ring
|
|
133
|
+
.filter((c) => networkId === undefined || c.networkId === networkId)
|
|
134
|
+
.map((c) => ({ network_id: c.networkId, ...conflictPayload(c) }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function __resetNodeIdentityConflictsForTest(): void {
|
|
138
|
+
ring.length = 0;
|
|
139
|
+
lastConflictAt.clear();
|
|
140
|
+
lastEmittedAt.clear();
|
|
141
|
+
pendingOffline.clear();
|
|
142
|
+
}
|