@sleep2agi/commhub-server 0.9.0-preview.6 → 0.9.0-preview.61

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 (143) 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 +88 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  10. package/src/api-host-supervisors-fallback.test.ts +271 -0
  11. package/src/api-nodes-shape.test.ts +26 -10
  12. package/src/auth-validate.test.ts +5 -1
  13. package/src/auth.ts +93 -14
  14. package/src/avatar-validate.test.ts +163 -0
  15. package/src/avatar-validate.ts +103 -0
  16. package/src/blocked-exit-on-reply.test.ts +122 -0
  17. package/src/boot-side-effect.test.ts +124 -0
  18. package/src/config-apply-sec1.test.ts +107 -0
  19. package/src/config-apply-validate.test.ts +92 -0
  20. package/src/config-apply-validate.ts +81 -3
  21. package/src/create-node-blocked-gate.test.ts +356 -0
  22. package/src/create-node-tool-schema.test.ts +39 -0
  23. package/src/create-node-validate.test.ts +89 -1
  24. package/src/create-node-validate.ts +55 -7
  25. package/src/create-node.ts +51 -1
  26. package/src/dashboard-slash-routing-http.test.ts +107 -0
  27. package/src/db-adapter-guard.test.ts +85 -0
  28. package/src/db-adapter.ts +64 -23
  29. package/src/db-timestamp.test.ts +38 -0
  30. package/src/db-timestamp.ts +15 -0
  31. package/src/db.ts +710 -33
  32. package/src/delivered-without-push.test.ts +140 -0
  33. package/src/desktop-message-seam.test.ts +150 -0
  34. package/src/desktop-message.test.ts +368 -0
  35. package/src/ext-token-shared.test.ts +60 -0
  36. package/src/ext-validation-regression.test.ts +131 -0
  37. package/src/external-schedule-edits-http.test.ts +320 -0
  38. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  39. package/src/external-schedule-edits.ts +327 -0
  40. package/src/file-download-authz-dev-open.test.ts +135 -0
  41. package/src/file-download-authz.test.ts +403 -0
  42. package/src/file-download-date-bucket.test.ts +436 -0
  43. package/src/file-network-scope-dev-open.test.ts +97 -0
  44. package/src/file-network-scope.test.ts +731 -0
  45. package/src/health-redaction.test.ts +641 -0
  46. package/src/http-gzip.test.ts +73 -0
  47. package/src/http-gzip.ts +60 -0
  48. package/src/hub-timestamp.test.ts +38 -0
  49. package/src/hub-timestamp.ts +27 -0
  50. package/src/inbox-count-sse.test.ts +310 -0
  51. package/src/inbox-count.ts +29 -0
  52. package/src/index.ts +15 -2227
  53. package/src/lifecycle-guard.ts +50 -0
  54. package/src/list-host-supervisors.test.ts +314 -0
  55. package/src/mcp-write-network-resolution.test.ts +429 -0
  56. package/src/messages-ack-by-agent-http.test.ts +142 -0
  57. package/src/network-name-http.test.ts +75 -0
  58. package/src/network-scope.ts +101 -0
  59. package/src/node-attrs-validate.ts +88 -0
  60. package/src/node-attrs.test.ts +217 -0
  61. package/src/node-create-request-status-http.test.ts +92 -0
  62. package/src/node-files-transport.test.ts +344 -0
  63. package/src/node-id-alias.test.ts +188 -0
  64. package/src/node-lifecycle-controllable.test.ts +64 -0
  65. package/src/node-lifecycle-controllable.ts +51 -0
  66. package/src/node-request-retention.test.ts +281 -0
  67. package/src/node-request-retention.ts +104 -0
  68. package/src/node-skills-transport.test.ts +209 -0
  69. package/src/observer-avatar-http.test.ts +428 -0
  70. package/src/observer-push.test.ts +252 -0
  71. package/src/package-version-consistency.test.ts +25 -0
  72. package/src/peer-reply-atomic.test.ts +455 -0
  73. package/src/peer-reply-capability.test.ts +70 -0
  74. package/src/push.ts +163 -7
  75. package/src/redact-tokens.test.ts +65 -0
  76. package/src/redact-tokens.ts +40 -0
  77. package/src/reply-target-derivation.test.ts +96 -0
  78. package/src/report-status-host-schema.test.ts +211 -0
  79. package/src/report-status-resume-id-handover.test.ts +150 -0
  80. package/src/require-explicit-test-db.ts +9 -0
  81. package/src/resolve-port.test.ts +57 -0
  82. package/src/resolve-port.ts +44 -0
  83. package/src/response-charset.test.ts +130 -0
  84. package/src/rest-explicit-columns-http.test.ts +216 -0
  85. package/src/rest-identity.test.ts +129 -0
  86. package/src/rest-identity.ts +100 -0
  87. package/src/rest-projections.ts +67 -0
  88. package/src/rest-write-network-resolution.test.ts +133 -0
  89. package/src/retention.ts +10 -1
  90. package/src/rules-file-duplicate-alias.test.ts +234 -0
  91. package/src/rules-file-session-target.test.ts +207 -0
  92. package/src/rules-file-transport.test.ts +237 -0
  93. package/src/runtime-label.test.ts +22 -0
  94. package/src/runtime-label.ts +21 -0
  95. package/src/scheduled-run-terminal.test.ts +268 -0
  96. package/src/scheduled-tasks-http.test.ts +483 -0
  97. package/src/scheduled-tasks.ts +549 -0
  98. package/src/send-dedup-request-id.test.ts +229 -0
  99. package/src/send-reply-agent-warning.test.ts +202 -0
  100. package/src/send-reply-alias-optional.test.ts +131 -0
  101. package/src/send-reply-attachments.test.ts +458 -0
  102. package/src/send_dedup.test.ts +54 -0
  103. package/src/send_dedup.ts +52 -6
  104. package/src/serve-error.test.ts +38 -0
  105. package/src/serve-error.ts +27 -0
  106. package/src/server.ts +3901 -0
  107. package/src/shared/external-schedule-contract.ts +60 -0
  108. package/src/side-thread-command-transport.test.ts +302 -0
  109. package/src/side-thread-command-transport.ts +318 -0
  110. package/src/side-thread-http-integration.test.ts +233 -0
  111. package/src/side-thread-http.ts +504 -0
  112. package/src/side-thread-production.test.ts +72 -0
  113. package/src/side-thread-production.ts +118 -0
  114. package/src/side-thread.test.ts +1067 -0
  115. package/src/side-thread.ts +2495 -0
  116. package/src/skillhub-http.test.ts +92 -0
  117. package/src/start-node.test.ts +109 -0
  118. package/src/stop-delete-node.test.ts +1164 -0
  119. package/src/task-auth-origin-http.test.ts +91 -0
  120. package/src/task-auth-origin.test.ts +24 -0
  121. package/src/task-auth-origin.ts +7 -0
  122. package/src/task-consumption.test.ts +482 -0
  123. package/src/task-diagnostic-http.test.ts +149 -0
  124. package/src/task-diagnostic.test.ts +115 -0
  125. package/src/task-diagnostic.ts +118 -0
  126. package/src/task-idempotency-mcp.test.ts +142 -0
  127. package/src/task-idempotency.test.ts +60 -0
  128. package/src/task-idempotency.ts +75 -0
  129. package/src/task-lifecycle-watcher.test.ts +204 -0
  130. package/src/task-lifecycle-watcher.ts +49 -0
  131. package/src/task-network-resolution-http.test.ts +141 -0
  132. package/src/tasks-pagination-http.test.ts +141 -0
  133. package/src/tools-list-json-schema.test.ts +66 -0
  134. package/src/tools.ts +3280 -206
  135. package/src/update-provider.test.ts +65 -0
  136. package/src/upload-early-reject-guard.test.ts +155 -0
  137. package/src/uploads-http.test.ts +97 -15
  138. package/src/uploads.test.ts +66 -0
  139. package/src/uploads.ts +130 -5
  140. package/src/user-inbox-by-agent-http.test.ts +161 -0
  141. package/src/user-inbox-netid-notnull.test.ts +122 -0
  142. package/src/user-inbox-read-http.test.ts +223 -0
  143. package/src/user-inbox-schema.test.ts +54 -0
