@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.131

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (318) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +933 -36
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +130 -0
  205. package/src/rest-identity.ts +139 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +93 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +140 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-sender-http.test.ts +143 -0
  303. package/src/task-stale-open-http.test.ts +290 -0
  304. package/src/task-stale-open.ts +103 -0
  305. package/src/tasks-pagination-http.test.ts +141 -0
  306. package/src/tool-audience-http.test.ts +220 -0
  307. package/src/tool-audience.ts +118 -0
  308. package/src/tools-list-json-schema.test.ts +67 -0
  309. package/src/tools.ts +3940 -325
  310. package/src/upload-early-reject-guard.test.ts +155 -0
  311. package/src/uploads-http.test.ts +97 -15
  312. package/src/uploads.ts +103 -6
  313. package/src/user-avatar-http.test.ts +74 -0
  314. package/src/user-avatar-migration.test.ts +25 -0
  315. package/src/user-inbox-by-agent-http.test.ts +161 -0
  316. package/src/user-inbox-netid-notnull.test.ts +122 -0
  317. package/src/user-inbox-read-http.test.ts +223 -0
  318. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,131 @@
1
+ // Board #757 — an offline scheduled target produces an auditable skip, not a
2
+ // doomed task; each offline episode warns its human/Agent creator once.
3
+ // Run: COMMHUB_DB=/tmp/board757.db bun test src/scheduled-offline.test.ts
4
+ // PG: registered in tests/test2123-hub-postgres-ladder/run.sh.
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { register } from "./auth.js";
7
+ import { db } from "./db.js";
8
+ import { dispatchScheduledOccurrence, getScheduleRow } from "./scheduled-tasks.js";
9
+ import { SCHEDULE_TARGET_OFFLINE_NOTICE_KIND, scheduledTargetOffline } from "./scheduled-offline.js";
10
+
11
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
12
+ if (!activeDbPath) throw new Error("scheduled-offline requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
13
+
14
+ const stamp = Date.now();
15
+ const TARGET_ID = `n_offline_target_${stamp}`;
16
+ const TARGET = `offline-target-${stamp}`;
17
+ const CREATOR_ID = `n_offline_creator_${stamp}`;
18
+ const CREATOR = `offline-creator-${stamp}`;
19
+ let NET = "", USER = "", slot = 0;
20
+
21
+ function seedSchedule(createdByNodeId: string | null = null): any {
22
+ const id = `sched_offline_${crypto.randomUUID()}`;
23
+ db.run(
24
+ `INSERT INTO scheduled_tasks
25
+ (schedule_id, network_id, created_by, created_by_node_id, name, target_node_id, target_alias,
26
+ task_content, priority, schedule_type, schedule_json, timezone, status, next_run_at)
27
+ VALUES (?1, ?2, ?3, ?4, '离线巡检', ?5, ?6, 'ping', 'normal', 'interval',
28
+ '{"type":"interval","every_seconds":60}', 'Asia/Shanghai', 'active', datetime('now', '+1 minute'))`,
29
+ [id, NET, USER, createdByNodeId, TARGET_ID, TARGET],
30
+ );
31
+ return getScheduleRow(id)!;
32
+ }
33
+
34
+ function fire(schedule: any) {
35
+ const at = new Date(Date.UTC(2026, 9, 8, 0, 0, 0) + (++slot) * 60_000).toISOString();
36
+ return dispatchScheduledOccurrence(schedule, at, false, new Date());
37
+ }
38
+
39
+ function setTarget(status: "idle" | "offline", minutesAgo = 0) {
40
+ const seen = new Date(Date.now() - minutesAgo * 60_000).toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "");
41
+ db.run("UPDATE sessions SET status = ?1, updated_at = ?2, last_seen_at = ?2 WHERE node_id = ?3 AND network_id = ?4", [status, seen, TARGET_ID, NET]);
42
+ }
43
+
44
+ const taskCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE network_id = ?1 AND to_node_id = ?2", NET, TARGET_ID)!.n;
45
+ const humanNotices = (scheduleId: string) => db.all<{ meta_json: string; content: string }>(
46
+ "SELECT meta_json, content FROM user_inbox WHERE network_id = ?1 AND user_id = ?2 AND kind = ?3 ORDER BY created_at, message_id",
47
+ NET, USER, SCHEDULE_TARGET_OFFLINE_NOTICE_KIND,
48
+ ).filter((row) => JSON.parse(row.meta_json).schedule_target_offline.schedule_id === scheduleId);
49
+ const agentNotices = (scheduleId: string) => db.all<{ meta_json: string; content: string }>(
50
+ "SELECT meta_json, content FROM inbox WHERE network_id = ?1 AND node_id = ?2 AND from_session = 'hub' ORDER BY created_at, id",
51
+ NET, CREATOR_ID,
52
+ ).filter((row) => JSON.parse(row.meta_json).schedule_target_offline?.schedule_id === scheduleId);
53
+
54
+ beforeAll(() => {
55
+ const owner = register(`offline_owner_${stamp}`, "OfflineOwner123!", undefined, "seed");
56
+ expect(owner.ok).toBe(true);
57
+ NET = owner.network_id!; USER = owner.user!.user_id;
58
+ db.run("INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, owner_user_id) VALUES (?1, ?2, ?2, 'codex', ?3, ?4)", [TARGET_ID, TARGET, NET, USER]);
59
+ db.run("INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, owner_user_id) VALUES (?1, ?2, ?2, 'codex', ?3, ?4)", [CREATOR_ID, CREATOR, NET, USER]);
60
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'))", [`r_target_${stamp}`, TARGET, TARGET_ID, NET]);
61
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'))", [`r_creator_${stamp}`, CREATOR, CREATOR_ID, NET]);
62
+ });
63
+
64
+ afterAll(() => {
65
+ db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [NET, SCHEDULE_TARGET_OFFLINE_NOTICE_KIND]);
66
+ });
67
+
68
+ describe("#757 scheduled target offline", () => {
69
+ test("offline skips without a task, warns a human once, and online recovery dispatches normally", () => {
70
+ const schedule = seedSchedule();
71
+ const online = fire(schedule);
72
+ expect(online.status).toBe("delivered");
73
+ db.run("UPDATE tasks SET status = 'replied' WHERE task_id = ?1", [online.taskId]);
74
+
75
+ setTarget("offline");
76
+ const before = taskCount();
77
+ const first = fire(schedule), second = fire(schedule);
78
+ expect([first.status, second.status]).toEqual(["skipped", "skipped"]);
79
+ expect(taskCount()).toBe(before);
80
+ for (const run of [first, second]) {
81
+ expect(db.get("SELECT status, error_code, task_id FROM scheduled_task_runs WHERE run_id = ?1", run.runId))
82
+ .toEqual({ status: "skipped", error_code: "target_offline", task_id: null });
83
+ }
84
+ expect(humanNotices(schedule.schedule_id)).toHaveLength(1);
85
+ expect(humanNotices(schedule.schedule_id)[0].content).toContain("恢复在线后");
86
+
87
+ setTarget("idle");
88
+ const recovered = fire(schedule);
89
+ expect(recovered.status).toBe("delivered");
90
+ expect(recovered.taskId).toBeTruthy();
91
+ db.run("UPDATE tasks SET status = 'replied' WHERE task_id = ?1", [recovered.taskId]);
92
+
93
+ setTarget("offline");
94
+ expect(fire(schedule).status).toBe("skipped");
95
+ expect(humanNotices(schedule.schedule_id)).toHaveLength(2);
96
+ });
97
+
98
+ test("an Agent-created schedule warns the creator inbox, never the owner's user inbox", () => {
99
+ const schedule = seedSchedule(CREATOR_ID);
100
+ setTarget("offline");
101
+ expect(fire(schedule).status).toBe("skipped");
102
+ expect(fire(schedule).status).toBe("skipped");
103
+ expect(agentNotices(schedule.schedule_id)).toHaveLength(1);
104
+ expect(agentNotices(schedule.schedule_id)[0].content).toContain("不需要回复");
105
+ expect(humanNotices(schedule.schedule_id)).toHaveLength(0);
106
+ });
107
+
108
+ test("an idle session older than the existing five-minute cutoff is offline", () => {
109
+ const schedule = seedSchedule();
110
+ setTarget("idle", 6);
111
+ const before = taskCount();
112
+ const run = fire(schedule);
113
+ expect(run.status).toBe("skipped");
114
+ expect(taskCount()).toBe(before);
115
+ expect(db.get<{ error_code: string }>("SELECT error_code FROM scheduled_task_runs WHERE run_id = ?1", run.runId)?.error_code).toBe("target_offline");
116
+ });
117
+
118
+ test("recent SQLite and ISO timestamps all keep an idle target online", () => {
119
+ const now = Date.now();
120
+ const isoMs = new Date(now - 1_000).toISOString();
121
+ const values = [
122
+ isoMs.slice(0, 19).replace("T", " "),
123
+ `${isoMs.slice(0, 19)}Z`,
124
+ isoMs,
125
+ ];
126
+ for (const value of values) {
127
+ db.run("UPDATE sessions SET status = 'idle', last_seen_at = ?1 WHERE node_id = ?2 AND network_id = ?3", [value, TARGET_ID, NET]);
128
+ expect(scheduledTargetOffline(TARGET_ID, TARGET, NET, now)).toBe(false);
129
+ }
130
+ });
131
+ });
@@ -0,0 +1,66 @@
1
+ import { sendAgentNotice } from "./agent-notice.js";
2
+ import { db, uuidv4 } from "./db.js";
3
+ import { parseDbTimestampMs } from "./db-timestamp.js";
4
+ import { pendingInboxCount } from "./inbox-count.js";
5
+ import { pushEvent } from "./push.js";
6
+
7
+ export const SCHEDULE_TARGET_OFFLINE_NOTICE_KIND = "schedule_target_offline";
8
+ const SESSION_STALE_MS = 5 * 60_000;
9
+
10
+ type SessionRow = { status: string | null; updated_at: string | null; last_seen_at: string | null };
11
+
12
+ /** Same status + five-minute timestamp rule as the existing task delivery resolver. */
13
+ export function scheduledTargetOffline(nodeId: string, alias: string, networkId: string, nowMs = Date.now()): boolean {
14
+ const session = db.get<SessionRow>(
15
+ "SELECT status, updated_at, last_seen_at FROM sessions WHERE node_id = ?1 AND network_id = ?2 ORDER BY updated_at DESC LIMIT 1",
16
+ nodeId, networkId,
17
+ ) ?? db.get<SessionRow>(
18
+ "SELECT status, updated_at, last_seen_at FROM sessions WHERE alias = ?1 AND network_id = ?2 AND node_id IS NULL ORDER BY updated_at DESC LIMIT 1",
19
+ alias, networkId,
20
+ );
21
+ if (!session) return true;
22
+ const raw = session.last_seen_at || session.updated_at;
23
+ const seen = raw ? parseDbTimestampMs(raw) : 0;
24
+ return String(session.status || "").toLowerCase() === "offline" || !Number.isFinite(seen) || nowMs - seen > SESSION_STALE_MS;
25
+ }
26
+
27
+ export type OfflineNotice = {
28
+ scheduleId: string; scheduleName: string; networkId: string;
29
+ createdBy: string | null; createdByNodeId: string | null;
30
+ targetAlias: string;
31
+ };
32
+
33
+ function noticeText(n: OfflineNotice): string {
34
+ return `定时任务「${n.scheduleName}」的目标节点 ${n.targetAlias} 已离线,本轮已跳过且没有创建任务。节点恢复在线后,后续轮次会自动照常派发。`;
35
+ }
36
+
37
+ /** Post-commit delivery. The transaction has already claimed this offline episode. */
38
+ export function sendOfflineNotice(n: OfflineNotice): string | null {
39
+ try {
40
+ const text = noticeText(n);
41
+ const meta = { schedule_target_offline: { schedule_id: n.scheduleId, target_alias: n.targetAlias, reason: "target_offline" } };
42
+ if (!n.createdByNodeId) {
43
+ if (!n.createdBy) return null;
44
+ return sendAgentNotice({
45
+ networkId: n.networkId, userId: n.createdBy, fromAlias: n.targetAlias,
46
+ kind: SCHEDULE_TARGET_OFFLINE_NOTICE_KIND, title: "定时任务目标节点离线", text,
47
+ severity: "warning", meta, idPrefix: "dm_sched_offline_",
48
+ });
49
+ }
50
+ const creator = db.get<{ alias: string | null }>(
51
+ "SELECT alias FROM nodes WHERE node_id = ?1 AND network_id = ?2", n.createdByNodeId, n.networkId,
52
+ );
53
+ if (!creator?.alias) return null;
54
+ const id = `sched_offline_${uuidv4()}`;
55
+ db.run(
56
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id, meta_json)
57
+ VALUES (?1, ?2, ?3, 'message', 'normal', ?4, 'hub', ?5, ?6)`,
58
+ [id, creator.alias, n.createdByNodeId, `[定时任务目标节点离线] ${text}\n(这是告警,不需要回复。)`, n.networkId, JSON.stringify(meta)],
59
+ );
60
+ pushEvent(creator.alias, { type: "new_message", inbox_count: pendingInboxCount(creator.alias, n.networkId), from: "hub", message_id: id }, n.networkId);
61
+ return id;
62
+ } catch (e: any) {
63
+ console.error(`[scheduled-tasks] offline notice failed schedule=${n.scheduleId}: ${e?.message || e}`);
64
+ return null;
65
+ }
66
+ }
@@ -0,0 +1,212 @@
1
+ // 定时任务的回复要进建排程那个人的未读(Vincent 2026-09-30「定时任务触发的…那个红点它是不显示的」)。
2
+ //
3
+ // scheduler 派的任务 from_name='scheduler',回复以前按 from_name 落进 inbox(session_name='scheduler'):
4
+ // app 会话里看得到(会话取自 tasks 表),但 unread_by_agent 只数 session_name=用户名 的行 ⇒ 永远没红点。
5
+ // 这里走真 Hub(bootServer)+ 真节点令牌回复路径(send_reply,callerTokenIsNetwork=true)。
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
9
+ import { db } from "./db.js";
10
+ import { runDueScheduledTasks } from "./scheduled-tasks.js";
11
+ import { registerTools } from "./tools.js";
12
+
13
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
14
+ if (!activeDbPath) throw new Error("scheduled-reply-unread requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
15
+
16
+ let server: any;
17
+ let base = "";
18
+ let ownerToken = "";
19
+ let ownerName = "";
20
+ let memberToken = "";
21
+ let memberName = "";
22
+ let networkId = "";
23
+ let ownerId = "";
24
+ const stamp = Date.now();
25
+ const ALIAS = `sched-reply-node-${stamp}`;
26
+ const nodeId = `n_sched_reply_${stamp}`;
27
+
28
+ async function api(token: string, path: string, init?: RequestInit) {
29
+ const res = await fetch(`${base}${path}`, {
30
+ ...init,
31
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) },
32
+ });
33
+ return { status: res.status, body: await res.json() as any };
34
+ }
35
+
36
+ /** 节点令牌身份调 send_reply(与真节点的 commhub_reply 同一个 handler、同一组归属校验)。 */
37
+ async function nodeReply(args: Record<string, unknown>): Promise<any> {
38
+ const mcp = new McpServer({ name: "sched-reply", version: "0" }) as any;
39
+ const handlers: Record<string, (a: any) => Promise<any>> = {};
40
+ const original = mcp.tool.bind(mcp);
41
+ mcp.tool = (name: string, ...rest: any[]) => {
42
+ const handler = rest.at(-1);
43
+ if (typeof handler === "function") handlers[name] = handler;
44
+ return original(name, ...rest);
45
+ };
46
+ registerTools(mcp, undefined, networkId, ownerId, ALIAS, true, null);
47
+ const out = await handlers.send_reply(args);
48
+ return JSON.parse(out.content[0].text);
49
+ }
50
+
51
+ async function unreadFor(token: string): Promise<Record<string, number>> {
52
+ const res = await api(token, "/api/messages?scope=user&limit=50");
53
+ expect(res.status).toBe(200);
54
+ return res.body.unread_by_agent ?? {};
55
+ }
56
+
57
+ async function ackAgent(token: string) {
58
+ const res = await api(token, "/api/messages/ack", { method: "POST", body: JSON.stringify({ agent: ALIAS }) });
59
+ expect(res.status).toBe(200);
60
+ return res.body;
61
+ }
62
+
63
+ /** 建一个排程、让它立刻到期、跑一次,返回它派出的 task_id。 */
64
+ async function fireSchedule(token: string, name: string): Promise<{ scheduleId: string; taskId: string }> {
65
+ const created = await api(token, "/api/scheduled-tasks", {
66
+ method: "POST",
67
+ body: JSON.stringify({
68
+ network_id: networkId, name, target_node_id: nodeId, task: `${name} tick`,
69
+ timezone: "UTC", schedule: { type: "interval", every_seconds: 120 },
70
+ }),
71
+ });
72
+ expect(created.status).toBe(201);
73
+ const scheduleId = created.body.schedule.schedule_id as string;
74
+ // 别的排程不许在这次 sweep 里一起跑
75
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id != ?2", [new Date(Date.now() + 24 * 3600_000).toISOString(), scheduleId]);
76
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 60_000).toISOString(), scheduleId]);
77
+ await runDueScheduledTasks();
78
+ const run = db.get<{ task_id: string | null }>(
79
+ "SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1 ORDER BY scheduled_for DESC LIMIT 1", scheduleId,
80
+ );
81
+ expect(run?.task_id).toBeTruthy();
82
+ const task = db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE task_id = ?1", run!.task_id!);
83
+ expect(task?.from_name).toBe("scheduler");
84
+ return { scheduleId, taskId: run!.task_id! };
85
+ }
86
+
87
+ function replyRow(taskId: string) {
88
+ return db.get<{ session_name: string; from_session: string; acked: number }>(
89
+ "SELECT session_name, from_session, acked FROM inbox WHERE type = 'reply' AND in_reply_to = ?1", taskId,
90
+ );
91
+ }
92
+
93
+ beforeAll(async () => {
94
+ ownerName = `sched_reply_owner_${stamp}`;
95
+ const owner = register(ownerName, "SchedReplyOwner123!", undefined, "seed");
96
+ expect(owner.ok).toBe(true);
97
+ ownerToken = owner.token!;
98
+ networkId = owner.network_id!;
99
+ ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId)!.owner_id;
100
+
101
+ memberName = `sched_reply_member_${stamp}`;
102
+ const member = register(memberName, "SchedReplyMember123!", undefined, "seed");
103
+ expect(member.ok).toBe(true);
104
+ memberToken = member.token!;
105
+ const memberId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", memberName)!.user_id;
106
+ expect(addNetworkMember(networkId, memberId, "member", ownerId, { agentAccess: "all" }).ok).toBe(true);
107
+
108
+ expect(createNetworkTokenForNode(ownerId, networkId, ALIAS).ok).toBe(true);
109
+ db.run(
110
+ "INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, ?2, ?2, 'claude-code', ?3)",
111
+ [nodeId, ALIAS, networkId],
112
+ );
113
+ db.run(
114
+ "INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)",
115
+ [`r_sched_reply_${stamp}`, ALIAS, nodeId, networkId],
116
+ );
117
+ const mod: any = await import("./server.js");
118
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
119
+ base = `http://127.0.0.1:${server.port}`;
120
+ }, 30_000);
121
+
122
+ afterAll(() => {
123
+ try { server?.stop?.(true); } catch {}
124
+ });
125
+
126
+ describe("scheduled-task replies reach the schedule creator's unread", () => {
127
+ test("a direct reply still lands on the sender and counts once", async () => {
128
+ await ackAgent(ownerToken);
129
+ const sent = await api(ownerToken, "/api/task", { method: "POST", body: JSON.stringify({ network_id: networkId, alias: ALIAS, task: "direct hello" }) });
130
+ expect(sent.status).toBe(200);
131
+ const taskId = sent.body.task_id as string;
132
+ const reply = await nodeReply({ in_reply_to: taskId, text: "direct answer", status: "replied" });
133
+ expect(reply.ok).toBe(true);
134
+ expect(replyRow(taskId)?.session_name).toBe(ownerName);
135
+ expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
136
+ await ackAgent(ownerToken);
137
+ expect((await unreadFor(ownerToken))[ALIAS]).toBeUndefined();
138
+ });
139
+
140
+ test("a scheduled-task reply counts for the creator, and ack-by-agent clears it", async () => {
141
+ await ackAgent(ownerToken);
142
+ const { taskId } = await fireSchedule(ownerToken, "owner-every-2m");
143
+ // 节点不带 alias(SDK 运行时)——收件人由 in_reply_to 推出
144
+ const reply = await nodeReply({ in_reply_to: taskId, text: "tick answer", status: "replied" });
145
+ expect(reply.ok).toBe(true);
146
+ expect(replyRow(taskId)).toEqual({ session_name: ownerName, from_session: ALIAS, acked: 0 });
147
+ expect(db.get<{ status: string; result: string }>("SELECT status, result FROM tasks WHERE task_id = ?1", taskId))
148
+ .toEqual({ status: "replied", result: "tick answer" });
149
+ expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
150
+
151
+ const acked = await ackAgent(ownerToken);
152
+ expect(acked.acked_inbox).toBe(1);
153
+ expect((await unreadFor(ownerToken))[ALIAS]).toBeUndefined();
154
+ expect(replyRow(taskId)?.acked).toBe(1);
155
+ });
156
+
157
+ test("a node that names alias='scheduler' explicitly is routed the same way", async () => {
158
+ await ackAgent(ownerToken);
159
+ const { taskId } = await fireSchedule(ownerToken, "owner-explicit-alias");
160
+ const reply = await nodeReply({ alias: "scheduler", in_reply_to: taskId, text: "explicit answer", status: "replied" });
161
+ expect(reply.ok).toBe(true);
162
+ expect(replyRow(taskId)?.session_name).toBe(ownerName);
163
+ expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
164
+ await ackAgent(ownerToken);
165
+ });
166
+
167
+ test("another member of the network does not get the owner's scheduled reply, and gets their own", async () => {
168
+ await ackAgent(ownerToken);
169
+ await ackAgent(memberToken);
170
+ const ownerRun = await fireSchedule(ownerToken, "owner-only");
171
+ expect((await nodeReply({ in_reply_to: ownerRun.taskId, text: "for owner", status: "replied" })).ok).toBe(true);
172
+ expect((await unreadFor(memberToken))[ALIAS]).toBeUndefined();
173
+ expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
174
+
175
+ const memberRun = await fireSchedule(memberToken, "member-only");
176
+ expect((await nodeReply({ in_reply_to: memberRun.taskId, text: "for member", status: "replied" })).ok).toBe(true);
177
+ expect(replyRow(memberRun.taskId)?.session_name).toBe(memberName);
178
+ expect((await unreadFor(memberToken))[ALIAS]).toBe(1);
179
+ expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
180
+ await ackAgent(ownerToken);
181
+ await ackAgent(memberToken);
182
+ });
183
+
184
+ test("a schedule with no creator falls back to 'scheduler'", async () => {
185
+ await ackAgent(ownerToken);
186
+ const { scheduleId, taskId } = await fireSchedule(ownerToken, "creatorless");
187
+ db.run("UPDATE scheduled_tasks SET created_by = NULL WHERE schedule_id = ?1", [scheduleId]);
188
+ expect((await nodeReply({ in_reply_to: taskId, text: "nobody's", status: "replied" })).ok).toBe(true);
189
+ expect(replyRow(taskId)?.session_name).toBe("scheduler");
190
+ expect((await unreadFor(ownerToken))[ALIAS]).toBeUndefined();
191
+ });
192
+
193
+ test("a creator whose username is also a node alias falls back to 'scheduler' (the collision guard)", async () => {
194
+ await ackAgent(ownerToken);
195
+ const { taskId } = await fireSchedule(ownerToken, "colliding-creator");
196
+ const clashNodeId = `n_clash_${stamp}`;
197
+ db.run(
198
+ "INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, ?2, ?2, 'claude-code', ?3)",
199
+ [clashNodeId, ownerName, networkId],
200
+ );
201
+ try {
202
+ expect((await nodeReply({ in_reply_to: taskId, text: "not a todo", status: "replied" })).ok).toBe(true);
203
+ // 那个同名节点收不到这条(它不是节点的待办),用户名也拿不到(inbox 半边整体跳过)
204
+ expect(replyRow(taskId)?.session_name).toBe("scheduler");
205
+ expect(db.get<{ n: number }>(
206
+ "SELECT COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND in_reply_to = ?2", ownerName, taskId,
207
+ )?.n).toBe(0);
208
+ } finally {
209
+ db.run("DELETE FROM nodes WHERE node_id = ?1", [clashNodeId]);
210
+ }
211
+ });
212
+ });