@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130
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 +930 -35
- 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 +129 -0
- package/src/rest-identity.ts +125 -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 +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 +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 +141 -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-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 +3927 -323
- 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,279 @@
|
|
|
1
|
+
// #460(#448 子项)—— 降级节点拒收新任务。
|
|
2
|
+
//
|
|
3
|
+
// 钉住:
|
|
4
|
+
// 1. 新鲜健康报告说某层坏了(app_server.ok=false / tui.ok=false / model_auth revoked|expired)
|
|
5
|
+
// → REST /api/task 409、MCP send_task / retry_task / reassign_task 返回 node_degraded,带层、原因、修法;
|
|
6
|
+
// 定时任务的这一次 run 记 failed + error_code=node_degraded,不建任务行。
|
|
7
|
+
// 2. 不知道 = 放行:没有报告(老 agent-node)、报告过期(> TTL)、各层都 ok、某层没报 —— 与改动前逐字相同的成功路径。
|
|
8
|
+
// 3. 逃生口 force=true 只对用户令牌生效;节点令牌带 force 照样被拒(不让 agent 绕过对另一个 agent 的健康判断)。
|
|
9
|
+
// 4. 老客户端请求形状(不带 force)在健康 / 未知节点上完全照旧 —— 兼容回放。
|
|
10
|
+
//
|
|
11
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/node-health-dispatch-http.test.ts
|
|
12
|
+
// PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
|
|
13
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
14
|
+
import { mkdtempSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
18
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
19
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
20
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
21
|
+
import { db } from "./db.js";
|
|
22
|
+
import { registerTools } from "./tools.js";
|
|
23
|
+
import { clearNodeHealthStore, NODE_HEALTH_TTL_MS, recordNodeHealth } from "./node-health-store.js";
|
|
24
|
+
import { degradedLayers } from "./node-health-guard.js";
|
|
25
|
+
import { runDueScheduledTasks } from "./scheduled-tasks.js";
|
|
26
|
+
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-health-dispatch-"));
|
|
28
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
29
|
+
if (!activeDbPath) throw new Error("node-health-dispatch requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
30
|
+
|
|
31
|
+
let server: any;
|
|
32
|
+
let base = "";
|
|
33
|
+
let ownerToken = "", ownerId = "", networkId = "";
|
|
34
|
+
let peerNodeToken = "", peerNodeTokenId = "";
|
|
35
|
+
const stamp = Date.now();
|
|
36
|
+
const TARGET = `hd-target-${stamp}`;
|
|
37
|
+
const TARGET_NODE = `n_hd_target_${stamp}`;
|
|
38
|
+
const PEER = `hd-peer-${stamp}`;
|
|
39
|
+
const PEER_NODE = `n_hd_peer_${stamp}`;
|
|
40
|
+
let seq = 0;
|
|
41
|
+
const text = (what: string) => `${what} #${++seq}`; // distinct content: the 5-minute dedup must not interfere
|
|
42
|
+
|
|
43
|
+
async function api(token: string, path: string, init?: RequestInit) {
|
|
44
|
+
const res = await fetch(`${base}${path}`, {
|
|
45
|
+
...init,
|
|
46
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) },
|
|
47
|
+
});
|
|
48
|
+
return { status: res.status, body: await res.json() as any };
|
|
49
|
+
}
|
|
50
|
+
const postTask = (token: string, extra: Record<string, unknown> = {}) =>
|
|
51
|
+
api(token, "/api/task", { method: "POST", body: JSON.stringify({ alias: TARGET, task: text("rest"), network_id: networkId, ...extra }) });
|
|
52
|
+
|
|
53
|
+
async function mcpAs(identity: { net: string | null; user: string; alias: string; isNetworkToken: boolean; tokenId: string }) {
|
|
54
|
+
const s = new McpServer({ name: "health-dispatch", version: "1" });
|
|
55
|
+
registerTools(s, undefined, identity.net, identity.user, identity.alias, identity.isNetworkToken, identity.tokenId);
|
|
56
|
+
const client = new Client({ name: "health-dispatch-client", version: "1" });
|
|
57
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
58
|
+
await s.connect(st);
|
|
59
|
+
await client.connect(ct);
|
|
60
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
61
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
62
|
+
const raw = r.content?.[0]?.text ?? "null";
|
|
63
|
+
try { return JSON.parse(raw); } catch { return { raw }; }
|
|
64
|
+
};
|
|
65
|
+
return { call, close: async () => { await client.close(); await s.close(); } };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const DOWN_APP = { bridge: "ok" as const, app_server: { ok: false, rtt_ms: null, last_error: "connect ECONNREFUSED 127.0.0.1:4500" }, model_auth: "ok" as const };
|
|
69
|
+
const HEALTHY = { bridge: "ok" as const, app_server: { ok: true, rtt_ms: 3, last_error: null }, tui: { ok: true, reason: "running" }, model_auth: "ok" as const };
|
|
70
|
+
const setHealth = (h: any, at?: number) => recordNodeHealth(networkId, TARGET, h, at);
|
|
71
|
+
const taskCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE to_name = ?1 AND network_id = ?2", TARGET, networkId)!.n;
|
|
72
|
+
|
|
73
|
+
beforeAll(async () => {
|
|
74
|
+
process.env.COMMHUB_UPLOADS_DIR = join(dir, "uploads");
|
|
75
|
+
process.env.HOST = "127.0.0.1";
|
|
76
|
+
const owner = register(`hd_owner_${stamp}`, "HealthDispatchOwner123!", undefined, "seed");
|
|
77
|
+
ownerToken = owner.token!; ownerId = owner.user!.user_id; networkId = owner.network_id!;
|
|
78
|
+
for (const [nodeId, alias] of [[TARGET_NODE, TARGET], [PEER_NODE, PEER]]) {
|
|
79
|
+
db.run(`INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, owner_user_id) VALUES (?1, ?2, ?2, 'codex-app-server', ?3, ?4)`, [nodeId, alias, networkId, ownerId]);
|
|
80
|
+
db.run(`INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)`, [`r_${nodeId}`, alias, nodeId, networkId]);
|
|
81
|
+
}
|
|
82
|
+
const minted = createNetworkTokenForNode(ownerId, networkId, PEER, PEER_NODE);
|
|
83
|
+
peerNodeToken = minted.token!; peerNodeTokenId = minted.token_id!;
|
|
84
|
+
const mod: any = await import("./server.js");
|
|
85
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
86
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
87
|
+
}, 30_000);
|
|
88
|
+
afterAll(() => { server?.stop?.(true); clearNodeHealthStore(); });
|
|
89
|
+
beforeEach(() => clearNodeHealthStore());
|
|
90
|
+
|
|
91
|
+
describe("#460 which layers count as degraded", () => {
|
|
92
|
+
test("each failing layer is named; ok / missing layers are not", () => {
|
|
93
|
+
expect(degradedLayers(null)).toEqual([]);
|
|
94
|
+
expect(degradedLayers(HEALTHY)).toEqual([]);
|
|
95
|
+
expect(degradedLayers({ bridge: "ok", model_auth: "unknown" })).toEqual([]);
|
|
96
|
+
expect(degradedLayers(DOWN_APP).map(l => [l.layer, l.label])).toEqual([["app_server", "App Server 断开"]]);
|
|
97
|
+
expect(degradedLayers({ tui: { ok: false, reason: "sleep-placeholder" } }).map(l => [l.layer, l.reason, l.label])).toEqual([["tui", "sleep-placeholder", "TUI 被占位(sleep)"]]);
|
|
98
|
+
expect(degradedLayers({ model_auth: "revoked" }).map(l => l.label)).toEqual(["需要重新登录"]);
|
|
99
|
+
expect(degradedLayers({ model_auth: "expired" }).map(l => l.label)).toEqual(["登录已过期"]);
|
|
100
|
+
// 修法只指向本节点重新登录,不教人拷别人的凭据
|
|
101
|
+
expect(degradedLayers({ model_auth: "revoked" })[0].hint).toContain("不要拷别的节点的 auth.json");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("#460 REST /api/task", () => {
|
|
106
|
+
test("unknown health (old agent-node) → dispatched exactly as before", async () => {
|
|
107
|
+
const before = taskCount();
|
|
108
|
+
const r = await postTask(ownerToken);
|
|
109
|
+
expect(r.status).toBe(200);
|
|
110
|
+
expect(r.body.ok).toBe(true);
|
|
111
|
+
expect(taskCount()).toBe(before + 1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("healthy report → dispatched", async () => {
|
|
115
|
+
setHealth(HEALTHY);
|
|
116
|
+
expect((await postTask(ownerToken)).status).toBe(200);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("App Server down → 409 node_degraded with layer, reason and hint; no task row", async () => {
|
|
120
|
+
setHealth(DOWN_APP);
|
|
121
|
+
const before = taskCount();
|
|
122
|
+
const r = await postTask(ownerToken);
|
|
123
|
+
expect(r.status).toBe(409);
|
|
124
|
+
expect(r.body.ok).toBe(false);
|
|
125
|
+
expect(r.body.error).toBe("node_degraded");
|
|
126
|
+
expect(r.body.alias).toBe(TARGET);
|
|
127
|
+
expect(r.body.layers.map((l: any) => l.layer)).toEqual(["app_server"]);
|
|
128
|
+
expect(r.body.layers[0].reason).toContain("ECONNREFUSED");
|
|
129
|
+
expect(r.body.message).toContain("App Server 断开");
|
|
130
|
+
expect(r.body.hint).toContain("force=true");
|
|
131
|
+
expect(r.body.force_allowed).toBe(true);
|
|
132
|
+
expect(typeof r.body.health_observed_ms_ago).toBe("number");
|
|
133
|
+
expect(taskCount()).toBe(before);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("TUI gone and login revoked are refused too, all layers listed", async () => {
|
|
137
|
+
setHealth({ bridge: "ok", app_server: { ok: true, rtt_ms: 2, last_error: null }, tui: { ok: false, reason: "session-missing" }, model_auth: "revoked" });
|
|
138
|
+
const r = await postTask(ownerToken);
|
|
139
|
+
expect(r.status).toBe(409);
|
|
140
|
+
expect(r.body.layers.map((l: any) => l.layer)).toEqual(["tui", "model_auth"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("expired login alone is refused", async () => {
|
|
144
|
+
setHealth({ bridge: "ok", model_auth: "expired" });
|
|
145
|
+
expect((await postTask(ownerToken)).body.error).toBe("node_degraded");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("stale report (older than the TTL) = unknown → dispatched", async () => {
|
|
149
|
+
setHealth(DOWN_APP, Date.now() - NODE_HEALTH_TTL_MS - 1_000);
|
|
150
|
+
expect((await postTask(ownerToken)).status).toBe(200);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("owner escape hatch: force=true with a user token dispatches", async () => {
|
|
154
|
+
setHealth(DOWN_APP);
|
|
155
|
+
const before = taskCount();
|
|
156
|
+
const r = await postTask(ownerToken, { force: true });
|
|
157
|
+
expect(r.status).toBe(200);
|
|
158
|
+
expect(taskCount()).toBe(before + 1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a node token cannot force past another node's health", async () => {
|
|
162
|
+
setHealth(DOWN_APP);
|
|
163
|
+
const r = await api(peerNodeToken, "/api/task", { method: "POST", body: JSON.stringify({ alias: TARGET, task: text("node-force"), force: true }) });
|
|
164
|
+
expect(r.status).toBe(409);
|
|
165
|
+
expect(r.body.error).toBe("node_degraded");
|
|
166
|
+
expect(r.body.force_allowed).toBe(false);
|
|
167
|
+
expect(r.body.hint).not.toContain("force=true");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("old-client request shapes are untouched on a healthy node (compat replay)", async () => {
|
|
171
|
+
setHealth(HEALTHY);
|
|
172
|
+
// the app's send (meta + client_request_id), a plain anet send, and a parent-linked dispatch
|
|
173
|
+
const shapes = [
|
|
174
|
+
{ alias: TARGET, task: text("app"), network_id: networkId, priority: "normal", meta: { client_request_id: `cr_${stamp}_${seq}` } },
|
|
175
|
+
{ alias: TARGET, task: text("anet"), network_id: networkId },
|
|
176
|
+
{ alias: TARGET, task: text("ttl"), network_id: networkId, priority: "high", ttl_seconds: 600 },
|
|
177
|
+
];
|
|
178
|
+
for (const shape of shapes) {
|
|
179
|
+
const r = await api(ownerToken, "/api/task", { method: "POST", body: JSON.stringify(shape) });
|
|
180
|
+
expect(r.status).toBe(200);
|
|
181
|
+
expect(r.body.ok).toBe(true);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("#460 MCP send_task / retry_task / reassign_task", () => {
|
|
187
|
+
const asOwner = () => ({ net: null, user: ownerId, alias: `hd_owner_${stamp}`, isNetworkToken: false, tokenId: "tok_hd_owner_login" });
|
|
188
|
+
const asPeerNode = () => ({ net: networkId, user: ownerId, alias: PEER, isNetworkToken: true, tokenId: peerNodeTokenId });
|
|
189
|
+
|
|
190
|
+
test("send_task from an agent to a degraded node → node_degraded (no queueing); force ignored for node tokens", async () => {
|
|
191
|
+
setHealth(DOWN_APP);
|
|
192
|
+
const peer = await mcpAs(asPeerNode());
|
|
193
|
+
try {
|
|
194
|
+
const before = taskCount();
|
|
195
|
+
const r = await peer.call("send_task", { alias: TARGET, task: text("mcp"), force: true });
|
|
196
|
+
expect(r.ok).toBe(false);
|
|
197
|
+
expect(r.error).toBe("node_degraded");
|
|
198
|
+
expect(r.force_allowed).toBe(false);
|
|
199
|
+
expect(taskCount()).toBe(before);
|
|
200
|
+
} finally { await peer.close(); }
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("send_task: unknown health → ok; owner force → ok", async () => {
|
|
204
|
+
const owner = await mcpAs(asOwner());
|
|
205
|
+
try {
|
|
206
|
+
expect((await owner.call("send_task", { alias: TARGET, task: text("mcp-unknown"), network_id: networkId })).ok).toBe(true);
|
|
207
|
+
setHealth({ bridge: "ok", model_auth: "revoked" });
|
|
208
|
+
expect((await owner.call("send_task", { alias: TARGET, task: text("mcp-refused"), network_id: networkId })).error).toBe("node_degraded");
|
|
209
|
+
expect((await owner.call("send_task", { alias: TARGET, task: text("mcp-forced"), network_id: networkId, force: true })).ok).toBe(true);
|
|
210
|
+
} finally { await owner.close(); }
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("retry_task and reassign_task onto a degraded node are refused", async () => {
|
|
214
|
+
const owner = await mcpAs(asOwner());
|
|
215
|
+
try {
|
|
216
|
+
const sent = await owner.call("send_task", { alias: PEER, task: text("to-peer"), network_id: networkId });
|
|
217
|
+
expect(sent.ok).toBe(true);
|
|
218
|
+
setHealth(DOWN_APP);
|
|
219
|
+
const re = await owner.call("reassign_task", { task_id: sent.task_id ?? sent.message_id, new_alias: TARGET, network_id: networkId });
|
|
220
|
+
expect(re.error).toBe("node_degraded");
|
|
221
|
+
const failed = await owner.call("send_task", { alias: TARGET, task: text("to-fail"), network_id: networkId, force: true });
|
|
222
|
+
const failedId = failed.task_id ?? failed.message_id;
|
|
223
|
+
db.run("UPDATE tasks SET status = 'failed' WHERE task_id = ?1", [failedId]);
|
|
224
|
+
const retry = await owner.call("retry_task", { task_id: failedId, network_id: networkId });
|
|
225
|
+
expect(retry.error).toBe("node_degraded");
|
|
226
|
+
clearNodeHealthStore();
|
|
227
|
+
expect((await owner.call("retry_task", { task_id: failedId, network_id: networkId })).ok).toBe(true);
|
|
228
|
+
} finally { await owner.close(); }
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("#460 scheduled tasks", () => {
|
|
233
|
+
test("a due run on a degraded node fails with node_degraded and creates no task", async () => {
|
|
234
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", {
|
|
235
|
+
method: "POST",
|
|
236
|
+
body: JSON.stringify({ network_id: networkId, name: `hd-schedule-${stamp}`, target_node_id: TARGET_NODE, task: text("scheduled"), timezone: "UTC", schedule: { type: "interval", every_seconds: 60 } }),
|
|
237
|
+
});
|
|
238
|
+
expect(created.status).toBe(201);
|
|
239
|
+
const scheduleId = created.body.schedule.schedule_id;
|
|
240
|
+
setHealth({ bridge: "ok", tui: { ok: false, reason: "pane-dead" }, model_auth: "ok" });
|
|
241
|
+
const before = taskCount();
|
|
242
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 60_000).toISOString(), scheduleId]);
|
|
243
|
+
runDueScheduledTasks();
|
|
244
|
+
const run = db.get<{ status: string; error_code: string | null; error_message: string | null; task_id: string | null }>(
|
|
245
|
+
"SELECT status, error_code, error_message, task_id FROM scheduled_task_runs WHERE schedule_id = ?1 ORDER BY created_at DESC LIMIT 1", scheduleId);
|
|
246
|
+
expect(run?.status).toBe("failed");
|
|
247
|
+
expect(run?.error_code).toBe("node_degraded");
|
|
248
|
+
expect(run?.error_message).toContain("TUI 已退出");
|
|
249
|
+
expect(run?.task_id ?? null).toBeNull();
|
|
250
|
+
expect(taskCount()).toBe(before);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("#460 /api/status carries `degraded` only for degraded nodes", () => {
|
|
255
|
+
const row = async (light: boolean) => {
|
|
256
|
+
const r = await api(ownerToken, `/api/status?network_id=${encodeURIComponent(networkId)}${light ? "&light=1" : ""}`);
|
|
257
|
+
expect(r.status).toBe(200);
|
|
258
|
+
return r.body.sessions.find((x: any) => x.alias === TARGET);
|
|
259
|
+
};
|
|
260
|
+
test("unknown / healthy → no `degraded` key at all (light response bytes unchanged)", async () => {
|
|
261
|
+
expect("degraded" in (await row(true))).toBe(false);
|
|
262
|
+
setHealth(HEALTHY);
|
|
263
|
+
expect("degraded" in (await row(true))).toBe(false);
|
|
264
|
+
expect("degraded" in (await row(false))).toBe(false);
|
|
265
|
+
});
|
|
266
|
+
test("degraded → light and full rows list the failing layers with the app's labels", async () => {
|
|
267
|
+
setHealth({ bridge: "ok", app_server: { ok: false, rtt_ms: null, last_error: "refused" }, model_auth: "revoked" });
|
|
268
|
+
const want = [
|
|
269
|
+
{ layer: "app_server", label: "App Server 断开", reason: "refused" },
|
|
270
|
+
{ layer: "model_auth", label: "需要重新登录", reason: "revoked" },
|
|
271
|
+
];
|
|
272
|
+
expect((await row(true)).degraded).toEqual(want);
|
|
273
|
+
expect((await row(false)).degraded).toEqual(want);
|
|
274
|
+
});
|
|
275
|
+
test("stale → no `degraded`", async () => {
|
|
276
|
+
setHealth(DOWN_APP, Date.now() - NODE_HEALTH_TTL_MS - 1_000);
|
|
277
|
+
expect("degraded" in (await row(true))).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// #460(#448 的子项)—— 降级节点拒收新任务。
|
|
2
|
+
//
|
|
3
|
+
// 现场:节点在 Hub 上 idle / in_flight=0,实际 app-server 端口没人听、TUI 变成 `sleep infinity`、
|
|
4
|
+
// 或登录态 revoked —— 派过去的任务**静默排队**,谁都不知道它永远不会被执行。agent-node .94 起
|
|
5
|
+
// 把这几层报上来(report_status.health,node-health-store.ts 只存内存、10 分钟 TTL);这里在**派发**
|
|
6
|
+
// 那一刻读它:新鲜的健康报告说某一层坏了,就直接拒,带上是哪一层、怎么修。
|
|
7
|
+
//
|
|
8
|
+
// 🔴 只拒「确知坏了」的:没有报告(老 agent-node / claude-code 节点)、报告已过期(>TTL)、
|
|
9
|
+
// 或某层没报(比如非共存节点没有 tui)= 不知道 = 放行。宁可像以前一样排队,
|
|
10
|
+
// 也不能因为缺数据把一台正常的老节点挡在外面。
|
|
11
|
+
// 🔴 只管**新任务派发**(send_task / REST /api/task / 定时任务 / retry / reassign / broadcast)。
|
|
12
|
+
// 回复、send_message、ack 不经过这里 —— 降级节点发出的回复照常送达,发给它的普通消息也照常入库。
|
|
13
|
+
// 逃生口:用户(不是节点令牌)可以带 force=true 强制派发 —— 比如明知 TUI 不在、但 bridge 能干活。
|
|
14
|
+
// 节点令牌不能 force:不让 agent 绕过对另一个 agent 的健康判断。
|
|
15
|
+
|
|
16
|
+
import { readNodeHealth, type NodeHealth } from "./node-health-store.js";
|
|
17
|
+
|
|
18
|
+
export type DegradedLayer = {
|
|
19
|
+
layer: "app_server" | "tui" | "model_auth";
|
|
20
|
+
/** 机器可读的原因:app_server 的错误摘要 / tui 的 reason / model_auth 的状态。 */
|
|
21
|
+
reason: string;
|
|
22
|
+
/** 给人看的一句(中文,与 app 的「降级」徽标同一套措辞)。 */
|
|
23
|
+
label: string;
|
|
24
|
+
hint: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const TUI_LABEL: Record<string, string> = {
|
|
28
|
+
"session-missing": "TUI 会话不在",
|
|
29
|
+
"pane-dead": "TUI 已退出",
|
|
30
|
+
"sleep-placeholder": "TUI 被占位(sleep)",
|
|
31
|
+
"tmux-unavailable": "tmux 不可用",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** 一份健康报告里确知坏掉的层;没报的层不算。 */
|
|
35
|
+
export function degradedLayers(health: NodeHealth | null | undefined): DegradedLayer[] {
|
|
36
|
+
if (!health) return [];
|
|
37
|
+
const out: DegradedLayer[] = [];
|
|
38
|
+
if (health.app_server && health.app_server.ok === false) {
|
|
39
|
+
out.push({
|
|
40
|
+
layer: "app_server",
|
|
41
|
+
reason: (health.app_server.last_error || "unreachable").slice(0, 200),
|
|
42
|
+
label: "App Server 断开",
|
|
43
|
+
hint: "节点的 codex app-server 端口没有响应:在节点所在机器上重启该节点(anet node restart <别名>)",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (health.tui && health.tui.ok === false) {
|
|
47
|
+
out.push({
|
|
48
|
+
layer: "tui",
|
|
49
|
+
reason: health.tui.reason,
|
|
50
|
+
label: TUI_LABEL[health.tui.reason] ?? "TUI 不可用",
|
|
51
|
+
hint: "共存 TUI 不在运行:在节点所在机器上重新拉起该节点的 TUI(anet node restart <别名>)",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (health.model_auth === "revoked" || health.model_auth === "expired") {
|
|
55
|
+
out.push({
|
|
56
|
+
layer: "model_auth",
|
|
57
|
+
reason: health.model_auth,
|
|
58
|
+
label: health.model_auth === "revoked" ? "需要重新登录" : "登录已过期",
|
|
59
|
+
hint: "该节点自己的 CODEX_HOME 需要重新登录(在那台机器上:CODEX_HOME=<节点目录>/codex-home codex login);不要拷别的节点的 auth.json",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type HealthGuardResult =
|
|
66
|
+
| { ok: true; forced?: true }
|
|
67
|
+
| {
|
|
68
|
+
ok: false;
|
|
69
|
+
error: "node_degraded";
|
|
70
|
+
alias: string;
|
|
71
|
+
layers: DegradedLayer[];
|
|
72
|
+
message: string;
|
|
73
|
+
hint: string;
|
|
74
|
+
health_observed_ms_ago: number;
|
|
75
|
+
/** 能不能用 force=true 绕过(只有用户令牌能)。 */
|
|
76
|
+
force_allowed: boolean;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 派发前的健康门。networkId 用与 report_status 写入时同一个键(sessionNetId = 网络 id ?? "default")。
|
|
81
|
+
* force 只在 forceAllowed(用户令牌)时生效。
|
|
82
|
+
*/
|
|
83
|
+
export function assertNodeHealthy(
|
|
84
|
+
alias: string,
|
|
85
|
+
networkId: string | null | undefined,
|
|
86
|
+
opts: { force?: boolean; forceAllowed: boolean; now?: number },
|
|
87
|
+
): HealthGuardResult {
|
|
88
|
+
if (!alias) return { ok: true };
|
|
89
|
+
const hit = readNodeHealth(networkId ?? "default", alias, opts.now);
|
|
90
|
+
if (!hit) return { ok: true };
|
|
91
|
+
const layers = degradedLayers(hit.health);
|
|
92
|
+
if (!layers.length) return { ok: true };
|
|
93
|
+
if (opts.force && opts.forceAllowed) return { ok: true, forced: true };
|
|
94
|
+
const labels = layers.map(l => l.label).join("、");
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
error: "node_degraded",
|
|
98
|
+
alias,
|
|
99
|
+
layers,
|
|
100
|
+
message: `节点 ${alias} 处于降级状态(${labels}),任务不会被执行,已拒绝派发。`,
|
|
101
|
+
hint: layers[0].hint + (opts.forceAllowed ? "。确需强制派发可带 force=true。" : ""),
|
|
102
|
+
health_observed_ms_ago: hit.observed_ms_ago,
|
|
103
|
+
force_allowed: opts.forceAllowed,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// #448 — report_status.health passthrough (layered node health, report-only).
|
|
2
|
+
//
|
|
3
|
+
// Hub-side contract verified here:
|
|
4
|
+
// 1. A well-formed `health` from the node's own token is kept (memory-only) and readable.
|
|
5
|
+
// 2. Malformed `health` never rejects the report — the node stays online, health reads as null.
|
|
6
|
+
// 3. Another node / a user login cannot speak for this node's health.
|
|
7
|
+
// 4. Stale (> TTL) health reads as null, never as "healthy".
|
|
8
|
+
//
|
|
9
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/node-health.db bun test src/node-health-passthrough.test.ts
|
|
10
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
12
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
13
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import { db } from "./db.js";
|
|
15
|
+
import { registerTools } from "./tools.js";
|
|
16
|
+
import { clearNodeHealthStore, NODE_HEALTH_TTL_MS, readNodeHealth, recordNodeHealth } from "./node-health-store.js";
|
|
17
|
+
|
|
18
|
+
const NET = "net_health_t";
|
|
19
|
+
const OWNER = "u_health_owner";
|
|
20
|
+
const NODE_A = "health-node-a";
|
|
21
|
+
const NODE_B = "health-node-b";
|
|
22
|
+
const NODE_A_ID = "node_health_a";
|
|
23
|
+
const NODE_B_ID = "node_health_b";
|
|
24
|
+
const T_A = "tok_health_node_a";
|
|
25
|
+
const T_B = "tok_health_node_b";
|
|
26
|
+
|
|
27
|
+
function cleanup() {
|
|
28
|
+
for (const t of ["sessions", "nodes", "api_tokens", "network_members", "networks"]) {
|
|
29
|
+
try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
|
|
30
|
+
}
|
|
31
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [OWNER]); } catch {}
|
|
32
|
+
clearNodeHealthStore();
|
|
33
|
+
}
|
|
34
|
+
function seed() {
|
|
35
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [OWNER, OWNER]);
|
|
36
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET, NET, OWNER]);
|
|
37
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [OWNER, NET]);
|
|
38
|
+
for (const [id, alias, tok] of [[NODE_A_ID, NODE_A, T_A], [NODE_B_ID, NODE_B, T_B]]) {
|
|
39
|
+
db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'))`, [id, alias, alias, NET, OWNER]);
|
|
40
|
+
db.run(`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)`, [tok, OWNER, NET, `node:${alias}`, `hash_${tok}`, id]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function connectAs(alias: string, tokenId: string) {
|
|
45
|
+
const server = new McpServer({ name: "health-test", version: "1" });
|
|
46
|
+
registerTools(server, undefined, NET, OWNER, alias, true, tokenId);
|
|
47
|
+
const client = new Client({ name: "health-client", version: "1" });
|
|
48
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
49
|
+
await server.connect(st);
|
|
50
|
+
await client.connect(ct);
|
|
51
|
+
const call = async (args: Record<string, unknown>) => {
|
|
52
|
+
const r: any = await client.callTool({ name: "report_status", arguments: args });
|
|
53
|
+
return { isError: r.isError === true, body: JSON.parse(r.content?.[0]?.text ?? "null") };
|
|
54
|
+
};
|
|
55
|
+
return { call, close: async () => { await client.close(); await server.close(); } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const HEALTH = {
|
|
59
|
+
bridge: "ok",
|
|
60
|
+
app_server: { ok: false, rtt_ms: null, last_error: "connection refused" },
|
|
61
|
+
tui: { ok: false, reason: "sleep-placeholder" },
|
|
62
|
+
model_auth: "revoked",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
beforeEach(() => { cleanup(); seed(); });
|
|
66
|
+
afterAll(cleanup);
|
|
67
|
+
|
|
68
|
+
describe("#448 report_status.health passthrough", () => {
|
|
69
|
+
test("node's own token: stored and readable as reported", async () => {
|
|
70
|
+
const a = await connectAs(NODE_A, T_A);
|
|
71
|
+
try {
|
|
72
|
+
const r = await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: HEALTH });
|
|
73
|
+
expect(r.isError).toBe(false);
|
|
74
|
+
expect(r.body.ok).not.toBe(false);
|
|
75
|
+
} finally { await a.close(); }
|
|
76
|
+
expect(readNodeHealth(NET, NODE_A)?.health).toEqual(HEALTH as any);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("malformed health never rejects the report (node stays online); it reads as not reported", async () => {
|
|
80
|
+
const a = await connectAs(NODE_A, T_A);
|
|
81
|
+
try {
|
|
82
|
+
for (const bad of [42, "ok", { app_server: { ok: "yes" }, model_auth: "weird", tui: [] }, { app_server: { ok: true, rtt_ms: NaN, last_error: 5 } }]) {
|
|
83
|
+
const r = await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: bad });
|
|
84
|
+
expect(r.isError).toBe(false);
|
|
85
|
+
}
|
|
86
|
+
} finally { await a.close(); }
|
|
87
|
+
expect(db.get<{ s: string }>("SELECT status AS s FROM sessions WHERE alias = ?1 AND network_id = ?2", NODE_A, NET)?.s).toBe("idle");
|
|
88
|
+
// 最后一份里 app_server.ok 合法、其余两格降级成 null —— 留下的是能证实的部分,不是编出来的。
|
|
89
|
+
expect(readNodeHealth(NET, NODE_A)?.health.app_server).toEqual({ ok: true, rtt_ms: null, last_error: null });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("another node's token cannot speak for this node", async () => {
|
|
93
|
+
const b = await connectAs(NODE_B, T_B);
|
|
94
|
+
try {
|
|
95
|
+
await b.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: HEALTH });
|
|
96
|
+
} finally { await b.close(); }
|
|
97
|
+
expect(readNodeHealth(NET, NODE_A)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("stale health reads as null, never as healthy", () => {
|
|
101
|
+
const t0 = 1_000_000;
|
|
102
|
+
recordNodeHealth(NET, NODE_A, { bridge: "ok", model_auth: "ok" }, t0);
|
|
103
|
+
expect(readNodeHealth(NET, NODE_A, t0 + 1_000)?.observed_ms_ago).toBe(1_000);
|
|
104
|
+
expect(readNodeHealth(NET, NODE_A, t0 + NODE_HEALTH_TTL_MS + 1)).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a report without health leaves the last one (heartbeats from older code paths do not erase it)", async () => {
|
|
108
|
+
const a = await connectAs(NODE_A, T_A);
|
|
109
|
+
try {
|
|
110
|
+
await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: HEALTH });
|
|
111
|
+
await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "working", node_id: NODE_A_ID });
|
|
112
|
+
} finally { await a.close(); }
|
|
113
|
+
expect(readNodeHealth(NET, NODE_A)?.health.model_auth).toBe("revoked");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// #448 —— agent-node 上报的分层健康(`report_status.health`)的 Hub 侧透传。
|
|
2
|
+
//
|
|
3
|
+
// 「在线」只说明桥进程还活着:现场 ~40 台 codex 共存节点里,有的在 Hub 上 idle / in_flight=0,
|
|
4
|
+
// 实际 app-server 端口没人监听、TUI pane 变成 `sleep infinity`、或登录态 "refresh token revoked"。
|
|
5
|
+
// 节点把这几层分开报上来;这里只负责**收下、按节点存最新一份、在 /api/status 和 status_update 里带出去**。
|
|
6
|
+
//
|
|
7
|
+
// 刻意只放内存,不加列:它是**几秒钟一变的观测值**,不是需要跨重启保留的状态 —— Hub 重启后下一次
|
|
8
|
+
// 心跳(≤3 分钟,翻转时立即)就会补上;持久化一份旧观测值反而会在节点死掉后继续说「app_server ok」。
|
|
9
|
+
// 同理,超过 TTL 的那份按「没报」处理(null),不按「健康」处理。
|
|
10
|
+
|
|
11
|
+
import { z } from "zod/v4";
|
|
12
|
+
|
|
13
|
+
export const NODE_HEALTH_TTL_MS = 10 * 60_000;
|
|
14
|
+
|
|
15
|
+
// 🔴 `.catch(undefined)`:这一格是纯诊断信息,**任何形状都不许拒掉整份 report_status**
|
|
16
|
+
// (zod 对象里一个已知字段验证失败 = 整份被拒 = 节点在 Hub 上失联,#1225 的形状)。
|
|
17
|
+
// 合法的透传,其余一律当没报。子对象不 strict:新节点多报的键丢掉即可。
|
|
18
|
+
export const nodeHealthSchema = z.object({
|
|
19
|
+
bridge: z.literal("ok").optional(),
|
|
20
|
+
app_server: z.object({
|
|
21
|
+
ok: z.boolean(),
|
|
22
|
+
rtt_ms: z.number().nullable().catch(null),
|
|
23
|
+
last_error: z.string().max(200).nullable().catch(null),
|
|
24
|
+
}).optional().catch(undefined),
|
|
25
|
+
tui: z.object({
|
|
26
|
+
ok: z.boolean(),
|
|
27
|
+
reason: z.string().max(40),
|
|
28
|
+
}).optional().catch(undefined),
|
|
29
|
+
model_auth: z.enum(["ok", "revoked", "expired", "unknown"]).optional().catch(undefined),
|
|
30
|
+
// #594 —— codex 节点的登录指纹 + 本机另有几台节点持有同一份登录(见下方 codexLoginSchema)。
|
|
31
|
+
// 🔴 线上 schema 故意是 unknown:report_status 的 JSON schema 每个节点每次 tools/list 都要下发,
|
|
32
|
+
// #476 给它定了字节上限(tool-audience-http.test.ts)。严格校验在 recordNodeHealth 里做,效果相同:
|
|
33
|
+
// 不合规 → 这一格当没报,整份 report 照收。
|
|
34
|
+
codex_login: z.unknown().optional(),
|
|
35
|
+
}).optional().catch(undefined);
|
|
36
|
+
|
|
37
|
+
// #594 —— health.codex_login 的严格形状。纯展示/提示,**不是** degraded 层:共用登录今天还能跑,
|
|
38
|
+
// 只是迟早互相顶掉(#1918);派发不因它拒绝。fingerprint 必须是 8 位小写 hex(sha256(refresh_token)
|
|
39
|
+
// 截断,不是凭据)—— 节点误把 token 塞进来也存不下;计数是小的非负整数;codex_home 是节点自己的路径
|
|
40
|
+
// (给客户端拼 `CODEX_HOME=<codex_home> codex login --device-auth`),拒控制字符。
|
|
41
|
+
// 可选格各自降级;必需格(fingerprint / shared_with)不合规 → 整格丢弃。
|
|
42
|
+
export const codexLoginSchema = z.object({
|
|
43
|
+
fingerprint: z.string().regex(/^[0-9a-f]{8}$/),
|
|
44
|
+
shared_with: z.number().int().min(0).max(100_000),
|
|
45
|
+
shared_home_with: z.number().int().min(0).max(100_000).optional().catch(undefined),
|
|
46
|
+
codex_home: z.string().max(1024).regex(/^[^\u0000-\u001f\u007f]*$/).optional().catch(undefined),
|
|
47
|
+
});
|
|
48
|
+
export type CodexLoginHealth = z.infer<typeof codexLoginSchema>;
|
|
49
|
+
|
|
50
|
+
export type NodeHealth = Omit<NonNullable<z.infer<typeof nodeHealthSchema>>, "codex_login"> & { codex_login?: CodexLoginHealth };
|
|
51
|
+
|
|
52
|
+
/** 线上收到的 health → 存的那份:codex_login 按严格形状过一遍,不合规就去掉这一格。 */
|
|
53
|
+
export function normalizeNodeHealth(raw: NonNullable<z.infer<typeof nodeHealthSchema>>): NodeHealth {
|
|
54
|
+
const { codex_login: rawLogin, ...rest } = raw;
|
|
55
|
+
if (rawLogin === undefined) return rest;
|
|
56
|
+
const parsed = codexLoginSchema.safeParse(rawLogin);
|
|
57
|
+
return parsed.success ? { ...rest, codex_login: parsed.data } : rest;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const store = new Map<string, { health: NodeHealth; at: number }>();
|
|
61
|
+
const keyOf = (networkId: string | null | undefined, alias: string) => `${networkId ?? "default"}\0${alias}`;
|
|
62
|
+
|
|
63
|
+
// #431 —— /api/status 的记忆化(status-read-cache.ts)按这两样判断正文还对不对:任何一次上报 → 版本 +1;
|
|
64
|
+
// 任何一份报告过 TTL 的那一刻(degraded / health 字段会消失)→ 由 nodeHealthNextExpiryAt 给出的时刻失效。
|
|
65
|
+
let version = 0;
|
|
66
|
+
export function nodeHealthVersion(): number { return version; }
|
|
67
|
+
/** 最早一份还没过期的报告的过期时刻(ms);一份都没有 → Infinity。 */
|
|
68
|
+
export function nodeHealthNextExpiryAt(now = Date.now()): number {
|
|
69
|
+
let next = Infinity;
|
|
70
|
+
for (const v of store.values()) {
|
|
71
|
+
const at = v.at + NODE_HEALTH_TTL_MS;
|
|
72
|
+
if (at >= now && at < next) next = at;
|
|
73
|
+
}
|
|
74
|
+
return next;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function recordNodeHealth(
|
|
78
|
+
networkId: string | null | undefined, alias: string,
|
|
79
|
+
health: NodeHealth | NonNullable<z.infer<typeof nodeHealthSchema>>, now = Date.now(),
|
|
80
|
+
): void {
|
|
81
|
+
version++;
|
|
82
|
+
store.set(keyOf(networkId, alias), { health: normalizeNodeHealth(health), at: now });
|
|
83
|
+
// 有界:按 TTL 顺手清掉过期的,别让改过名/删掉的节点在内存里攒着。
|
|
84
|
+
if (store.size > 256) {
|
|
85
|
+
for (const [k, v] of store) if (now - v.at > NODE_HEALTH_TTL_MS) store.delete(k);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 最新一份;没有或已过 TTL → null(读的人必须把它当「不知道」,而不是「健康」)。 */
|
|
90
|
+
export function readNodeHealth(
|
|
91
|
+
networkId: string | null | undefined, alias: string, now = Date.now(),
|
|
92
|
+
): { health: NodeHealth; observed_ms_ago: number } | null {
|
|
93
|
+
const hit = store.get(keyOf(networkId, alias));
|
|
94
|
+
if (!hit) return null;
|
|
95
|
+
const age = now - hit.at;
|
|
96
|
+
if (age > NODE_HEALTH_TTL_MS) { store.delete(keyOf(networkId, alias)); return null; }
|
|
97
|
+
return { health: hit.health, observed_ms_ago: Math.max(0, age) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 测试用。 */
|
|
101
|
+
export function clearNodeHealthStore(): void { store.clear(); version++; }
|