package/src/db.ts CHANGED
@@ -53,6 +53,38 @@ db.exec(`
53
53
  CREATE INDEX IF NOT EXISTS idx_inbox_pending
54
54
  ON inbox(session_name, acked) WHERE acked = 0;
55
55
 
56
+ -- #1459 ① — agent→用户的 desktop message 持久化收件箱。
57
+ -- inbox 是 session_name(alias) 寻址的;desktop message 目标是 user_id,不能复用
58
+ -- inbox(否则 /api/messages+inbox_count+SSE gate 一票 alias 域查询会隐性开始返回
59
+ -- 用户消息)。单独一张 user_id 寻址表,镜像 inbox 的 acked/count 语义。
60
+ -- message_id 即主键(dm_<uuid>,与 inbox「消息 id 即主键」同形)→ 写入用
61
+ -- INSERT OR IGNORE,send_desktop_message 重试天然幂等,不会插出重复消息行。
62
+ -- 索引留到 P3:读取查询形状定了再按真实 WHERE 加(SDK马 review:没查询就加索引是猜;
63
+ -- 部分索引 acked=0 若「近期」读含已 ack 行则用不上)。P1 只落表。
64
+ CREATE TABLE IF NOT EXISTS user_inbox (
65
+ message_id TEXT PRIMARY KEY,
66
+ network_id TEXT NOT NULL,
67
+ user_id TEXT NOT NULL,
68
+ from_session TEXT,
69
+ kind TEXT NOT NULL DEFAULT 'info',
70
+ title TEXT,
71
+ content TEXT NOT NULL,
72
+ severity TEXT NOT NULL DEFAULT 'info',
73
+ meta_json TEXT,
74
+ acked INTEGER NOT NULL DEFAULT 0,
75
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
76
+ acked_at TEXT
77
+ );
78
+
79
+ -- #1459 ① P3 —— 现在有真实查询了才建索引(P1 刻意没建:没有查询就加索引是猜)。
80
+ -- 覆盖回读分支的两条:
81
+ -- 列表 SELECT ... WHERE user_id = ? [AND acked = 0] ORDER BY created_at DESC
82
+ -- 未读数 SELECT COUNT(*) WHERE user_id = ? AND acked = 0
83
+ -- 不是部分索引(不写 WHERE acked = 0):列表在 unacked=0 时要读**含已 ack 的**
84
+ -- 近期消息,部分索引那时用不上。
85
+ CREATE INDEX IF NOT EXISTS idx_user_inbox_user_acked
86
+ ON user_inbox(user_id, acked, created_at);
87
+
56
88
  CREATE TABLE IF NOT EXISTS completions (
57
89
  id TEXT PRIMARY KEY,
58
90
  session_name TEXT NOT NULL,
@@ -89,6 +121,18 @@ for (const col of [
89
121
  { name: "process_cpu_pct", def: "REAL" },
90
122
  { name: "process_uptime_seconds", def: "REAL" },
91
123
  { name: "process_in_flight_count", def: "INTEGER" },
124
+ { name: "external_schedules", def: "TEXT" },
125
+ { name: "peer_reply_inbox_capable", def: "INTEGER NOT NULL DEFAULT 0" },
126
+ // app#225 follow-up — a session (incl. claude-code sessions with no `nodes` row)
127
+ // that answers the rules-file doorbell. Sticky: set by a bound node token, never
128
+ // cleared by reports that omit it (agent-node status-only reports do).
129
+ { name: "rules_file_capable", def: "INTEGER NOT NULL DEFAULT 0" },
130
+ // Node skills view — the same session answers skills_list / skill_read on the
131
+ // rules-file doorbell (node-skills.ts). Sticky, bound-token only, like above.
132
+ { name: "skills_capable", def: "INTEGER NOT NULL DEFAULT 0" },
133
+ // Project folder view — the same session answers files_list / file_read on the
134
+ // rules-file doorbell (node-files.ts). Sticky, bound-token only, like above.
135
+ { name: "files_capable", def: "INTEGER NOT NULL DEFAULT 0" },
92
136
  ]) {
93
137
  try { db.exec(`ALTER TABLE sessions ADD COLUMN ${col.name} ${col.def}`); } catch {}
94
138
  }
@@ -162,6 +206,11 @@ for (const col of [
162
206
  { name: "scope", def: "TEXT DEFAULT 'single'" },
163
207
  { name: "meta_json", def: "TEXT" },
164
208
  { name: "node_id", def: "TEXT" },
209
+ // #520 — explicit logical task identity. Initial deliveries historically
210
+ // used inbox.id == tasks.task_id, while retry/reassign generate a fresh
211
+ // inbox row id. Keep transport-row identity and logical-task identity
212
+ // separate so runtime evidence/replies survive those redeliveries.
213
+ { name: "task_id", def: "TEXT" },
165
214
  ]) {
166
215
  try { db.exec(`ALTER TABLE inbox ADD COLUMN ${col.name} ${col.def}`); } catch {}
167
216
  }
@@ -191,6 +240,10 @@ db.exec(`
191
240
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
192
241
  delivered_at TEXT,
193
242
  started_at TEXT,
243
+ runtime_submitted_at TEXT,
244
+ consumed_at TEXT,
245
+ thread_id TEXT,
246
+ turn_id TEXT,
194
247
  completed_at TEXT,
195
248
  expires_at TEXT
196
249
  );
