@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.41

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.
Files changed (122) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +84 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-fallback.test.ts +267 -0
  10. package/src/api-nodes-shape.test.ts +107 -13
  11. package/src/auth.ts +93 -14
  12. package/src/avatar-validate.test.ts +163 -0
  13. package/src/avatar-validate.ts +103 -0
  14. package/src/boot-side-effect.test.ts +124 -0
  15. package/src/config-apply-sec1.test.ts +107 -0
  16. package/src/config-apply-validate.test.ts +92 -0
  17. package/src/config-apply-validate.ts +81 -3
  18. package/src/create-node-tool-schema.test.ts +39 -0
  19. package/src/create-node-validate.test.ts +80 -1
  20. package/src/create-node-validate.ts +49 -5
  21. package/src/create-node.ts +51 -1
  22. package/src/dashboard-slash-routing-http.test.ts +103 -0
  23. package/src/db-adapter-guard.test.ts +85 -0
  24. package/src/db-adapter.ts +64 -23
  25. package/src/db-timestamp.test.ts +38 -0
  26. package/src/db-timestamp.ts +15 -0
  27. package/src/db.ts +654 -33
  28. package/src/delivered-without-push.test.ts +140 -0
  29. package/src/desktop-message-seam.test.ts +150 -0
  30. package/src/desktop-message.test.ts +368 -0
  31. package/src/ext-token-shared.test.ts +60 -0
  32. package/src/ext-validation-regression.test.ts +131 -0
  33. package/src/external-schedule-edits-http.test.ts +316 -0
  34. package/src/external-schedule-edits.ts +314 -0
  35. package/src/file-download-authz-dev-open.test.ts +135 -0
  36. package/src/file-download-authz.test.ts +403 -0
  37. package/src/file-download-date-bucket.test.ts +436 -0
  38. package/src/file-network-scope-dev-open.test.ts +97 -0
  39. package/src/file-network-scope.test.ts +731 -0
  40. package/src/health-redaction.test.ts +637 -0
  41. package/src/inbox-count-sse.test.ts +310 -0
  42. package/src/inbox-count.ts +29 -0
  43. package/src/index.ts +15 -2187
  44. package/src/lifecycle-guard.ts +50 -0
  45. package/src/list-host-supervisors.test.ts +314 -0
  46. package/src/mcp-write-network-resolution.test.ts +429 -0
  47. package/src/network-name-http.test.ts +71 -0
  48. package/src/network-scope.ts +101 -0
  49. package/src/node-attrs-validate.ts +88 -0
  50. package/src/node-attrs.test.ts +213 -0
  51. package/src/node-id-alias.test.ts +147 -0
  52. package/src/node-lifecycle-controllable.test.ts +64 -0
  53. package/src/node-lifecycle-controllable.ts +51 -0
  54. package/src/observer-avatar-http.test.ts +424 -0
  55. package/src/observer-push.test.ts +252 -0
  56. package/src/package-version-consistency.test.ts +25 -0
  57. package/src/peer-reply-atomic.test.ts +455 -0
  58. package/src/peer-reply-capability.test.ts +70 -0
  59. package/src/probe-validate.test.ts +221 -0
  60. package/src/probe-validate.ts +104 -0
  61. package/src/probe.test.ts +80 -0
  62. package/src/probe.ts +200 -0
  63. package/src/push.ts +163 -7
  64. package/src/redact-tokens.test.ts +65 -0
  65. package/src/redact-tokens.ts +40 -0
  66. package/src/reply-target-derivation.test.ts +96 -0
  67. package/src/require-explicit-test-db.ts +9 -0
  68. package/src/resolve-port.test.ts +57 -0
  69. package/src/resolve-port.ts +44 -0
  70. package/src/response-charset.test.ts +130 -0
  71. package/src/rest-explicit-columns-http.test.ts +212 -0
  72. package/src/rest-identity.test.ts +129 -0
  73. package/src/rest-identity.ts +100 -0
  74. package/src/rest-projections.ts +67 -0
  75. package/src/rest-write-network-resolution.test.ts +133 -0
  76. package/src/runtime-label.test.ts +22 -0
  77. package/src/runtime-label.ts +21 -0
  78. package/src/scheduled-run-terminal.test.ts +268 -0
  79. package/src/scheduled-tasks-http.test.ts +479 -0
  80. package/src/scheduled-tasks.ts +549 -0
  81. package/src/send-reply-agent-warning.test.ts +202 -0
  82. package/src/send-reply-alias-optional.test.ts +131 -0
  83. package/src/send-reply-attachments.test.ts +437 -0
  84. package/src/server.ts +3664 -0
  85. package/src/shared/external-schedule-contract.ts +60 -0
  86. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  87. package/src/shared/probe-host-allowlist.ts +128 -0
  88. package/src/side-thread-command-transport.test.ts +302 -0
  89. package/src/side-thread-command-transport.ts +318 -0
  90. package/src/side-thread-http-integration.test.ts +229 -0
  91. package/src/side-thread-http.ts +504 -0
  92. package/src/side-thread-production.test.ts +72 -0
  93. package/src/side-thread-production.ts +118 -0
  94. package/src/side-thread.test.ts +1067 -0
  95. package/src/side-thread.ts +2495 -0
  96. package/src/skillhub-http.test.ts +88 -0
  97. package/src/start-node.test.ts +109 -0
  98. package/src/stop-delete-node.test.ts +952 -0
  99. package/src/task-auth-origin-http.test.ts +87 -0
  100. package/src/task-auth-origin.test.ts +24 -0
  101. package/src/task-auth-origin.ts +7 -0
  102. package/src/task-consumption.test.ts +471 -0
  103. package/src/task-diagnostic-http.test.ts +145 -0
  104. package/src/task-diagnostic.test.ts +115 -0
  105. package/src/task-diagnostic.ts +118 -0
  106. package/src/task-idempotency-mcp.test.ts +142 -0
  107. package/src/task-idempotency.test.ts +60 -0
  108. package/src/task-idempotency.ts +75 -0
  109. package/src/task-lifecycle-watcher.test.ts +200 -0
  110. package/src/task-lifecycle-watcher.ts +49 -0
  111. package/src/task-network-resolution-http.test.ts +137 -0
  112. package/src/tasks-pagination-http.test.ts +137 -0
  113. package/src/tools.ts +2994 -191
  114. package/src/update-provider.test.ts +234 -0
  115. package/src/upload-early-reject-guard.test.ts +155 -0
  116. package/src/uploads-http.test.ts +92 -14
  117. package/src/uploads.test.ts +66 -0
  118. package/src/uploads.ts +130 -5
  119. package/src/user-inbox-read-http.test.ts +220 -0
  120. package/src/user-inbox-schema.test.ts +53 -0
  121. package/src/vault.test.ts +200 -0
  122. package/src/vault.ts +156 -0
