@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -10
- package/bin/commhub.ts +92 -11
- package/package.json +8 -7
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/agent-notice.ts +48 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +493 -35
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +270 -1
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +275 -0
- package/src/create-node-validate.ts +288 -0
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +349 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +984 -34
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +329 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2172
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-context-cost-http.test.ts +119 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +72 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +242 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +518 -0
- package/src/requirement-due-reminders.ts +369 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-comment-http.test.ts +165 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +344 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-status-participants-http.test.ts +220 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1761 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +650 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5038 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +413 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +313 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +211 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +195 -0
- package/src/tool-audience.ts +109 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +4955 -300
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// app#225 — 节点规则文件远程读写:走真 MCP in-process transport 的端到端。
|
|
2
|
+
//
|
|
3
|
+
// 验的是 #225 验收里 hub 能验的那几条:
|
|
4
|
+
// 1. 客户端只用 node_id 就能发起读/写;工具的 inputSchema 里**没有**任何
|
|
5
|
+
// 路径/文件名字段(验收第 5 条「不能借路径参数写任意文件」在 hub 侧的形状)。
|
|
6
|
+
// 2. 节点用网络 token + alias 拉到的请求里也没有路径字段;ack 后客户端轮询拿到内容。
|
|
7
|
+
// 3. SEC-1:跨网络节点发不了、别的节点 ack 不了、用户 token 拉不了。
|
|
8
|
+
// 4. 单飞 + 超时回收。
|
|
9
|
+
//
|
|
10
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/rules.db bun test src/rules-file-transport.test.ts
|
|
11
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
13
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
14
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
|
+
import { db } from "./db.js";
|
|
16
|
+
import { registerTools } from "./tools.js";
|
|
17
|
+
|
|
18
|
+
const NET = "net_rules_file";
|
|
19
|
+
const OTHER_NET = "net_rules_file_other";
|
|
20
|
+
const USER = "u_rules_file_owner";
|
|
21
|
+
const NODE_ID = "node_rules_file_claude";
|
|
22
|
+
const NODE_ALIAS = "rules-file-claude";
|
|
23
|
+
const OTHER_NODE_ID = "node_rules_file_other";
|
|
24
|
+
const OTHER_NODE_ALIAS = "rules-file-other";
|
|
25
|
+
const NODE_TOKEN = "tok_rules_file_node";
|
|
26
|
+
const OTHER_NODE_TOKEN = "tok_rules_file_other_node";
|
|
27
|
+
|
|
28
|
+
function cleanup() {
|
|
29
|
+
for (const net of [NET, OTHER_NET]) {
|
|
30
|
+
try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [net]); } catch {}
|
|
31
|
+
try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [net]); } catch {}
|
|
32
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [net]); } catch {}
|
|
33
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [net]); } catch {}
|
|
34
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [net]); } catch {}
|
|
35
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
|
|
36
|
+
}
|
|
37
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function seedWorld() {
|
|
41
|
+
db.run(
|
|
42
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
43
|
+
[USER, USER],
|
|
44
|
+
);
|
|
45
|
+
for (const net of [NET, OTHER_NET]) {
|
|
46
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [net, net, USER]);
|
|
47
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, net]);
|
|
48
|
+
}
|
|
49
|
+
db.run(
|
|
50
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
|
|
51
|
+
[NODE_ID, NODE_ALIAS, NODE_ALIAS, NET],
|
|
52
|
+
);
|
|
53
|
+
db.run(
|
|
54
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
|
|
55
|
+
[OTHER_NODE_ID, OTHER_NODE_ALIAS, OTHER_NODE_ALIAS, OTHER_NET],
|
|
56
|
+
);
|
|
57
|
+
db.run(
|
|
58
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
59
|
+
[NODE_TOKEN, USER, NET, `node:${NODE_ALIAS}`, "hash_rules_file_node"],
|
|
60
|
+
);
|
|
61
|
+
db.run(
|
|
62
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
63
|
+
[OTHER_NODE_TOKEN, USER, OTHER_NET, `node:${OTHER_NODE_ALIAS}`, "hash_rules_file_other"],
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Identity = { net: string; alias: string; isNetworkToken: boolean; tokenId: string };
|
|
68
|
+
const asUser: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_rules_file_user" };
|
|
69
|
+
const asNode: Identity = { net: NET, alias: NODE_ALIAS, isNetworkToken: true, tokenId: NODE_TOKEN };
|
|
70
|
+
const asOtherNode: Identity = { net: OTHER_NET, alias: OTHER_NODE_ALIAS, isNetworkToken: true, tokenId: OTHER_NODE_TOKEN };
|
|
71
|
+
|
|
72
|
+
async function connect(id: Identity) {
|
|
73
|
+
const server = new McpServer({ name: "rules-file-test", version: "1" });
|
|
74
|
+
registerTools(server, undefined, id.net, USER, id.alias, id.isNetworkToken, id.tokenId);
|
|
75
|
+
const client = new Client({ name: "rules-file-client", version: "1" });
|
|
76
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
77
|
+
await server.connect(st);
|
|
78
|
+
await client.connect(ct);
|
|
79
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
80
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
81
|
+
const first = r.content?.[0];
|
|
82
|
+
if (!first || first.type !== "text") throw new Error(`no text result from ${name}`);
|
|
83
|
+
return JSON.parse(first.text);
|
|
84
|
+
};
|
|
85
|
+
const close = async () => { await client.close(); await server.close(); };
|
|
86
|
+
return { client, server, call, close };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
beforeEach(() => { cleanup(); seedWorld(); });
|
|
90
|
+
afterAll(cleanup);
|
|
91
|
+
|
|
92
|
+
describe("app#225 rules file — schema has no path", () => {
|
|
93
|
+
test("read/write tool inputs are node id + content only; no path / file_name / dir field anywhere", async () => {
|
|
94
|
+
// 🔴 不走 client.listTools():main 上 report_status / send_desktop_message /
|
|
95
|
+
// update_node_config / create_node 四个存量工具的 zod 形状让 SDK 的
|
|
96
|
+
// toJSONSchema 抛 `schema._zod`,listTools 整个失败(与本 PR 无关,实验见
|
|
97
|
+
// PR 正文)。这里直接看 McpServer 登记表里的 zod shape —— 它就是 hub 校验
|
|
98
|
+
// 入参用的那份,比 JSON schema 更接近真相。
|
|
99
|
+
const u = await connect(asUser);
|
|
100
|
+
try {
|
|
101
|
+
const reg: Record<string, any> = (u.server as any)._registeredTools;
|
|
102
|
+
const shapeKeys = (name: string): string[] => {
|
|
103
|
+
expect(reg[name], name).toBeDefined();
|
|
104
|
+
const schema = reg[name].inputSchema;
|
|
105
|
+
const shape = schema?.shape ?? schema?._def?.shape?.() ?? schema ?? {};
|
|
106
|
+
return Object.keys(shape).sort();
|
|
107
|
+
};
|
|
108
|
+
// `alias` (app#225 follow-up) names a TARGET node/session, not a file —
|
|
109
|
+
// the no-path regex below still covers it.
|
|
110
|
+
expect(shapeKeys("read_node_rules_file")).toEqual(["alias", "child_node_id", "network_id", "node_id"]);
|
|
111
|
+
expect(shapeKeys("write_node_rules_file")).toEqual(["alias", "child_node_id", "content", "network_id", "node_id"]);
|
|
112
|
+
expect(shapeKeys("get_rules_file_request")).toEqual([]);
|
|
113
|
+
// ack 的 file_name 是节点**回报**它用了哪个文件,不是客户端**指定**用哪个文件。
|
|
114
|
+
expect(shapeKeys("ack_rules_file_request")).toEqual(["content", "error", "exists", "file_name", "request_id", "status"]);
|
|
115
|
+
expect(shapeKeys("get_rules_file_result")).toEqual(["network_id", "request_id"]);
|
|
116
|
+
for (const name of ["read_node_rules_file", "write_node_rules_file", "get_rules_file_request", "get_rules_file_result"]) {
|
|
117
|
+
for (const k of shapeKeys(name)) expect(k, `${name}.${k}`).not.toMatch(/path|dir|file|cwd/i);
|
|
118
|
+
}
|
|
119
|
+
} finally { await u.close(); }
|
|
120
|
+
}, 20_000);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("app#225 rules file — read round trip", () => {
|
|
124
|
+
test("user enqueues read → node pulls (no path in request) → acks content → user polls done", async () => {
|
|
125
|
+
const u = await connect(asUser);
|
|
126
|
+
const n = await connect(asNode);
|
|
127
|
+
try {
|
|
128
|
+
const enq = await u.call("read_node_rules_file", { node_id: NODE_ID });
|
|
129
|
+
expect(enq).toMatchObject({ ok: true, op: "read" });
|
|
130
|
+
expect(enq.request_id).toMatch(/^rf_/);
|
|
131
|
+
|
|
132
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, status: "pending", op: "read" });
|
|
133
|
+
|
|
134
|
+
const pulled = await n.call("get_rules_file_request", {});
|
|
135
|
+
expect(pulled.ok).toBe(true);
|
|
136
|
+
expect(Object.keys(pulled.request).sort()).toEqual(["op", "request_id"]);
|
|
137
|
+
expect(pulled.request).toEqual({ request_id: enq.request_id, op: "read" });
|
|
138
|
+
// 第二次拉:已经 in_progress,不再重复下发
|
|
139
|
+
expect(await n.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
140
|
+
|
|
141
|
+
expect(await n.call("ack_rules_file_request", {
|
|
142
|
+
request_id: enq.request_id, status: "done", file_name: "CLAUDE.md", exists: true, content: "# 规则\n",
|
|
143
|
+
})).toMatchObject({ ok: true, status: "done" });
|
|
144
|
+
|
|
145
|
+
const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
|
|
146
|
+
expect(res).toMatchObject({ ok: true, status: "done", op: "read", file_name: "CLAUDE.md", exists: true, content: "# 规则\n", error: null });
|
|
147
|
+
} finally { await u.close(); await n.close(); }
|
|
148
|
+
}, 20_000);
|
|
149
|
+
|
|
150
|
+
test("write: content travels to the node verbatim; result carries no content back", async () => {
|
|
151
|
+
const u = await connect(asUser);
|
|
152
|
+
const n = await connect(asNode);
|
|
153
|
+
try {
|
|
154
|
+
const body = "# new rules\n\n- 不碰生产 DB\n";
|
|
155
|
+
const enq = await u.call("write_node_rules_file", { node_id: NODE_ID, content: body });
|
|
156
|
+
expect(enq).toMatchObject({ ok: true, op: "write" });
|
|
157
|
+
const pulled = await n.call("get_rules_file_request", {});
|
|
158
|
+
expect(pulled.request).toEqual({ request_id: enq.request_id, op: "write", content: body });
|
|
159
|
+
await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "CLAUDE.md", exists: true });
|
|
160
|
+
const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
|
|
161
|
+
expect(res).toMatchObject({ ok: true, status: "done", op: "write", file_name: "CLAUDE.md" });
|
|
162
|
+
expect(res.content).toBeUndefined();
|
|
163
|
+
} finally { await u.close(); await n.close(); }
|
|
164
|
+
}, 20_000);
|
|
165
|
+
|
|
166
|
+
test("node failure is surfaced with its reason", async () => {
|
|
167
|
+
const u = await connect(asUser);
|
|
168
|
+
const n = await connect(asNode);
|
|
169
|
+
try {
|
|
170
|
+
const enq = await u.call("write_node_rules_file", { node_id: NODE_ID, content: "x" });
|
|
171
|
+
await n.call("get_rules_file_request", {});
|
|
172
|
+
await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "failed", file_name: "CLAUDE.md", error: "EACCES: permission denied" });
|
|
173
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, status: "failed", error: "EACCES: permission denied" });
|
|
174
|
+
} finally { await u.close(); await n.close(); }
|
|
175
|
+
}, 20_000);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("app#225 rules file — SEC-1 and lifecycle", () => {
|
|
179
|
+
test("cross-network node cannot be targeted", async () => {
|
|
180
|
+
const u = await connect(asUser);
|
|
181
|
+
try {
|
|
182
|
+
expect(await u.call("read_node_rules_file", { node_id: OTHER_NODE_ID })).toMatchObject({ ok: false, error: "cross_network_node" });
|
|
183
|
+
expect(db.get<{ c: number }>("SELECT COUNT(*) AS c FROM node_rules_requests WHERE node_id = ?1", OTHER_NODE_ID)?.c).toBe(0);
|
|
184
|
+
} finally { await u.close(); }
|
|
185
|
+
}, 20_000);
|
|
186
|
+
|
|
187
|
+
test("a node in another network can neither pull nor ack this node's request", async () => {
|
|
188
|
+
const u = await connect(asUser);
|
|
189
|
+
const o = await connect(asOtherNode);
|
|
190
|
+
try {
|
|
191
|
+
const enq = await u.call("read_node_rules_file", { node_id: NODE_ID });
|
|
192
|
+
expect(await o.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
193
|
+
expect(await o.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "stolen" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
|
|
194
|
+
expect(db.get<{ status: string }>("SELECT status FROM node_rules_requests WHERE request_id = ?1", enq.request_id)?.status).toBe("pending");
|
|
195
|
+
} finally { await u.close(); await o.close(); }
|
|
196
|
+
}, 20_000);
|
|
197
|
+
|
|
198
|
+
test("user token (not a network token) cannot use the node-side pull/ack", async () => {
|
|
199
|
+
const u = await connect(asUser);
|
|
200
|
+
try {
|
|
201
|
+
expect(await u.call("get_rules_file_request", {})).toMatchObject({ ok: false, error: "network_token_required" });
|
|
202
|
+
expect(await u.call("ack_rules_file_request", { request_id: "rf_nope", status: "done" })).toMatchObject({ ok: false, error: "network_token_required" });
|
|
203
|
+
} finally { await u.close(); }
|
|
204
|
+
}, 20_000);
|
|
205
|
+
|
|
206
|
+
test("single flight: a second request while one is pending is refused; a stale one is superseded", async () => {
|
|
207
|
+
const u = await connect(asUser);
|
|
208
|
+
try {
|
|
209
|
+
const first = await u.call("read_node_rules_file", { node_id: NODE_ID });
|
|
210
|
+
expect(await u.call("read_node_rules_file", { node_id: NODE_ID })).toMatchObject({ ok: false, error: "request_in_flight", existing_request_id: first.request_id });
|
|
211
|
+
db.run("UPDATE node_rules_requests SET created_at = ?1 WHERE request_id = ?2", [Date.now() - 120_000, first.request_id]);
|
|
212
|
+
const second = await u.call("read_node_rules_file", { node_id: NODE_ID });
|
|
213
|
+
expect(second.ok).toBe(true);
|
|
214
|
+
expect(db.get<{ status: string }>("SELECT status FROM node_rules_requests WHERE request_id = ?1", first.request_id)?.status).toBe("timeout");
|
|
215
|
+
} finally { await u.close(); }
|
|
216
|
+
}, 20_000);
|
|
217
|
+
|
|
218
|
+
test("polling a request the node never answered flips it to timeout with a readable reason", async () => {
|
|
219
|
+
const u = await connect(asUser);
|
|
220
|
+
try {
|
|
221
|
+
const enq = await u.call("read_node_rules_file", { node_id: NODE_ID });
|
|
222
|
+
db.run("UPDATE node_rules_requests SET created_at = ?1 WHERE request_id = ?2", [Date.now() - 120_000, enq.request_id]);
|
|
223
|
+
const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
|
|
224
|
+
expect(res).toMatchObject({ ok: true, status: "timeout" });
|
|
225
|
+
expect(res.error).toMatch(/did not answer/);
|
|
226
|
+
} finally { await u.close(); }
|
|
227
|
+
}, 20_000);
|
|
228
|
+
|
|
229
|
+
test("oversized content is refused before anything is queued", async () => {
|
|
230
|
+
const u = await connect(asUser);
|
|
231
|
+
try {
|
|
232
|
+
const r: any = await u.client.callTool({ name: "write_node_rules_file", arguments: { node_id: NODE_ID, content: "x".repeat(256 * 1024 + 1) } });
|
|
233
|
+
expect(r.isError).toBe(true);
|
|
234
|
+
expect(db.get<{ c: number }>("SELECT COUNT(*) AS c FROM node_rules_requests WHERE node_id = ?1", NODE_ID)?.c).toBe(0);
|
|
235
|
+
} finally { await u.close(); }
|
|
236
|
+
}, 20_000);
|
|
237
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { normalizeSessionRuntime } from "./runtime-label";
|
|
3
|
+
|
|
4
|
+
describe("session runtime projection", () => {
|
|
5
|
+
test("preserves exact co-presence and CLI runtimes", () => {
|
|
6
|
+
expect(normalizeSessionRuntime("agent-node:codex-app-server")).toBe("codex-app-server");
|
|
7
|
+
expect(normalizeSessionRuntime("agent-node:grok-build-cli")).toBe("grok-build-cli");
|
|
8
|
+
});
|
|
9
|
+
test("keeps legacy labels backward-compatible", () => {
|
|
10
|
+
expect(normalizeSessionRuntime("agent-node:codex")).toBe("codex-sdk");
|
|
11
|
+
expect(normalizeSessionRuntime("agent-node:claude")).toBe("claude-agent-sdk");
|
|
12
|
+
expect(normalizeSessionRuntime("agent-node:grok")).toBe("grok-build-acp");
|
|
13
|
+
expect(normalizeSessionRuntime("agent-node:opencode")).toBe("opencode-cli");
|
|
14
|
+
expect(normalizeSessionRuntime("claude-code")).toBe("claude-code-cli");
|
|
15
|
+
});
|
|
16
|
+
test("does not invent unknown runtimes", () => {
|
|
17
|
+
expect(normalizeSessionRuntime("agent-node:future-runtime")).toBeNull();
|
|
18
|
+
expect(normalizeSessionRuntime("agent-node:codex-app-server-evil")).toBeNull();
|
|
19
|
+
expect(normalizeSessionRuntime("agent-node:grok-build-cli-preview")).toBeNull();
|
|
20
|
+
expect(normalizeSessionRuntime(null)).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Preserve exact agent-node runtimes before applying legacy family aliases. */
|
|
2
|
+
export function normalizeSessionRuntime(agent: unknown): string | null {
|
|
3
|
+
if (typeof agent !== "string" || agent.length === 0) return null;
|
|
4
|
+
if (agent === "claude-code") return "claude-code-cli";
|
|
5
|
+
const reported = agent.startsWith("agent-node:") ? agent.slice("agent-node:".length) : "";
|
|
6
|
+
const exact: Record<string, string> = {
|
|
7
|
+
"codex-app-server": "codex-app-server",
|
|
8
|
+
"grok-build-cli": "grok-build-cli",
|
|
9
|
+
opencode: "opencode-cli",
|
|
10
|
+
"opencode-cli": "opencode-cli",
|
|
11
|
+
codex: "codex-sdk",
|
|
12
|
+
"codex-sdk": "codex-sdk",
|
|
13
|
+
claude: "claude-agent-sdk",
|
|
14
|
+
"claude-agent-sdk": "claude-agent-sdk",
|
|
15
|
+
grok: "grok-build-acp",
|
|
16
|
+
"grok-build-acp": "grok-build-acp",
|
|
17
|
+
};
|
|
18
|
+
if (exact[reported]) return exact[reported];
|
|
19
|
+
if (agent === "http-api" || agent === "http" || agent === "api") return "http-api";
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { createSSEStream } from "./push.js";
|
|
4
|
+
import { dispatchScheduledOccurrence } from "./scheduled-tasks.js";
|
|
5
|
+
|
|
6
|
+
// A claude-code node's channel server reports status without node_id, so its
|
|
7
|
+
// sessions row has node_id NULL. The scheduler used to look the session up by
|
|
8
|
+
// node_id only, judged every occurrence "queued" and never rang the doorbell:
|
|
9
|
+
// the task sat in the inbox until an unrelated push drained it (20–36 min in
|
|
10
|
+
// production).
|
|
11
|
+
|
|
12
|
+
const NET = "net_sched_doorbell_nodeless";
|
|
13
|
+
const NODE = "n_sched_doorbell_cc";
|
|
14
|
+
const ALIAS = "sched-doorbell-cc";
|
|
15
|
+
|
|
16
|
+
function cleanup(): void {
|
|
17
|
+
for (const table of ["task_events", "inbox", "scheduled_task_runs", "scheduled_tasks", "tasks", "sessions", "nodes"]) {
|
|
18
|
+
try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function seedNode(): void {
|
|
23
|
+
db.run(
|
|
24
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, lifecycle_state)
|
|
25
|
+
VALUES (?1, ?2, ?2, 'claude-code-cli', ?3, 'active')`,
|
|
26
|
+
[NODE, ALIAS, NET],
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function seedSession(status: string, nodeId: string | null): void {
|
|
31
|
+
db.run(
|
|
32
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, agent, updated_at, last_seen_at)
|
|
33
|
+
VALUES (?1, ?2, ?3, ?4, ?5, 'claude-code', datetime('now'), datetime('now'))`,
|
|
34
|
+
[`cc-${crypto.randomUUID()}`, ALIAS, status, nodeId, NET],
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function seedSchedule(): any {
|
|
39
|
+
const scheduleId = `sched_${crypto.randomUUID()}`;
|
|
40
|
+
db.run(
|
|
41
|
+
`INSERT INTO scheduled_tasks
|
|
42
|
+
(schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
|
|
43
|
+
schedule_type, schedule_json, timezone, status, next_run_at)
|
|
44
|
+
VALUES (?1, ?2, 'doorbell', ?3, ?4, 'scheduled doorbell task', 'normal', 'interval',
|
|
45
|
+
'{"type":"interval","every_seconds":3600}', 'UTC', 'active', datetime('now', '+1 hour'))`,
|
|
46
|
+
[scheduleId, NET, NODE, ALIAS],
|
|
47
|
+
);
|
|
48
|
+
return db.get("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", scheduleId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Subscribe like the channel server does and collect decoded SSE frames. */
|
|
52
|
+
function subscribe(): { frames: any[]; close: () => Promise<void> } {
|
|
53
|
+
const res = createSSEStream(ALIAS, NET);
|
|
54
|
+
const reader = res.body!.getReader();
|
|
55
|
+
const decoder = new TextDecoder();
|
|
56
|
+
const frames: any[] = [];
|
|
57
|
+
let buffer = "";
|
|
58
|
+
let open = true;
|
|
59
|
+
(async () => {
|
|
60
|
+
while (open) {
|
|
61
|
+
const { done, value } = await reader.read();
|
|
62
|
+
if (done) break;
|
|
63
|
+
buffer += decoder.decode(value, { stream: true });
|
|
64
|
+
const blocks = buffer.split("\n\n");
|
|
65
|
+
buffer = blocks.pop() || "";
|
|
66
|
+
for (const block of blocks) {
|
|
67
|
+
const line = block.split("\n").find((l) => l.startsWith("data: "));
|
|
68
|
+
if (line) frames.push(JSON.parse(line.slice(6)));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
})().catch(() => {});
|
|
72
|
+
return {
|
|
73
|
+
frames,
|
|
74
|
+
close: async () => {
|
|
75
|
+
open = false;
|
|
76
|
+
await reader.cancel().catch(() => {});
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function settle(): Promise<void> {
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
cleanup();
|
|
87
|
+
seedNode();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterAll(cleanup);
|
|
91
|
+
|
|
92
|
+
describe("scheduler doorbell for sessions reported without node_id", () => {
|
|
93
|
+
test("idle node_id-less session is delivered and its subscriber receives new_task", async () => {
|
|
94
|
+
seedSession("idle", null);
|
|
95
|
+
const sub = subscribe();
|
|
96
|
+
try {
|
|
97
|
+
await settle();
|
|
98
|
+
const result = dispatchScheduledOccurrence(seedSchedule(), new Date().toISOString(), false);
|
|
99
|
+
await settle();
|
|
100
|
+
expect(result.status).toBe("delivered");
|
|
101
|
+
expect(db.get<{ status: string }>("SELECT status FROM scheduled_task_runs WHERE run_id = ?1", result.runId)?.status).toBe("delivered");
|
|
102
|
+
const wake = sub.frames.filter((f) => f.type === "new_task");
|
|
103
|
+
expect(wake).toHaveLength(1);
|
|
104
|
+
expect(wake[0].from).toBe("scheduler");
|
|
105
|
+
expect(wake[0].inbox_count).toBe(1);
|
|
106
|
+
} finally {
|
|
107
|
+
await sub.close();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a live subscriber counts even when the session row says offline", async () => {
|
|
112
|
+
seedSession("offline", null);
|
|
113
|
+
const sub = subscribe();
|
|
114
|
+
try {
|
|
115
|
+
await settle();
|
|
116
|
+
const result = dispatchScheduledOccurrence(seedSchedule(), new Date().toISOString(), false);
|
|
117
|
+
await settle();
|
|
118
|
+
expect(result.status).toBe("delivered");
|
|
119
|
+
expect(sub.frames.filter((f) => f.type === "new_task")).toHaveLength(1);
|
|
120
|
+
} finally {
|
|
121
|
+
await sub.close();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("offline session with nobody listening stays queued", () => {
|
|
126
|
+
seedSession("offline", null);
|
|
127
|
+
const result = dispatchScheduledOccurrence(seedSchedule(), new Date().toISOString(), false);
|
|
128
|
+
expect(result.status).toBe("queued");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// #523 —— 定时任务连续失败:告诉创建者(可选自动暂停)。
|
|
2
|
+
//
|
|
3
|
+
// 钉住:
|
|
4
|
+
// 1. 连续 5 次失败(派出去的任务以 failed 结束)→ 给创建者恰好 1 条通知(别的成员 0 条),第 6–10 次不再发;
|
|
5
|
+
// 通知里有排程名、目标节点、失败次数、最近一次的错误(截断)和怎么暂停。
|
|
6
|
+
// 2. 派发时就失败(目标节点不存在)同样计数。
|
|
7
|
+
// 3. 中间成功一次 → 重新上膛:再连续 5 次失败才发第二条。
|
|
8
|
+
// 4. 一直失败:24 小时内不重发;上次告警早于 24 小时 → 再发一条。
|
|
9
|
+
// 5. 默认不自动暂停;COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS=3 → 第 3 次失败后排程 paused、next_run_at 清空、
|
|
10
|
+
// revision+1,并通知一次。
|
|
11
|
+
// 6. GET /runs 返回 consecutive_failures / failure_alert_threshold / last_failure_alert_at,
|
|
12
|
+
// 任务以 failed 结束的 run 的 error_message 取自任务的回复正文。
|
|
13
|
+
//
|
|
14
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/scheduled-failures-http.test.ts
|
|
15
|
+
// PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
|
|
16
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
17
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
|
+
import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
|
|
19
|
+
import { db } from "./db.js";
|
|
20
|
+
import { dispatchScheduledOccurrence, handleScheduledTaskRequest } from "./scheduled-tasks.js";
|
|
21
|
+
import { SCHEDULE_FAILING_NOTICE_KIND, failureStreak } from "./scheduled-failures.js";
|
|
22
|
+
import { registerTools } from "./tools.js";
|
|
23
|
+
|
|
24
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
25
|
+
if (!activeDbPath) throw new Error("scheduled-failures requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
26
|
+
|
|
27
|
+
const stamp = Date.now();
|
|
28
|
+
const ALIAS = `fail-node-${stamp}`;
|
|
29
|
+
const NODE_ID = `n_fail_${stamp}`;
|
|
30
|
+
let NET = "", creatorId = "", otherId = "";
|
|
31
|
+
const ENV_KEYS = ["COMMHUB_SCHEDULE_FAILURE_NOTICE_RUNS", "COMMHUB_SCHEDULE_FAILURE_RENOTICE_SEC", "COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS"];
|
|
32
|
+
const SAFETY_ERROR = "执行出错: upstream model refused the request (safety system). " + "x".repeat(600);
|
|
33
|
+
|
|
34
|
+
function seedSchedule(name = "前后端分离巡检", targetNodeId = NODE_ID): any {
|
|
35
|
+
const scheduleId = `sched_${crypto.randomUUID()}`;
|
|
36
|
+
db.run(
|
|
37
|
+
`INSERT INTO scheduled_tasks
|
|
38
|
+
(schedule_id, network_id, created_by, name, target_node_id, target_alias, task_content, priority,
|
|
39
|
+
schedule_type, schedule_json, timezone, status, next_run_at)
|
|
40
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'scheduled task', 'normal', 'interval',
|
|
41
|
+
'{"type":"interval","every_seconds":120}', 'Asia/Shanghai', 'active', datetime('now', '+2 minutes'))`,
|
|
42
|
+
[scheduleId, NET, creatorId, name, targetNodeId, ALIAS],
|
|
43
|
+
);
|
|
44
|
+
return reload(scheduleId);
|
|
45
|
+
}
|
|
46
|
+
const reload = (scheduleId: string) => db.get<any>("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", scheduleId);
|
|
47
|
+
|
|
48
|
+
let slot = 0;
|
|
49
|
+
const nextSlot = () => new Date(Date.UTC(2026, 9, 4, 0, 0, 0) + (++slot) * 120_000).toISOString();
|
|
50
|
+
const fire = (schedule: any, now = new Date()) => dispatchScheduledOccurrence(schedule, nextSlot(), false, now);
|
|
51
|
+
|
|
52
|
+
const notices = (userId: string) => db.all<{ title: string; content: string; meta_json: string; from_session: string; severity: string }>(
|
|
53
|
+
"SELECT title, content, meta_json, from_session, severity FROM user_inbox WHERE user_id = ?1 AND network_id = ?2 AND kind = ?3 ORDER BY created_at, message_id",
|
|
54
|
+
userId, NET, SCHEDULE_FAILING_NOTICE_KIND,
|
|
55
|
+
);
|
|
56
|
+
const noticesFor = (userId: string, scheduleId: string) => notices(userId).filter((n) => JSON.parse(n.meta_json).schedule_failing.schedule_id === scheduleId);
|
|
57
|
+
|
|
58
|
+
async function nodeReply(args: Record<string, unknown>): Promise<any> {
|
|
59
|
+
const mcp = new McpServer({ name: "sched-fail", version: "0" }) as any;
|
|
60
|
+
const handlers: Record<string, (a: any) => Promise<any>> = {};
|
|
61
|
+
const original = mcp.tool.bind(mcp);
|
|
62
|
+
mcp.tool = (name: string, ...rest: any[]) => {
|
|
63
|
+
const handler = rest.at(-1);
|
|
64
|
+
if (typeof handler === "function") handlers[name] = handler;
|
|
65
|
+
return original(name, ...rest);
|
|
66
|
+
};
|
|
67
|
+
registerTools(mcp, undefined, NET, creatorId, ALIAS, true, null);
|
|
68
|
+
const out = await handlers.send_reply(args);
|
|
69
|
+
return JSON.parse(out.content[0].text);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 派一次,节点以 failed(或 replied)回复。 */
|
|
73
|
+
async function runOnce(schedule: any, outcome: "failed" | "replied" = "failed"): Promise<{ runId: string; taskId: string }> {
|
|
74
|
+
const r = fire(schedule);
|
|
75
|
+
expect(r.status).toBe("delivered");
|
|
76
|
+
const out = await nodeReply({ alias: "scheduler", text: outcome === "failed" ? SAFETY_ERROR : "done", in_reply_to: r.taskId, status: outcome, from_session: ALIAS });
|
|
77
|
+
expect(out.ok).toBe(true);
|
|
78
|
+
return { runId: r.runId, taskId: r.taskId! };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function getRuns(scheduleId: string): Promise<any> {
|
|
82
|
+
const url = new URL(`http://hub.test/api/scheduled-tasks/${scheduleId}/runs`);
|
|
83
|
+
const res = await handleScheduledTaskRequest({
|
|
84
|
+
req: new Request(url, { method: "GET" }), url,
|
|
85
|
+
auth: { userId: creatorId, networkId: NET, username: "creator" },
|
|
86
|
+
isAdmin: false, isNodeToken: false, scope: { networkId: NET, networkIds: [NET] } as any,
|
|
87
|
+
});
|
|
88
|
+
expect(res!.status).toBe(200);
|
|
89
|
+
return res!.json();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
beforeAll(() => {
|
|
93
|
+
const creator = register(`fail_creator_${stamp}`, "FailCreator123!", undefined, "seed");
|
|
94
|
+
expect(creator.ok).toBe(true);
|
|
95
|
+
NET = creator.network_id!; creatorId = creator.user!.user_id;
|
|
96
|
+
const other = register(`fail_other_${stamp}`, "FailOther123!", undefined, "seed");
|
|
97
|
+
otherId = other.user!.user_id;
|
|
98
|
+
expect(addNetworkMember(NET, otherId, "member", creatorId, { agentAccess: "all" }).ok).toBe(true);
|
|
99
|
+
expect(createNetworkTokenForNode(creatorId, NET, ALIAS).ok).toBe(true);
|
|
100
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, ?2, ?2, 'claude-code', ?3)", [NODE_ID, ALIAS, NET]);
|
|
101
|
+
db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_fail_${stamp}`, ALIAS, NODE_ID, NET]);
|
|
102
|
+
});
|
|
103
|
+
beforeEach(() => { for (const k of ENV_KEYS) delete process.env[k]; });
|
|
104
|
+
afterEach(() => { for (const k of ENV_KEYS) delete process.env[k]; });
|
|
105
|
+
afterAll(() => { db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [NET, SCHEDULE_FAILING_NOTICE_KIND]); });
|
|
106
|
+
|
|
107
|
+
describe("#523 consecutive-failure alert", () => {
|
|
108
|
+
test("5 failed runs → exactly one notice to the creator with name/target/count/error/how to pause; runs 6–10 stay quiet", async () => {
|
|
109
|
+
const s = seedSchedule();
|
|
110
|
+
for (let i = 0; i < 4; i++) await runOnce(s);
|
|
111
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(0);
|
|
112
|
+
await runOnce(s);
|
|
113
|
+
const mine = noticesFor(creatorId, s.schedule_id);
|
|
114
|
+
expect(mine.length).toBe(1);
|
|
115
|
+
expect(noticesFor(otherId, s.schedule_id).length).toBe(0);
|
|
116
|
+
const n = mine[0];
|
|
117
|
+
expect(n.from_session).toBe(ALIAS);
|
|
118
|
+
expect(n.title).toBe("定时任务连续失败");
|
|
119
|
+
expect(n.severity).toBe("error");
|
|
120
|
+
for (const frag of ["前后端分离巡检", ALIAS, "连续 5 次", "safety system", "task_failed", "暂停", s.schedule_id]) expect(n.content).toContain(frag);
|
|
121
|
+
expect(n.content).not.toContain("x".repeat(400)); // 错误被截断
|
|
122
|
+
expect(JSON.parse(n.meta_json).schedule_failing).toEqual({ reason: "failing", schedule_id: s.schedule_id, consecutive_failures: 5, error_code: "task_failed" });
|
|
123
|
+
for (let i = 0; i < 5; i++) await runOnce(s);
|
|
124
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
|
|
125
|
+
expect(reload(s.schedule_id).status).toBe("active"); // 默认不自动暂停
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("dispatch-time failures (target node gone) count too", () => {
|
|
129
|
+
const s = seedSchedule("孤儿排程", `n_missing_${stamp}`);
|
|
130
|
+
for (let i = 0; i < 5; i++) expect(fire(s).status).toBe("failed");
|
|
131
|
+
const mine = noticesFor(creatorId, s.schedule_id);
|
|
132
|
+
expect(mine.length).toBe(1);
|
|
133
|
+
expect(mine[0].content).toContain("target_node_not_found");
|
|
134
|
+
expect(mine[0].content).toContain("The bound node no longer exists");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a success in between re-arms: the next 5 failures notify again", async () => {
|
|
138
|
+
const s = seedSchedule();
|
|
139
|
+
for (let i = 0; i < 5; i++) await runOnce(s);
|
|
140
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
|
|
141
|
+
await runOnce(s, "replied");
|
|
142
|
+
expect(failureStreak(s.schedule_id).consecutiveFailures).toBe(0);
|
|
143
|
+
for (let i = 0; i < 4; i++) await runOnce(s);
|
|
144
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
|
|
145
|
+
await runOnce(s);
|
|
146
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(2);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("still failing: no repeat within 24h; one more once the last alert is older than 24h", async () => {
|
|
150
|
+
const s = seedSchedule();
|
|
151
|
+
for (let i = 0; i < 5; i++) await runOnce(s);
|
|
152
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
|
|
153
|
+
db.run("UPDATE scheduled_tasks SET failure_alert_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 23 * 3_600_000).toISOString(), s.schedule_id]);
|
|
154
|
+
await runOnce(s);
|
|
155
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
|
|
156
|
+
db.run("UPDATE scheduled_tasks SET failure_alert_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 25 * 3_600_000).toISOString(), s.schedule_id]);
|
|
157
|
+
await runOnce(s);
|
|
158
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(2);
|
|
159
|
+
await runOnce(s);
|
|
160
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(2);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("COMMHUB_SCHEDULE_FAILURE_NOTICE_RUNS changes N; no creator → no notice", async () => {
|
|
164
|
+
process.env.COMMHUB_SCHEDULE_FAILURE_NOTICE_RUNS = "2";
|
|
165
|
+
const s = seedSchedule();
|
|
166
|
+
await runOnce(s);
|
|
167
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(0);
|
|
168
|
+
await runOnce(s);
|
|
169
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
|
|
170
|
+
|
|
171
|
+
const orphan = seedSchedule();
|
|
172
|
+
db.run("UPDATE scheduled_tasks SET created_by = NULL WHERE schedule_id = ?1", [orphan.schedule_id]);
|
|
173
|
+
const row = reload(orphan.schedule_id);
|
|
174
|
+
for (let i = 0; i < 3; i++) await runOnce(row);
|
|
175
|
+
expect(noticesFor(creatorId, orphan.schedule_id).length).toBe(0);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("#523 auto-pause (opt-in)", () => {
|
|
180
|
+
test("off by default: 10 failures leave the schedule active", async () => {
|
|
181
|
+
const s = seedSchedule();
|
|
182
|
+
for (let i = 0; i < 10; i++) await runOnce(s);
|
|
183
|
+
const after = reload(s.schedule_id);
|
|
184
|
+
expect(after.status).toBe("active");
|
|
185
|
+
expect(after.next_run_at).not.toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS=3 pauses after the 3rd failure and tells the creator once", async () => {
|
|
189
|
+
process.env.COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS = "3";
|
|
190
|
+
const s = seedSchedule();
|
|
191
|
+
await runOnce(s); await runOnce(s);
|
|
192
|
+
expect(reload(s.schedule_id).status).toBe("active");
|
|
193
|
+
await runOnce(s);
|
|
194
|
+
const after = reload(s.schedule_id);
|
|
195
|
+
expect(after.status).toBe("paused");
|
|
196
|
+
expect(after.next_run_at).toBeNull();
|
|
197
|
+
expect(after.revision).toBe(s.revision + 1);
|
|
198
|
+
const mine = noticesFor(creatorId, s.schedule_id);
|
|
199
|
+
expect(mine.length).toBe(1);
|
|
200
|
+
expect(mine[0].title).toBe("定时任务连续失败,已自动暂停");
|
|
201
|
+
expect(JSON.parse(mine[0].meta_json).schedule_failing.reason).toBe("paused");
|
|
202
|
+
// 手动 run-now 再失败两次到 N=5:同一段失败,已经通知过 → 不再发
|
|
203
|
+
await runOnce(after); await runOnce(after);
|
|
204
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("#523 GET /runs", () => {
|
|
209
|
+
test("exposes consecutive_failures and the failed task's reason as error_message", async () => {
|
|
210
|
+
const s = seedSchedule();
|
|
211
|
+
await runOnce(s, "replied");
|
|
212
|
+
const fails = [await runOnce(s), await runOnce(s)];
|
|
213
|
+
const body = await getRuns(s.schedule_id);
|
|
214
|
+
expect(body.ok).toBe(true);
|
|
215
|
+
expect(body.consecutive_failures).toBe(2);
|
|
216
|
+
expect(body.failure_alert_threshold).toBe(5);
|
|
217
|
+
expect(body.last_failure_alert_at).toBeNull();
|
|
218
|
+
expect(body.runs.length).toBe(3);
|
|
219
|
+
const failed = body.runs.find((r: any) => r.run_id === fails[1].runId);
|
|
220
|
+
expect(failed.status).toBe("failed");
|
|
221
|
+
expect(failed.error_code).toBe("task_failed");
|
|
222
|
+
expect(failed.error_message).toContain("safety system");
|
|
223
|
+
expect(failed.error_message.length).toBeLessThanOrEqual(500);
|
|
224
|
+
for (const k of ["scheduled_for", "created_at", "completed_at", "task_id"]) expect(k in failed).toBe(true);
|
|
225
|
+
const ok = body.runs.find((r: any) => r.status === "replied");
|
|
226
|
+
expect(ok.error_message).toBeNull();
|
|
227
|
+
|
|
228
|
+
for (let i = 0; i < 3; i++) await runOnce(s);
|
|
229
|
+
const again = await getRuns(s.schedule_id);
|
|
230
|
+
expect(again.consecutive_failures).toBe(5);
|
|
231
|
+
expect(typeof again.last_failure_alert_at).toBe("string");
|
|
232
|
+
});
|
|
233
|
+
});
|