@@ -237,6 +290,8 @@ db.exec(`
237
290
  task_id TEXT NOT NULL,
238
291
  from_status TEXT,
239
292
  to_status TEXT NOT NULL,
293
+ event_type TEXT,
294
+ event_key TEXT,
240
295
  actor TEXT NOT NULL DEFAULT 'system',
241
296
  detail TEXT,
242
297
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
@@ -301,6 +356,159 @@ try {
301
356
  if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
302
357
  }
303
358
 
359
+ // RFC-036 / B4 — immutable per-node owner. New nodes receive this value in
360
+ // the same transaction that mints their node token. Heartbeats may verify the
361
+ // binding but must never first-claim or replace it. Legacy NULL rows remain
362
+ // read-only for external-schedule writes.
363
+ try {
364
+ db.exec(`ALTER TABLE nodes ADD COLUMN owner_user_id TEXT`);
365
+ } catch (e: any) {
366
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
367
+ }
368
+
369
+ // RFC-026 §9 / #338 PR2 — daemon self-declare fields promoted from
370
+ // config_snapshot to first-class indexable columns. The hub
371
+ // `list_host_supervisors` MCP tool reads these directly without parsing
372
+ // the snapshot JSON on every list call.
373
+ //
374
+ // Both fields are JSON arrays of strings:
375
+ // runtimes_supported e.g. ["claude-agent-sdk","codex-sdk","grok-build-acp"]
376
+ // allowed_secret_keys e.g. ["ANTHROPIC_API_KEY","OPENAI_API_KEY"]
377
+ //
378
+ // Stored as TEXT (JSON-encoded) — SQLite has no native array type and
379
+ // the wrapper code is small. Both nullable for pre-PR2 daemons; the
380
+ // list endpoint falls back to [] when null.
381
+ try {
382
+ db.exec(`ALTER TABLE nodes ADD COLUMN runtimes_supported TEXT`);
383
+ } catch (e: any) {
384
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
385
+ }
386
+ try {
387
+ db.exec(`ALTER TABLE nodes ADD COLUMN allowed_secret_keys TEXT`);
388
+ } catch (e: any) {
389
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
390
+ }
391
+
392
+ // RFC-027 §2.3 — state machine for stop/delete (D5). 'active' default
393
+ // matches pre-RFC behavior for every existing row. Transitions:
394
+ // active → stopping → stopped → (back to active via restart_node)
395
+ // active → deleting → (row gone)
396
+ // stopped → deleting → (row gone)
397
+ // Used as the inbox-enqueue gate per §2.3 race-free invariant:
398
+ // pushEvent / INSERT INTO inbox must refuse a non-active target so the
399
+ // SIGTERM-in-flight window never gets a new task.
400
+ try {
401
+ db.exec(`ALTER TABLE nodes ADD COLUMN lifecycle_state TEXT DEFAULT 'active'`);
402
+ } catch (e: any) {
403
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
404
+ }
405
+
406
+ // #462 — user-set custom avatar (nullable http/https image URL). Pure
407
+ // display attribute: deliberately NOT part of the RFC-024 config-apply
408
+ // pipeline (nothing for agent-node to consume, no revision/ack). Written
409
+ // only via PUT /api/nodes/:ref/avatar which validates the URL shape.
410
+ try {
411
+ db.exec(`ALTER TABLE nodes ADD COLUMN avatar_url TEXT`);
412
+ } catch (e: any) {
413
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
414
+ }
415
+
416
+ // Node DISPLAY attributes (Vincent 目标第 3 条 — dashboard/app 多维度编辑).
417
+ // Same posture as avatar_url above: hub-side presentation only, NOT part of
418
+ // the RFC-024 config-apply pipeline (agent-node consumes none of them, so a
419
+ // doorbell/ack round-trip would be meaningless and could wedge the config
420
+ // revision). Written only via PUT /api/nodes/:ref/attrs.
421
+ //
422
+ // display_name — human label shown in the UI. Distinct from `alias`,
423
+ // which is the ROUTING key (messages address the alias)
424
+ // and from `node_name`, which is an addressing key
425
+ // (`WHERE node_id OR node_name OR alias`). Renaming
426
+ // either of those must go through the rename 2PC
427
+ // (/api/node-rename/*), never through an attrs write.
428
+ // team, tags — organisation metadata; tags stored as JSON array text.
429
+ // attrs_revision — optimistic-lock counter for these display fields ONLY.
430
+ // Deliberately separate from config_revision so that
431
+ // editing a tag never bumps the node's *config* revision
432
+ // (which would perturb the config ack protocol).
433
+ //
434
+ // All nullable / defaulted so the ~100 rows already in flight keep reading.
435
+ for (const ddl of [
436
+ `ALTER TABLE nodes ADD COLUMN display_name TEXT`,
437
+ `ALTER TABLE nodes ADD COLUMN team TEXT`,
438
+ `ALTER TABLE nodes ADD COLUMN tags TEXT`,
439
+ `ALTER TABLE nodes ADD COLUMN attrs_revision INTEGER NOT NULL DEFAULT 0`,
440
+ ]) {
441
+ try {
442
+ db.exec(ddl);
443
+ } catch (e: any) {
444
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
445
+ }
446
+ }
447
+
448
+ // RFC-027 §2 — stop/delete request envelope. Mirrors
449
+ // node_create_requests structure so the daemon-side pull/ack pattern
450
+ // is symmetric with create_node. action='stop'|'delete' picks which
451
+ // branch (delete adds backup_path + revokes ntok on ack).
452
+ db.exec(`
453
+ CREATE TABLE IF NOT EXISTS node_stop_requests (
454
+ request_id TEXT PRIMARY KEY,
455
+ network_id TEXT NOT NULL,
456
+ daemon_node_id TEXT NOT NULL,
457
+ child_node_id TEXT NOT NULL,
458
+ child_alias TEXT NOT NULL,
459
+ child_token_id TEXT,
460
+ action TEXT NOT NULL CHECK (action IN ('stop', 'delete')),
461
+ delete_config INTEGER NOT NULL DEFAULT 1,
462
+ grace_seconds INTEGER NOT NULL DEFAULT 10,
463
+ force INTEGER NOT NULL DEFAULT 0,
464
+ in_flight_at_dispatch INTEGER NOT NULL DEFAULT 0,
465
+ created_by_token TEXT NOT NULL,
466
+ status TEXT NOT NULL DEFAULT 'pending',
467
+ error TEXT,
468
+ backup_path TEXT,
469
+ exit_signal TEXT,
470
+ created_at INTEGER NOT NULL,
471
+ delivered_at INTEGER,
472
+ acked_at INTEGER
473
+ )
474
+ `);
475
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_stop_req_daemon ON node_stop_requests(daemon_node_id, status)`);
476
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_stop_req_child ON node_stop_requests(child_node_id)`);
477
+ // #1448 finding-5 — 存 delete 目标 child 的 token_id,让 ack 精确按 token_id 撤 ntok
478
+ // (而非按 name='node:<alias>' 广撤 → 同 alias 在 delete 窗口内重建会误杀新 token)。
479
+ // 幂等迁移:已有库补列,旧行 child_token_id 为 NULL、ack 时 fallback 回按 name 撤。
480
+ try { db.exec(`ALTER TABLE node_stop_requests ADD COLUMN child_token_id TEXT`); } catch { /* 已存在 */ }
481
+
482
+ // Daemon-mediated start request. Kept separate from node_stop_requests:
483
+ // the deployed stop table has a CHECK(action IN ('stop','delete')) and
484
+ // rebuilding that table in-place would be a risky migration for an additive
485
+ // lifecycle operation.
486
+ db.exec(`
487
+ CREATE TABLE IF NOT EXISTS node_start_requests (
488
+ request_id TEXT PRIMARY KEY,
489
+ network_id TEXT NOT NULL,
490
+ daemon_node_id TEXT NOT NULL,
491
+ child_node_id TEXT NOT NULL,
492
+ child_alias TEXT NOT NULL,
493
+ created_by_token TEXT NOT NULL,
494
+ status TEXT NOT NULL DEFAULT 'pending',
495
+ error TEXT,
496
+ child_pid INTEGER,
497
+ created_at INTEGER NOT NULL,
498
+ delivered_at INTEGER,
499
+ acked_at INTEGER
500
+ )
501
+ `);
502
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_start_req_daemon ON node_start_requests(daemon_node_id, status)`);
503
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_start_req_child ON node_start_requests(child_node_id)`);
504
+ try {
505
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_start_req_child_inflight
506
+ ON node_start_requests(child_node_id)
507
+ WHERE status IN ('pending', 'delivered')`);
508
+ } catch (e: any) {
509
+ if (!/unique constraint/i.test(e?.message || "")) throw e;
510
+ }
511
+
304
512
  // `node_config_updates` — pending + history. One row per dashboard write.