@@ -0,0 +1,133 @@
1
+ // #819 — `resolveRestWriteNetworkId` 是 network-scope.ts 里唯一没有任何测试点名的
2
+ // 导出函数。同文件的五个兄弟各有 1-2 个测试文件点名它们:
3
+ //
4
+ // resolveRestNetworkScope 1 canRestWriteNetwork 1
5
+ // singleNetworkId 2 addNetworkScope 1
6
+ // getUserNetworkIds 1 resolveRestWriteNetworkId 🔴 0
7
+ //
8
+ // 而它决定的是**一次 REST 写入落到哪个网络**。它的 docstring 说明了一条不显然的
9
+ // 规则:管理员的**读**作用域按设计是全局的(networkIds=null),所以它本身表达不了
10
+ // 「这个管理员恰好只属于一个网络」;而**写**不能继承这个歧义 —— 只有在确实只有
11
+ // 一个成员关系时才用它,0 个或 ≥2 个都必须显式指定网络。
12
+ //
13
+ // 🔴 这条规则的两半都要钉:
14
+ // - **放行那半**(admin + 恰好 1 个成员关系 → 用它)如果坏了,会退化成「管理员
15
+ // 写任何东西都要显式带 network_id」——很吵,但**安全**,所以没人会急着修;
16
+ // - **收紧那半**(admin + 0 或 ≥2 → null)如果坏了,一次写入会**落到一个没被
17
+ // 指定的网络里**,而调用方看到的是成功。
18
+ //
19
+ // 只写反向断言是不够的:一个「永远返回 null」的实现能通过所有「收紧」用例。
20
+ // 所以每一条收紧断言都配了正控。
21
+ //
22
+ // Run: COMMHUB_DB=/tmp/819-rest-write-scope.db bun test src/rest-write-network-resolution.test.ts
23
+
24
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
25
+ import { db } from "./db.js";
26
+ import { resolveRestNetworkScope, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
27
+
28
+ const NET_A = "net_819_a";
29
+ const NET_B = "net_819_b";
30
+ const U_SINGLE = "u819_single"; // 只属于 NET_A
31
+ const U_MULTI = "u819_multi"; // 属于 NET_A + NET_B
32
+ const U_NONE = "u819_none"; // 一个都不属于
33
+ const ALL_USERS = [U_SINGLE, U_MULTI, U_NONE];
34
+
35
+ function cleanup() {
36
+ try { db.run("DELETE FROM network_members WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
37
+ try { db.run("DELETE FROM networks WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
38
+ for (const u of ALL_USERS) {
39
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
40
+ }
41
+ }
42
+
43
+ function seed() {
44
+ for (const u of ALL_USERS) {
45
+ db.run(
46
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
47
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
48
+ [u, u],
49
+ );
50
+ }
51
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_A, U_SINGLE]);
52
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_B, U_MULTI]);
53
+ const member = (u: string, net: string, role: string) =>
54
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [u, net, role]);
55
+ member(U_SINGLE, NET_A, "owner");
56
+ member(U_MULTI, NET_A, "member");
57
+ member(U_MULTI, NET_B, "owner");
58
+ }
59
+
60
+ beforeEach(() => { cleanup(); seed(); });
61
+ afterAll(() => { cleanup(); });
62
+
63
+ /** 管理员的读作用域:全局(networkIds=null),这正是歧义的来源。 */
64
+ const ADMIN_SCOPE: RestNetworkScope = { networkIds: null };
65
+ const scopeOf = (...ids: string[]): RestNetworkScope => ({ networkIds: ids });
66
+ const ctx = (userId: string) => ({ userId, networkId: null });
67
+
68
+ describe("#819 resolveRestWriteNetworkId — 作用域里就能确定时,直接用它", () => {
69
+ test("作用域恰好一个网络 → 用那一个(与是不是 admin 无关)", () => {
70
+ expect(resolveRestWriteNetworkId(scopeOf(NET_A), ctx(U_MULTI), false)).toBe(NET_A);
71
+ expect(resolveRestWriteNetworkId(scopeOf(NET_A), ctx(U_MULTI), true)).toBe(NET_A);
72
+ });
73
+
74
+ test("作用域两个网络 → 歧义,null(即使调用者是 admin)", () => {
75
+ expect(resolveRestWriteNetworkId(scopeOf(NET_A, NET_B), ctx(U_MULTI), true)).toBeNull();
76
+ });
77
+
78
+ // 🔴 这一条我一开始断错了,留下来当记录:我以为「作用域为空数组 → null」,
79
+ // 实测是 NET_A。原因是 `[]` 过不了 singleNetworkId,于是落到 admin 的成员关系
80
+ // 回退分支,而 U_SINGLE 恰好只有一个成员关系。
81
+ //
82
+ // 而 `networkIds: []` 在 resolveRestNetworkScope 里有确切含义:`:46` 那一行,
83
+ // **非 admin 请求了一个自己没有角色的网络** —— 它带着 `denied: "access denied
84
+ // to requested network"`。也就是说 `[]` 不是「没指定」,是「明确被拒」。
85
+ //
86
+ // 现在**没有**问题,因为那一行只对非 admin 产生(admin 在 `:42` 就提前返回
87
+ // networkIds:null 了),而非 admin 走到这里必然 null —— 见下一条。
88
+ //
89
+ // 但它是一个潜伏的形状:如果将来有任何路径让 admin 拿到 networkIds: [],
90
+ // 这次写入会**忽略那条明确的拒绝**,回退到他的单一成员关系。所以两条都钉住:
91
+ // 当前行为,以及那条让它安全的前提。
92
+ test("作用域为空数组 + admin + 恰好一个成员关系 → 回退到成员关系(当前行为,记录)", () => {
93
+ expect(resolveRestWriteNetworkId(scopeOf(), ctx(U_SINGLE), true)).toBe(NET_A);
94
+ });
95
+
96
+ test("🔴 让上一条安全的前提:空数组作用域只由非 admin 产生,而非 admin → null", () => {
97
+ expect(resolveRestWriteNetworkId(scopeOf(), ctx(U_SINGLE), false)).toBeNull();
98
+ // 前提本身也断一次:resolveRestNetworkScope 对 admin 从不产出空数组。
99
+ const adminScope = resolveRestNetworkScope(NET_B, ctx(U_NONE), true);
100
+ expect(adminScope.networkIds).toBeNull();
101
+ });
102
+ });
103
+
104
+ describe("#819 admin 的全局读作用域不能替写入决定网络", () => {
105
+ test("🔴 admin + 恰好 1 个成员关系 → 用那一个(放行那半)", () => {
106
+ expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_SINGLE), true)).toBe(NET_A);
107
+ });
108
+
109
+ test("🔴 admin + 2 个成员关系 → null,必须显式指定(收紧那半)", () => {
110
+ expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_MULTI), true)).toBeNull();
111
+ });
112
+
113
+ test("🔴 admin + 0 个成员关系 → null(不能因为读是全局的就随便挑一个)", () => {
114
+ expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_NONE), true)).toBeNull();
115
+ });
116
+
117
+ test("非 admin + 全局作用域 → null(这种组合本身就不该发生,fail closed)", () => {
118
+ // 正控在上面那条「admin + 单成员 → NET_A」:如果实现改成永远 null,那条会红。
119
+ expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_SINGLE), false)).toBeNull();
120
+ });
121
+
122
+ test("没有 authCtx + 全局作用域 → null", () => {
123
+ expect(resolveRestWriteNetworkId(ADMIN_SCOPE, null, true)).toBeNull();
124
+ });
125
+ });
126
+
127
+ describe("#819 作用域优先于成员关系回退", () => {
128
+ test("作用域说 NET_B,而该用户唯一的成员关系是 NET_A → 结果是 NET_B", () => {
129
+ // 归属由作用域决定,不由「他碰巧属于哪个网络」决定。
130
+ // 如果实现把两者的优先级搞反,这条会返回 NET_A。
131
+ expect(resolveRestWriteNetworkId(scopeOf(NET_B), ctx(U_SINGLE), true)).toBe(NET_B);
132
+ });
133
+ });
@@ -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,268 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { db, chainReplyToParent } from "./db.js";
4
+ import { dispatchScheduledOccurrence } from "./scheduled-tasks.js";
5
+ import { registerTools } from "./tools.js";
6
+ import { patrolExpiredTasks } from "./server.js";
7
+
8
+ const NET = "net_690_scheduler_terminal";
9
+ const USER = "u_690_owner";
10
+ const NODE = "n_690_grok";
11
+ const ALIAS = "A站GrokTUI-690";
12
+
13
+ type Handler = (args: any, extra?: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
14
+
15
+ function cleanup(): void {
16
+ for (const table of ["task_events", "inbox", "scheduled_task_runs", "scheduled_tasks", "tasks", "sessions", "nodes", "network_members", "networks", "users"]) {
17
+ try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
18
+ }
19
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
20
+ }
21
+
22
+ function seedIdentity(): void {
23
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))", [USER, USER]);
24
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
25
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
26
+ db.run(
27
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, lifecycle_state)
28
+ VALUES (?1, ?2, ?2, 'grok-build-acp', ?3, 'active')`,
29
+ [NODE, ALIAS, NET],
30
+ );
31
+ db.run(
32
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
33
+ VALUES ('r_690', ?1, 'idle', ?2, ?3, datetime('now'), datetime('now'))`,
34
+ [ALIAS, NODE, NET],
35
+ );
36
+ }
37
+
38
+ function handlers(): Record<string, Handler> {
39
+ const server = new McpServer({ name: "test690", version: "0" }) as any;
40
+ const result: Record<string, Handler> = {};
41
+ const original = server.tool.bind(server);
42
+ server.tool = (name: string, ...args: any[]) => {
43
+ const handler = args.at(-1);
44
+ if (typeof handler === "function") result[name] = handler;
45
+ return original(name, ...args);
46
+ };
47
+ registerTools(server, undefined, NET, USER, USER, false, null);
48
+ return result;
49
+ }
50
+
51
+ async function call(handler: Handler, args: any): Promise<any> {
52
+ const reply = await handler(args);
53
+ return JSON.parse(reply.content[0].text);
54
+ }
55
+
56
+ function seedScheduledTask(status = "delivered", suffix = crypto.randomUUID()): { taskId: string; runId: string; scheduleId: string } {
57
+ const taskId = `task_${suffix}`;
58
+ const runId = `srun_${suffix}`;
59
+ const scheduleId = `sched_${suffix}`;
60
+ const meta = JSON.stringify({ scheduled_task_id: scheduleId, scheduled_run_id: runId, auth_origin: "hub_scheduler" });
61
+ db.run(
62
+ `INSERT INTO scheduled_tasks
63
+ (schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
64
+ schedule_type, schedule_json, timezone, status, next_run_at)
65
+ VALUES (?1, ?2, 'test690', ?3, ?4, 'test690 task', 'normal', 'interval',
66
+ '{"type":"interval","every_seconds":3600}', 'UTC', 'active', datetime('now', '+1 hour'))`,
67
+ [scheduleId, NET, NODE, ALIAS],
68
+ );
69
+ db.run(
70
+ `INSERT INTO tasks
71
+ (task_id, from_name, to_node_id, to_name, priority, status, content, requires_response,
72
+ created_at, delivered_at, expires_at, network_id, meta_json)
73
+ VALUES (?1, 'scheduler', ?2, ?3, 'normal', ?4, 'test690 task', 'reply',
74
+ datetime('now'), datetime('now'), datetime('now', '+1 hour'), ?5, ?6)`,
75
+ [taskId, NODE, ALIAS, status, NET, meta],
76
+ );
77
+ db.run(
78
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
79
+ VALUES (?1, ?1, ?2, ?3, 'task', 'normal', 'test690 task', 'scheduler', 'reply', ?4, ?5)`,
80
+ [taskId, ALIAS, NODE, NET, meta],
81
+ );
82
+ db.run(
83
+ `INSERT INTO scheduled_task_runs (run_id, schedule_id, network_id, scheduled_for, task_id, status, completed_at)
84
+ VALUES (?1, ?2, ?3, datetime('now'), ?4, ?5, datetime('now'))`,
85
+ [runId, scheduleId, NET, taskId, status],
86
+ );
87
+ return { taskId, runId, scheduleId };
88
+ }
89
+
90
+ const readRun = (runId: string) => db.get<any>(
91
+ "SELECT run_id, task_id, network_id, status, error_code, completed_at FROM scheduled_task_runs WHERE run_id = ?1",
92
+ runId,
93
+ );
94
+
95
+ beforeEach(() => { cleanup(); seedIdentity(); });
96
+ afterAll(cleanup);
97
+
98
+ describe("#690 scheduled run follows the real task lifecycle", () => {
99
+ test("dispatch is open until the task reaches a terminal state", () => {
100
+ const scheduleId = `sched_dispatch_${crypto.randomUUID()}`;
101
+ const row: any = {
102
+ schedule_id: scheduleId,
103
+ network_id: NET,
104
+ name: "dispatch open",
105
+ target_node_id: NODE,
106
+ target_alias: ALIAS,
107
+ task_content: "dispatch open",
108
+ priority: "normal",
109
+ schedule_type: "interval",
110
+ schedule_json: JSON.stringify({ type: "interval", every_seconds: 3600 }),
111
+ timezone: "UTC",
112
+ overlap_policy: "skip",
113
+ misfire_policy: "catch_up_once",
114
+ status: "active",
115
+ next_run_at: new Date(Date.now() + 3600_000).toISOString(),
116
+ last_run_at: null,
117
+ revision: 1,
118
+ };
119
+ db.run(
120
+ `INSERT INTO scheduled_tasks
121
+ (schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
122
+ schedule_type, schedule_json, timezone, overlap_policy, misfire_policy, status, next_run_at)
123
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)`,
124
+ [scheduleId, NET, row.name, NODE, ALIAS, row.task_content, row.priority, row.schedule_type,
125
+ row.schedule_json, row.timezone, row.overlap_policy, row.misfire_policy, row.status, row.next_run_at],
126
+ );
127
+ const dispatched = dispatchScheduledOccurrence(row, new Date().toISOString(), false);
128
+ const run = readRun(dispatched.runId);
129
+ expect(["delivered", "queued"]).toContain(run.status);
130
+ expect(run.completed_at).toBeNull();
131
+ });
132
+
133
+ test("send_reply terminalizes the exact bound run", async () => {
134
+ const { taskId, runId } = seedScheduledTask();
135
+ const reply = await call(handlers().send_reply, {
136
+ alias: ALIAS,
137
+ text: "verified",
138
+ in_reply_to: taskId,
139
+ status: "replied",
140
+ network_id: NET,
141
+ attachments: [{ type: "file", file_id: "file_690_reply", name: "evidence.json", size: 12, mime: "application/json" }],
142
+ });
143
+ expect(reply.ok).toBe(true);
144
+ expect(readRun(runId).status).toBe("replied");
145
+ expect(readRun(runId).completed_at).toBeTruthy();
146
+ });
147
+
148
+ test("task and run terminal transition roll back together on an injected run-write failure", async () => {
149
+ const { taskId, runId } = seedScheduledTask();
150
+ db.exec(
151
+ `CREATE TRIGGER test690_fail_run_terminal
152
+ BEFORE UPDATE OF status ON scheduled_task_runs
153
+ WHEN NEW.run_id = '${runId}' AND NEW.status = 'replied'
154
+ BEGIN
155
+ SELECT RAISE(ABORT, 'test690_injected_run_failure');
156
+ END`,
157
+ );
158
+ let threw = false;
159
+ try {
160
+ await call(handlers().send_reply, {
161
+ alias: ALIAS,
162
+ text: "must roll back",
163
+ in_reply_to: taskId,
164
+ status: "replied",
165
+ network_id: NET,
166
+ });
167
+ } catch (error: any) {
168
+ threw = /test690_injected_run_failure/.test(String(error?.message || error));
169
+ } finally {
170
+ db.exec("DROP TRIGGER IF EXISTS test690_fail_run_terminal");
171
+ }
172
+ expect(threw).toBe(true);
173
+ expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = ?1", taskId)?.status).toBe("delivered");
174
+ expect(readRun(runId).status).toBe("delivered");
175
+ expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM inbox WHERE in_reply_to = ?1", taskId)?.count).toBe(0);
176
+ });
177
+
178
+ test("cancel terminalizes, retry reopens, and the retry result closes the same run", async () => {
179
+ const { taskId, runId } = seedScheduledTask();
180
+ const tools = handlers();
181
+ expect((await call(tools.cancel_task, { task_id: taskId, reason: "operator", network_id: NET })).ok).toBe(true);
182
+ expect(readRun(runId).status).toBe("cancelled");
183
+ expect(readRun(runId).completed_at).toBeTruthy();
184
+
185
+ expect((await call(tools.retry_task, { task_id: taskId, network_id: NET })).ok).toBe(true);
186
+ expect(readRun(runId).status).toBe("delivered");
187
+ expect(readRun(runId).completed_at).toBeNull();
188
+
189
+ expect((await call(tools.send_reply, {
190
+ alias: ALIAS,
191
+ text: "runtime failed",
192
+ in_reply_to: taskId,
193
+ status: "failed",
194
+ network_id: NET,
195
+ })).ok).toBe(true);
196
+ expect(readRun(runId).status).toBe("failed");
197
+ expect(readRun(runId).error_code).toBe("task_failed");
198
+ });
199
+
200
+ test("report_completion terminalizes a scheduler run", async () => {
201
+ const { taskId, runId } = seedScheduledTask();
202
+ const reply = await call(handlers().report_completion, {
203
+ alias: ALIAS,
204
+ task: taskId,
205
+ result: "done",
206
+ network_id: NET,
207
+ });
208
+ expect(reply.ok).toBe(true);
209
+ expect(readRun(runId).status).toBe("replied");
210
+ });
211
+
212
+ test("expiration patrol closes the exact run in the same lifecycle pass", () => {
213
+ const { taskId, runId } = seedScheduledTask();
214
+ db.run("UPDATE tasks SET expires_at = datetime('now', '-1 minute') WHERE task_id = ?1", [taskId]);
215
+ patrolExpiredTasks();
216
+ expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = ?1", taskId)?.status).toBe("expired");
217
+ expect(readRun(runId).status).toBe("expired");
218
+ expect(readRun(runId).error_code).toBe("task_expired");
219
+ expect(readRun(runId).completed_at).toBeTruthy();
220
+ });
221
+
222
+ test("child auto-chain terminalizes a scheduled parent", () => {
223
+ const { taskId: parentId, runId } = seedScheduledTask();
224
+ const childId = `child_${crypto.randomUUID()}`;
225
+ db.run(
226
+ `INSERT INTO tasks
227
+ (task_id, parent_task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
228
+ VALUES (?1, ?2, ?3, 'A站负责人', 'normal', 'replied', 'verify candidate', 'reply', datetime('now'), ?4)`,
229
+ [childId, parentId, ALIAS, NET],
230
+ );
231
+ const result = chainReplyToParent(childId, "news_id=14519", "replied", 5, NET);
232
+ expect(result.chained).toBe(true);
233
+ expect(readRun(runId).status).toBe("replied");
234
+ });
235
+
236
+ test("forged metadata cannot cross-bind another task or network run", async () => {
237
+ const victim = seedScheduledTask("delivered", "victim");
238
+ const attackerTaskId = "task_attacker";
239
+ const forgedMeta = JSON.stringify({ scheduled_task_id: victim.scheduleId, scheduled_run_id: victim.runId, auth_origin: "hub_scheduler" });
240
+ db.run(
241
+ `INSERT INTO tasks
242
+ (task_id, from_name, to_node_id, to_name, priority, status, content, requires_response, created_at, network_id, meta_json)
243
+ VALUES (?1, 'scheduler', ?2, ?3, 'normal', 'delivered', 'forged', 'reply', datetime('now'), ?4, ?5)`,
244
+ [attackerTaskId, NODE, ALIAS, NET, forgedMeta],
245
+ );
246
+ await call(handlers().send_reply, {
247
+ alias: ALIAS,
248
+ text: "forged close",
249
+ in_reply_to: attackerTaskId,
250
+ status: "replied",
251
+ network_id: NET,
252
+ });
253
+ expect(readRun(victim.runId).status).toBe("delivered");
254
+ });
255
+
256
+ test("a task cannot close a run whose persisted network binding differs", async () => {
257
+ const victim = seedScheduledTask("delivered", "foreign_network");
258
+ db.run("UPDATE scheduled_task_runs SET network_id = 'net_690_foreign' WHERE run_id = ?1", [victim.runId]);
259
+ await call(handlers().send_reply, {
260
+ alias: ALIAS,
261
+ text: "must not cross network",
262
+ in_reply_to: victim.taskId,
263
+ status: "replied",
264
+ network_id: NET,
265
+ });
266
+ expect(readRun(victim.runId).status).toBe("delivered");
267
+ });
268
+ });