305
513
  // At most one row per node may be in a non-terminal state at a time
306
514
  // (single-flight enforced at the tool layer; this table just stores).
@@ -342,6 +550,47 @@ try {
342
550
  console.warn(`[commhub] partial unique index uniq_ncu_node_inflight skipped: ${e?.message || e}`);
343
551
  }
344
552
 
553
+ // ── app#225: 节点规则文件(AGENTS.md / CLAUDE.md)远程读写请求 ──
554
+ //
555
+ // 形状照抄 node_config_updates(上面):客户端落一行 + 门铃,节点拉走做完回报,
556
+ // 客户端轮询结果。network_id 同样在插入时从 nodes 反规范化。
557
+ //
558
+ // 🔴 这张表**故意没有路径列**:文件名由节点按自己的 RUNTIME 决定
559
+ // (claude → CLAUDE.md,其余 → AGENTS.md),目录固定是节点进程的 cwd。
560
+ // hub 侧只存 op + 内容,客户端连文件名都指定不了(#225 验收第 5 条)。
561
+ // file_name 是节点**回报**的实际文件名,只用于显示。
562
+ db.exec(`
563
+ CREATE TABLE IF NOT EXISTS node_rules_requests (
564
+ request_id TEXT PRIMARY KEY,
565
+ node_id TEXT NOT NULL,
566
+ network_id TEXT NOT NULL,
567
+ op TEXT NOT NULL,
568
+ content TEXT,
569
+ status TEXT NOT NULL,
570
+ file_name TEXT,
571
+ file_exists INTEGER,
572
+ result_content TEXT,
573
+ error TEXT,
574
+ created_at INTEGER NOT NULL,
575
+ created_by_token TEXT NOT NULL,
576
+ pulled_at INTEGER,
577
+ acked_at INTEGER
578
+ );
579
+
580
+ CREATE INDEX IF NOT EXISTS idx_nrr_node_status ON node_rules_requests(node_id, status);
581
+ CREATE INDEX IF NOT EXISTS idx_nrr_network ON node_rules_requests(network_id);
582
+ `);
583
+ // Content retention (node-request-retention.ts): file bytes in content /
584
+ // result_content are purged after the first terminal read (+grace) or a TTL;
585
+ // these two columns record when, so the row itself survives as audit metadata.
586
+ for (const col of ["first_read_at INTEGER", "content_purged_at INTEGER"]) {
587
+ try { db.exec(`ALTER TABLE node_rules_requests ADD COLUMN ${col}`); } catch {}
588
+ }
589
+ db.exec(`
590
+ CREATE INDEX IF NOT EXISTS idx_nrr_created ON node_rules_requests(created_at);
591
+ CREATE INDEX IF NOT EXISTS idx_nrr_first_read ON node_rules_requests(first_read_at);
592
+ `);
593
+
345
594
  // ── V3: networks table ──
346
595
  db.exec(`
347
596
  CREATE TABLE IF NOT EXISTS networks (
@@ -387,6 +636,7 @@ for (const ddl of [
387
636
  "ALTER TABLE api_tokens ADD COLUMN request_id TEXT",
388
637
  "ALTER TABLE api_tokens ADD COLUMN revoked_at TEXT",
389
638
  "ALTER TABLE api_tokens ADD COLUMN role TEXT",
639
+ "ALTER TABLE api_tokens ADD COLUMN bound_node_id TEXT",
390
640
  ]) {
391
641
  try { db.exec(ddl); }
392
642
  catch (e: any) {
@@ -395,6 +645,8 @@ for (const ddl of [
395
645
  }
396
646
  try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_request ON api_tokens(request_id)`); }
397
647
  catch (e: any) { /* index may already exist */ void e; }
648
+ try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_bound_node ON api_tokens(bound_node_id)`); }
649
+ catch (e: any) { void e; }
398
650
 
399
651
  // RFC-026 v4 §2.5 + §4.4 — node_create_requests. Metadata only;
400
652
  // env_blob NEVER lives in this table (F1 mint-stream-evict — secret
@@ -411,7 +663,7 @@ db.exec(`
411
663
  child_name TEXT NOT NULL,
412
664
  network_id TEXT NOT NULL,
413
665
  runtime TEXT NOT NULL,
414
- model TEXT NOT NULL,
666
+ model TEXT,
415
667
  flags_json TEXT NOT NULL,
416
668
  env_keys TEXT NOT NULL,
417
669
  status TEXT NOT NULL,
@@ -428,6 +680,138 @@ db.exec(`
428
680
  CREATE INDEX IF NOT EXISTS idx_ncr_network ON node_create_requests(network_id);
429
681
  CREATE INDEX IF NOT EXISTS idx_ncr_child_name ON node_create_requests(child_name);
430
682
  `);
683
+ function migrateNodeCreateRequestsModelNullable() {
684
+ if (db.dialect === "postgres") {
685
+ try { db.exec("ALTER TABLE node_create_requests ALTER COLUMN model DROP NOT NULL"); } catch {}
686
+ return;
687
+ }
688
+
689
+ let needsRebuild = false;
690
+ try {
691
+ const createSql = db.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'node_create_requests'");
692
+ needsRebuild = !!createSql?.sql && /\bmodel\s+TEXT\s+NOT\s+NULL\b/i.test(createSql.sql);
693
+ } catch {}
694
+ if (!needsRebuild) return;
695
+
696
+ db.transaction(() => {
697
+ db.exec("DROP TABLE IF EXISTS node_create_requests_migrated");
698
+ db.exec(`
699
+ CREATE TABLE node_create_requests_migrated (
700
+ request_id TEXT PRIMARY KEY,
701
+ daemon_node_id TEXT NOT NULL,
702
+ child_name TEXT NOT NULL,
703
+ network_id TEXT NOT NULL,
704
+ runtime TEXT NOT NULL,
705
+ model TEXT,
706
+ flags_json TEXT NOT NULL,
707
+ env_keys TEXT NOT NULL,
708
+ status TEXT NOT NULL,
709
+ error TEXT,
710
+ child_token_id TEXT,
711
+ created_at INTEGER NOT NULL,
712
+ created_by_token TEXT NOT NULL,
713
+ delivered_at INTEGER,
714
+ acked_at INTEGER,
715
+ child_node_id TEXT
716
+ )
717
+ `);
718
+ db.exec(`
719
+ INSERT INTO node_create_requests_migrated (
720
+ request_id, daemon_node_id, child_name, network_id, runtime, model,
721
+ flags_json, env_keys, status, error, child_token_id, created_at,
722
+ created_by_token, delivered_at, acked_at, child_node_id
723
+ )
724
+ SELECT
725
+ request_id, daemon_node_id, child_name, network_id, runtime, model,
726
+ flags_json, env_keys, status, error, child_token_id, created_at,
727
+ created_by_token, delivered_at, acked_at, child_node_id
728
+ FROM node_create_requests
729
+ `);
730
+ db.exec("DROP TABLE node_create_requests");
731
+ db.exec("ALTER TABLE node_create_requests_migrated RENAME TO node_create_requests");
732
+ });
733
+ }
734
+ migrateNodeCreateRequestsModelNullable();
735
+
736
+ // #1493 — 把 user_inbox.network_id 升到 **schema 级 NOT NULL**(belt-and-suspenders,
737
+ // 叠在 send_desktop_message 的代码级三闸之上:canWrite / `!effectiveNetId` return /
738
+ // getUserNetworkRole,tools.ts)。「不产生 network_id=NULL 孤儿」现是代码级保证 + #1492
739
+ // 三道测试;schema 级约束让**任何**未来绕过那三闸写 NULL 的回归在 INSERT 处直接被
740
+ // 数据库拒(而非产出 scoped-unreadable 的静默孤儿)。
741
+ //
742
+ // SQLite 不能 `ALTER COLUMN ADD NOT NULL` → 建新表→copy→drop→rename(与本文件既有
743
+ // recreate-table 迁移同范式);新库的 CREATE 已直接 NOT NULL,迁移检测到就跳过(幂等)。
744
+ function migrateUserInboxNetworkIdNotNull() {
745
+ if (db.dialect === "postgres") {
746
+ try { db.exec("ALTER TABLE user_inbox ALTER COLUMN network_id SET NOT NULL"); } catch {}
747
+ return;
748
+ }
749
+ let createSql: string | undefined;
750
+ try {
751
+ createSql = db.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'user_inbox'")?.sql;
752
+ } catch { return; }
753
+ if (!createSql) return; // 表不存在:上面的 CREATE 已建成 NOT NULL 新表
754
+ if (/network_id\s+TEXT\s+NOT\s+NULL/i.test(createSql)) return; // 已是 NOT NULL → 幂等跳过
755
+
756
+ // 🔴 迁移前守卫:存量若有 network_id IS NULL 行(按代码级三闸理应 0)→ **大声 warn +
757
+ // 跳过迁移**(列暂保持可空),hub 照常启动。fail-safe,不 throw(SDK马 review #1516):
758
+ // · 这条 NOT NULL 是 belt-and-suspenders,代码级三闸(#1492 三测钉着)已挡新 NULL;
759
+ // 为一条冗余保险带让**整个舰队的 hub** boot 期停摆,代价与收益不成比例;
760
+ // · 生产这个 COUNT 没人量过,而 #1493 第一条原则就是"没量过的数不要动手"——只要生产
761
+ // 有一行历史 NULL,throw 就把升级变成停机;
762
+ // · fail-closed 的真正目的(不盲目清空、不 COALESCE 猜网络)用 warn+skip 一样达到。
763
+ // ⚠ 校正:本文件既有迁移(migrateNodeCreateRequestsModelNullable 内部 catch{}+return;
764
+ // migrateSessionsNetworkAliasUnique 全函数 throw 0 次)都**不**在 boot 期打死 hub——
765
+ // "顶层无 try/catch"是真前提,但"所以 throw 符合惯例"是没验的推论(SDK马 量出),撤回。
766
+ // 孤儿行仍有明确运维信号,三闸仍生效;等人工量过、清干净,下次启动幂等判据自动完成迁移。
767
+ const nullCount = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE network_id IS NULL")?.n ?? 0;
768
+ if (nullCount > 0) {
769
+ console.error(
770
+ `[migrate #1493] user_inbox 有 ${nullCount} 行 network_id IS NULL —— 与 send_desktop_message ` +
771
+ `的代码级三闸矛盾,可能存在更早的写路径漏洞;已**跳过** NOT NULL 迁移(列暂保持可空),` +
772
+ `hub 正常启动。请人工排查这些孤儿行来源、清理后下次启动会自动完成迁移(不要盲目清空)。`,
773
+ );
774
+ return;
775
+ }
776
+
777
+ db.transaction(() => {
778
+ db.exec("DROP TABLE IF EXISTS user_inbox_migrated");
779
+ db.exec(`
780
+ CREATE TABLE user_inbox_migrated (
781
+ message_id TEXT PRIMARY KEY,
782
+ network_id TEXT NOT NULL,
783
+ user_id TEXT NOT NULL,
784
+ from_session TEXT,
785
+ kind TEXT NOT NULL DEFAULT 'info',
786
+ title TEXT,
787
+ content TEXT NOT NULL,
788
+ severity TEXT NOT NULL DEFAULT 'info',
789
+ meta_json TEXT,
790
+ acked INTEGER NOT NULL DEFAULT 0,
791
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
792
+ acked_at TEXT
793
+ )
794
+ `);
795
+ db.exec(`
796
+ INSERT INTO user_inbox_migrated
797
+ (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, acked, created_at, acked_at)
798
+ SELECT
799
+ message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, acked, created_at, acked_at
800
+ FROM user_inbox
801
+ `);
802
+ db.exec("DROP TABLE user_inbox");
803
+ db.exec("ALTER TABLE user_inbox_migrated RENAME TO user_inbox");
804
+ });
805
+ }
806
+ migrateUserInboxNetworkIdNotNull();
807
+ // 迁移会 drop 掉旧表上的 idx_user_inbox_user_acked → 重建(IF NOT EXISTS;未迁移时 no-op)。
808
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_user_inbox_user_acked ON user_inbox(user_id, acked, created_at);`);
809
+
810
+ db.exec(`
811
+ CREATE INDEX IF NOT EXISTS idx_ncr_daemon_status ON node_create_requests(daemon_node_id, status);
812
+ CREATE INDEX IF NOT EXISTS idx_ncr_network ON node_create_requests(network_id);
813
+ CREATE INDEX IF NOT EXISTS idx_ncr_child_name ON node_create_requests(child_name);
814
+ `);
431
815
  try {
432
816
  // Single-flight per (daemon, child_name) — prevents racing dashboard
433
817
  // create on same name. Mirrors RFC-024 uniq_ncu_node_inflight.
@@ -526,6 +910,37 @@ db.exec(`
526
910
  CREATE INDEX IF NOT EXISTS idx_audit_network ON audit_log(network_id);
527
911
  `);
528
912
 
913
+ // SkillHub — immutable, network-scoped skill submissions. A (network, slug,
914
+ // version) tuple is write-once: retries with the same content are idempotent,
915
+ // while different content must use a new version. Node submissions remain
916
+ // pending until an owner/admin explicitly publishes them.
917
+ db.exec(`
918
+ CREATE TABLE IF NOT EXISTS skillhub_skills (
919
+ skill_id TEXT PRIMARY KEY,
920
+ network_id TEXT NOT NULL,
921
+ slug TEXT NOT NULL,
922
+ name TEXT NOT NULL,
923
+ description TEXT NOT NULL DEFAULT '',
924
+ version TEXT NOT NULL,
925
+ content TEXT NOT NULL,
926
+ content_hash TEXT NOT NULL,
927
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'published', 'rejected', 'archived')),
928
+ source_type TEXT NOT NULL CHECK(source_type IN ('node', 'user')),
929
+ source_alias TEXT,
930
+ created_by_user TEXT,
931
+ reviewed_by_user TEXT,
932
+ review_note TEXT,
933
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
934
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
935
+ reviewed_at TEXT,
936
+ UNIQUE(network_id, slug, version)
937
+ );
938
+ CREATE INDEX IF NOT EXISTS idx_skillhub_network_status
939
+ ON skillhub_skills(network_id, status, updated_at DESC);
940
+ CREATE INDEX IF NOT EXISTS idx_skillhub_network_slug
941
+ ON skillhub_skills(network_id, slug, version);
942
+ `);
943
+
529
944
  // ── V3: licenses table ──
530
945
  db.exec(`
531
946
  CREATE TABLE IF NOT EXISTS licenses (
@@ -636,6 +1051,14 @@ for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events", "comp
636
1051
  try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
637
1052
  }
638
1053
 
1054
+ // #167 — stable lifecycle event names plus an internal idempotency key.
1055
+ // Ordinary status transitions keep event_key NULL, so retries and repeated
1056
+ // transitions remain auditable. Watcher warnings set a stable key; the unique
1057
+ // index makes every (task, threshold) write-once across patrol rounds/workers.
1058
+ try { db.exec("ALTER TABLE task_events ADD COLUMN event_type TEXT"); } catch {}
1059
+ try { db.exec("ALTER TABLE task_events ADD COLUMN event_key TEXT"); } catch {}
1060
+ try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_task_events_task_key ON task_events(task_id, event_key)"); } catch {}
1061
+
639
1062
  // PR-1 (#146): durable node identity on historical inbox/tasks rows. Some
640
1063
  // existing databases created `tasks` before from_node_id/to_node_id were in the
641
1064
  // CREATE TABLE statement, so keep these ALTERs explicit and idempotent.
@@ -922,6 +1345,172 @@ try { db.exec("CREATE INDEX IF NOT EXISTS idx_completions_network ON completions
922
1345
  // the reply up the chain via parent_task_id.
923
1346
  try { db.exec("ALTER TABLE tasks ADD COLUMN parent_task_id TEXT"); } catch {}
924
1347
  try { db.exec("ALTER TABLE tasks ADD COLUMN meta_json TEXT"); } catch {}
1348
+ // #520 — two monotonic evidence levels for this exact task:
1349
+ // runtime_submitted_at means agent-node handed the body to the vendor runtime;
1350
+ // consumed_at requires an attributable turn-start/first-activity signal.
1351
+ // Both stay separate from delivered_at (enqueue), acked (process receipt), and
1352
+ // started_at (legacy report_status/content matching). Old binaries ignore them.
1353
+ try { db.exec("ALTER TABLE tasks ADD COLUMN runtime_submitted_at TEXT"); } catch {}
1354
+ try { db.exec("ALTER TABLE tasks ADD COLUMN consumed_at TEXT"); } catch {}
1355
+ // Exact vendor-runtime boundary for SideThread-capable clients. These values
1356
+ // are written only by the token-bound target node after attributable runtime
1357
+ // evidence; the Hub never derives them from task text or caller input.
1358
+ try { db.exec("ALTER TABLE tasks ADD COLUMN thread_id TEXT"); } catch {}
1359
+ try { db.exec("ALTER TABLE tasks ADD COLUMN turn_id TEXT"); } catch {}
1360
+
1361
+ // #1181 durable outbound terminal cursor. A task's created_at cannot be used
1362
+ // as a recovery watermark because an old task may become terminal after newer
1363
+ // tasks. Record terminal transitions in their own monotonically increasing
1364
+ // journal instead. The journal is append-only for a logical task; retry keeps
1365
+ // the existing task identity and therefore the same idempotency domain.
1366
+ db.exec(`
1367
+ CREATE TABLE IF NOT EXISTS task_terminal_events (
1368
+ terminal_seq INTEGER PRIMARY KEY AUTOINCREMENT,
1369
+ task_id TEXT NOT NULL UNIQUE,
1370
+ network_id TEXT,
1371
+ from_node_id TEXT,
1372
+ completed_at TEXT NOT NULL DEFAULT (datetime('now'))
1373
+ );
1374
+ CREATE INDEX IF NOT EXISTS idx_task_terminal_events_sender_seq
1375
+ ON task_terminal_events(network_id, from_node_id, terminal_seq);
1376
+ `);
1377
+ if (db.dialect === "sqlite") {
1378
+ db.exec(`
1379
+ CREATE TRIGGER IF NOT EXISTS tasks_terminal_event_insert
1380
+ AFTER INSERT ON tasks
1381
+ WHEN NEW.status IN ('replied', 'failed', 'cancelled', 'expired')
1382
+ BEGIN
1383
+ INSERT OR IGNORE INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
1384
+ VALUES (NEW.task_id, NEW.network_id, NEW.from_node_id, COALESCE(NEW.completed_at, datetime('now')));
1385
+ END;
1386
+ CREATE TRIGGER IF NOT EXISTS tasks_terminal_event_update
1387
+ AFTER UPDATE OF status ON tasks
1388
+ WHEN NEW.status IN ('replied', 'failed', 'cancelled', 'expired')
1389
+ AND OLD.status NOT IN ('replied', 'failed', 'cancelled', 'expired')
1390
+ BEGIN
1391
+ INSERT OR IGNORE INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
1392
+ VALUES (NEW.task_id, NEW.network_id, NEW.from_node_id, COALESCE(NEW.completed_at, datetime('now')));
1393
+ END;
1394
+ `);
1395
+ db.run(`INSERT OR IGNORE INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
1396
+ SELECT task_id, network_id, from_node_id, COALESCE(completed_at, created_at)
1397
+ FROM tasks
1398
+ WHERE status IN ('replied', 'failed', 'cancelled', 'expired')
1399
+ ORDER BY COALESCE(completed_at, created_at), task_id`);
1400
+ } else {
1401
+ db.exec(`
1402
+ CREATE OR REPLACE FUNCTION record_task_terminal_event() RETURNS trigger AS $$
1403
+ BEGIN
1404
+ IF NEW.status IN ('replied', 'failed', 'cancelled', 'expired')
1405
+ AND (TG_OP = 'INSERT' OR OLD.status NOT IN ('replied', 'failed', 'cancelled', 'expired')) THEN
1406
+ INSERT INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
1407
+ VALUES (NEW.task_id, NEW.network_id, NEW.from_node_id, COALESCE(NEW.completed_at, NOW()))
1408
+ ON CONFLICT (task_id) DO NOTHING;
1409
+ END IF;
1410
+ RETURN NEW;
1411
+ END;
1412
+ $$ LANGUAGE plpgsql;
1413
+ DROP TRIGGER IF EXISTS tasks_terminal_event_insert ON tasks;
1414
+ DROP TRIGGER IF EXISTS tasks_terminal_event_update ON tasks;
1415
+ CREATE TRIGGER tasks_terminal_event_insert AFTER INSERT ON tasks
1416
+ FOR EACH ROW EXECUTE FUNCTION record_task_terminal_event();
1417
+ CREATE TRIGGER tasks_terminal_event_update AFTER UPDATE OF status ON tasks
1418
+ FOR EACH ROW EXECUTE FUNCTION record_task_terminal_event();
1419
+ INSERT INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
1420
+ SELECT task_id, network_id, from_node_id, COALESCE(completed_at, created_at)
1421
+ FROM tasks WHERE status IN ('replied', 'failed', 'cancelled', 'expired')
1422
+ ON CONFLICT (task_id) DO NOTHING;
1423
+ `);
1424
+ }
1425
+
1426
+ // ── Hub scheduled tasks ──────────────────────────────────────────────
1427
+ // Scheduling is a Hub concern: clients manage these rows, while agents only
1428
+ // receive the ordinary inbox/tasks rows produced for each occurrence. Keep
1429
+ // the occurrence table separate so retries, skips and failures remain
1430
+ // auditable without overloading the task lifecycle.
1431
+ db.exec(`
1432
+ CREATE TABLE IF NOT EXISTS scheduled_tasks (
1433
+ schedule_id TEXT PRIMARY KEY,
1434
+ network_id TEXT NOT NULL,
1435
+ created_by TEXT,
1436
+ name TEXT NOT NULL,
1437
+ target_node_id TEXT NOT NULL,
1438
+ target_alias TEXT NOT NULL,
1439
+ task_content TEXT NOT NULL,
1440
+ priority TEXT NOT NULL DEFAULT 'normal',
1441
+ schedule_type TEXT NOT NULL,
1442
+ schedule_json TEXT NOT NULL,
1443
+ timezone TEXT NOT NULL DEFAULT 'UTC',
1444
+ overlap_policy TEXT NOT NULL DEFAULT 'skip',
1445
+ misfire_policy TEXT NOT NULL DEFAULT 'catch_up_once',
1446
+ status TEXT NOT NULL DEFAULT 'active',
1447
+ next_run_at TEXT,
1448
+ last_run_at TEXT,
1449
+ revision INTEGER NOT NULL DEFAULT 1,
1450
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1451
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1452
+ );
1453
+
1454
+ CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_due
1455
+ ON scheduled_tasks(status, next_run_at);
1456
+ CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_network
1457
+ ON scheduled_tasks(network_id, updated_at DESC);
1458
+ CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_target
1459
+ ON scheduled_tasks(network_id, target_node_id);
1460
+
1461
+ CREATE TABLE IF NOT EXISTS scheduled_task_runs (
1462
+ run_id TEXT PRIMARY KEY,
1463
+ schedule_id TEXT NOT NULL,
1464
+ network_id TEXT NOT NULL,
1465
+ scheduled_for TEXT NOT NULL,
1466
+ task_id TEXT,
1467
+ status TEXT NOT NULL,
1468
+ error_code TEXT,
1469
+ error_message TEXT,
1470
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1471
+ completed_at TEXT,
1472
+ UNIQUE(schedule_id, scheduled_for)
1473
+ );
1474
+
1475
+ CREATE INDEX IF NOT EXISTS idx_scheduled_runs_schedule
1476
+ ON scheduled_task_runs(schedule_id, created_at DESC);
1477
+ CREATE INDEX IF NOT EXISTS idx_scheduled_runs_network
1478
+ ON scheduled_task_runs(network_id, created_at DESC);
1479
+ `);
1480
+ try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN misfire_policy TEXT NOT NULL DEFAULT 'catch_up_once'"); } catch {}
1481
+
1482
+ // RFC-036 / B4 — owner-authorized edits of node-host managed schedules.
1483
+ // This is intentionally separate from Hub scheduled_tasks: these rows are
1484
+ // one-shot control intents consumed by the exact token-bound node. The partial
1485
+ // unique index makes the single-flight promise survive multiple Hub workers.
1486
+ db.exec(`
1487
+ CREATE TABLE IF NOT EXISTS external_schedule_edits (
1488
+ intent_id TEXT PRIMARY KEY,
1489
+ network_id TEXT NOT NULL,
1490
+ node_id TEXT NOT NULL,
1491
+ schedule_id TEXT NOT NULL,
1492
+ base_revision INTEGER NOT NULL,
1493
+ patch_json TEXT NOT NULL,
1494
+ status TEXT NOT NULL DEFAULT 'pending',
1495
+ expires_at INTEGER NOT NULL,
1496
+ created_at INTEGER NOT NULL,
1497
+ delivered_at INTEGER,
1498
+ acked_at INTEGER,
1499
+ created_by_user TEXT NOT NULL,
1500
+ created_by_token TEXT NOT NULL,
1501
+ consumed_by_token TEXT,
1502
+ result_revision INTEGER,
1503
+ error_code TEXT
1504
+ );
1505
+
1506
+ CREATE INDEX IF NOT EXISTS idx_external_schedule_edits_node
1507
+ ON external_schedule_edits(network_id, node_id, status, created_at);
1508
+ CREATE INDEX IF NOT EXISTS idx_external_schedule_edits_owner
1509
+ ON external_schedule_edits(created_by_user, created_at DESC);
1510
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_external_schedule_edits_singleflight
1511
+ ON external_schedule_edits(node_id, schedule_id)
1512
+ WHERE status IN ('pending', 'delivered');
1513
+ `);
925
1514
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id)"); } catch {}
926
1515
 
927
1516
  // Helpers
@@ -1230,38 +1819,40 @@ export function chainReplyToParent(
1230
1819
  const marker = `\n\n[via ${childAlias} 子任务结果]\n${currentReply}`;
1231
1820
  const newResult = parent.result ? parent.result + marker : `[via ${childAlias} 子任务结果]\n${currentReply}`;
1232
1821
 
1233
- // Bump parent status to replied if still open. If it was already replied
1234
- // (e.g. 指挥室 sent an early status update), we still want to update the
1235
- // result so the dashboard can render the final chained answer — but keep
1236
- // status as replied (it was already terminal).
1237
- if (parent.status === "delivered" || parent.status === "acked" || parent.status === "running" || parent.status === "created") {
1238
- db.run(
1239
- "UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now') WHERE task_id = ?3",
1240
- [replyStatus, newResult.slice(0, 8000), parent.task_id]
1241
- );
1242
- logTaskEvent(parent.task_id, parent.status, replyStatus, "auto-chain", `from ${childAlias}`);
1243
- } else {
1244
- db.run(
1245
- "UPDATE tasks SET result = ?1, completed_at = datetime('now') WHERE task_id = ?2",
1246
- [newResult.slice(0, 8000), parent.task_id]
1247
- );
1248
- logTaskEvent(parent.task_id, parent.status, parent.status, "auto-chain-append", `from ${childAlias}`);
1249
- }
1250
-
1251
- if (parent.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
1252
- try {
1253
- const notifyId = `chain_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
1254
- const notifyNode = db.get<{ node_id: string | null }>(
1255
- "SELECT node_id FROM sessions WHERE alias = ?1 AND COALESCE(network_id, 'default') = COALESCE(?2, 'default') ORDER BY updated_at DESC LIMIT 1",
1256
- [parent.from_name, parent.network_id ?? null]
1822
+ db.transaction(() => {
1823
+ // Bump parent status to replied if still open. The task transition and
1824
+ // its scheduler-run mirror share this transaction, so a crash cannot
1825
+ // leave one terminal while the other remains delivered.
1826
+ if (parent.status === "delivered" || parent.status === "acked" || parent.status === "running" || parent.status === "created") {
1827
+ db.run(
1828
+ "UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now') WHERE task_id = ?3",
1829
+ [replyStatus, newResult.slice(0, 8000), parent.task_id]
1257
1830
  );
1831
+ syncScheduledRunForTask(parent.task_id, parent.network_id);
1832
+ logTaskEvent(parent.task_id, parent.status, replyStatus, "auto-chain", `from ${childAlias}`);
1833
+ } else {
1258
1834
  db.run(
1259
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
1260
- VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
1261
- [notifyId, parent.from_name, notifyNode?.node_id ?? null, `[${childAlias} 子任务完成]\n${currentReply.slice(0, 4000)}`, parent.to_name, parent.task_id, parent.network_id ?? null]
1835
+ "UPDATE tasks SET result = ?1, completed_at = datetime('now') WHERE task_id = ?2",
1836
+ [newResult.slice(0, 8000), parent.task_id]
1262
1837
  );
1263
- } catch {}
1264
- }
1838
+ logTaskEvent(parent.task_id, parent.status, parent.status, "auto-chain-append", `from ${childAlias}`);
1839
+ }
1840
+
1841
+ if (parent.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
1842
+ try {
1843
+ const notifyId = `chain_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
1844
+ const notifyNode = db.get<{ node_id: string | null }>(
1845
+ "SELECT node_id FROM sessions WHERE alias = ?1 AND COALESCE(network_id, 'default') = COALESCE(?2, 'default') ORDER BY updated_at DESC LIMIT 1",
1846
+ [parent.from_name, parent.network_id ?? null]
1847
+ );
1848
+ db.run(
1849
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
1850
+ VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
1851
+ [notifyId, parent.from_name, notifyNode?.node_id ?? null, `[${childAlias} 子任务完成]\n${currentReply.slice(0, 4000)}`, parent.to_name, parent.task_id, parent.network_id ?? null]
1852
+ );
1853
+ } catch {}
1854
+ }
1855
+ });
1265
1856
 
1266
1857
  // This iteration actually wrote a parent row (and possibly an
1267
1858
  // inbox notification). Record that so the caller can gate its
@@ -1275,12 +1866,98 @@ export function chainReplyToParent(
1275
1866
  return { chained };
1276
1867
  }
1277
1868
 
1869
+ const SCHEDULED_RUN_TERMINAL_STATUSES = new Set(["replied", "failed", "cancelled", "expired"]);
1870
+
1871
+ type ScheduledTaskLifecycleRow = {
1872
+ task_id: string;
1873
+ network_id: string | null;
1874
+ status: string;
1875
+ completed_at: string | null;
1876
+ };
1877
+
1878
+ type ScheduledTaskBinding = { run_id: string; schedule_id: string };
1879
+
1880
+ /**
1881
+ * Mirror an exact scheduler-created task's lifecycle into its run row.
1882
+ *
1883
+ * The status is always read back from `tasks`; callers cannot supply one.
1884
+ * The scheduler's storage binding is the four-part tuple
1885
+ * (run_id, schedule_id, task_id, network_id). It is read from
1886
+ * `scheduled_task_runs`, not caller-controlled task metadata (which may also
1887
+ * legitimately be replaced by reply attachment metadata).
1888
+ *
1889
+ * Terminal states close the run. `retry_task` calls this after resetting the
1890
+ * same logical task_id to delivered, which deliberately reopens that run so
1891
+ * the history reflects the current retry rather than the first failed attempt.
1892
+ */
1893
+ export function syncScheduledRunForTask(taskId: string, expectedNetworkId?: string | null): { matched: boolean; status?: string } {
1894
+ const task = db.get<ScheduledTaskLifecycleRow>(
1895
+ `SELECT task_id, network_id, status, completed_at
1896
+ FROM tasks
1897
+ WHERE task_id = ?1
1898
+ AND (?2 IS NULL OR network_id = ?2)`,
1899
+ taskId,
1900
+ expectedNetworkId ?? null,
1901
+ );
1902
+ if (!task?.network_id) return { matched: false };
1903
+ const bindings = db.all<ScheduledTaskBinding>(
1904
+ `SELECT run_id, schedule_id FROM scheduled_task_runs
1905
+ WHERE task_id = ?1 AND network_id = ?2`,
1906
+ task.task_id,
1907
+ task.network_id,
1908
+ );
1909
+ // One task belongs to at most one scheduled occurrence. Fail closed if a
1910
+ // corrupt/legacy database violates that invariant rather than fanning one
1911
+ // lifecycle transition into multiple runs.
1912
+ if (bindings.length !== 1) return { matched: false };
1913
+ const binding = bindings[0];
1914
+
1915
+ if (SCHEDULED_RUN_TERMINAL_STATUSES.has(task.status)) {
1916
+ const errorCode = task.status === "replied" ? null : `task_${task.status}`;
1917
+ const updated = db.run(
1918
+ `UPDATE scheduled_task_runs
1919
+ SET status = ?1,
1920
+ error_code = ?2,
1921
+ error_message = NULL,
1922
+ completed_at = COALESCE(?3, datetime('now'))
1923
+ WHERE run_id = ?4 AND task_id = ?5 AND network_id = ?6 AND schedule_id = ?7`,
1924
+ [task.status, errorCode, task.completed_at, binding.run_id, task.task_id, task.network_id, binding.schedule_id],
1925
+ );
1926
+ return { matched: updated.changes === 1, status: task.status };
1927
+ }
1928
+
1929
+ if (task.status === "delivered") {
1930
+ const updated = db.run(
1931
+ `UPDATE scheduled_task_runs
1932
+ SET status = 'delivered', error_code = NULL, error_message = NULL, completed_at = NULL
1933
+ WHERE run_id = ?1 AND task_id = ?2 AND network_id = ?3 AND schedule_id = ?4`,
1934
+ [binding.run_id, task.task_id, task.network_id, binding.schedule_id],
1935
+ );
1936
+ return { matched: updated.changes === 1, status: task.status };
1937
+ }
1938
+
1939
+ return { matched: false };
1940
+ }
1941
+
1942
+ function taskEventTypeForStatus(toStatus: string): string {
1943
+ switch (toStatus) {
1944
+ case "delivered": return "task.send.delivered";
1945
+ case "acked": return "task.ack";
1946
+ case "running": return "task.started";
1947
+ case "replied": return "task.replied";
1948
+ case "expired": return "task.expired";
1949
+ case "failed":
1950
+ case "cancelled": return "task.failed";
1951
+ default: return `task.status.${toStatus}`;
1952
+ }
1953
+ }
1954
+
1278
1955
  export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
1279
1956
  try {
1280
1957
  db.run(
1281
- `INSERT INTO task_events (task_id, from_status, to_status, actor, detail, network_id)
1282
- VALUES (?1, ?2, ?3, ?4, ?5, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
1283
- [taskId, fromStatus, toStatus, actor, detail ?? null]
1958
+ `INSERT INTO task_events (task_id, from_status, to_status, event_type, actor, detail, network_id)
1959
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
1960
+ [taskId, fromStatus, toStatus, taskEventTypeForStatus(toStatus), actor, detail ?? null]
1284
1961
  );
1285
1962
  } catch {}
1286
1963
  }