@sleep2agi/commhub-server 0.9.0-preview.14 → 0.9.0-preview.140

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 (341) 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-department-http.test.ts +148 -0
  16. package/src/agent-groups-perf.test.ts +223 -0
  17. package/src/agent-notice.ts +48 -0
  18. package/src/agent-team-whoami-http.test.ts +98 -0
  19. package/src/agent-teams-http.test.ts +323 -0
  20. package/src/agent-teams.ts +244 -0
  21. package/src/alias-filter.test.ts +72 -0
  22. package/src/alias-filter.ts +42 -0
  23. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  24. package/src/api-host-supervisors-fallback.test.ts +271 -0
  25. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  26. package/src/api-nodes-shape.test.ts +26 -10
  27. package/src/auth-password-change-cli-tokens-http.test.ts +423 -0
  28. package/src/auth-sessions-http.test.ts +250 -0
  29. package/src/auth-validate.test.ts +5 -1
  30. package/src/auth-weak-password-login.test.ts +176 -0
  31. package/src/auth.ts +604 -51
  32. package/src/avatar-validate.test.ts +163 -0
  33. package/src/avatar-validate.ts +103 -0
  34. package/src/binding-generation-http.test.ts +121 -0
  35. package/src/blocked-exit-on-reply.test.ts +122 -0
  36. package/src/boot-side-effect.test.ts +124 -0
  37. package/src/chain-attempt-dedup.test.ts +447 -0
  38. package/src/clone-node.test.ts +248 -0
  39. package/src/clone-node.ts +289 -0
  40. package/src/codex-config-flags.ts +77 -0
  41. package/src/codex-fork-contract.ts +23 -0
  42. package/src/codex-fork-migration.test.ts +38 -0
  43. package/src/commhub-bin-args.test.ts +85 -0
  44. package/src/config-apply-sec1.test.ts +107 -0
  45. package/src/config-apply-validate.test.ts +134 -1
  46. package/src/config-apply-validate.ts +111 -5
  47. package/src/create-node-blocked-gate.test.ts +356 -0
  48. package/src/create-node-network-id.test.ts +79 -0
  49. package/src/create-node-tool-schema.test.ts +49 -0
  50. package/src/create-node-unicode-name.test.ts +120 -0
  51. package/src/create-node-validate.test.ts +199 -7
  52. package/src/create-node-validate.ts +232 -15
  53. package/src/create-node-workdir.test.ts +289 -0
  54. package/src/create-node.ts +88 -23
  55. package/src/daemon-provider-snapshots.test.ts +421 -0
  56. package/src/daemon-provider-snapshots.ts +327 -0
  57. package/src/daemon-token-owner-http.test.ts +236 -0
  58. package/src/dashboard-slash-routing-http.test.ts +109 -0
  59. package/src/db-adapter-guard.test.ts +148 -0
  60. package/src/db-adapter-translate.test.ts +128 -0
  61. package/src/db-adapter.ts +323 -87
  62. package/src/db-timestamp.test.ts +38 -0
  63. package/src/db-timestamp.ts +15 -0
  64. package/src/db.ts +1111 -42
  65. package/src/delivered-without-push.test.ts +140 -0
  66. package/src/department-groups-http.test.ts +209 -0
  67. package/src/department-groups-sync-http.test.ts +328 -0
  68. package/src/department-groups.ts +434 -0
  69. package/src/department-heads-http.test.ts +330 -0
  70. package/src/department-heads.ts +226 -0
  71. package/src/departments-http.test.ts +185 -0
  72. package/src/departments.ts +276 -0
  73. package/src/desktop-message-seam.test.ts +150 -0
  74. package/src/desktop-message.test.ts +368 -0
  75. package/src/dm-file-scope-http.test.ts +178 -0
  76. package/src/env-transport-gate-http.test.ts +146 -0
  77. package/src/env-transport-gate.ts +74 -0
  78. package/src/env-transport-policy.ts +21 -0
  79. package/src/ext-token-shared.test.ts +60 -0
  80. package/src/ext-validation-regression.test.ts +131 -0
  81. package/src/external-schedule-edits-http.test.ts +321 -0
  82. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  83. package/src/external-schedule-edits.ts +327 -0
  84. package/src/file-download-authz-dev-open.test.ts +135 -0
  85. package/src/file-download-authz.test.ts +404 -0
  86. package/src/file-download-date-bucket.test.ts +436 -0
  87. package/src/file-network-scope-dev-open.test.ts +97 -0
  88. package/src/file-network-scope.test.ts +732 -0
  89. package/src/group-membership-events-http.test.ts +264 -0
  90. package/src/group-messages-http.test.ts +482 -0
  91. package/src/group-messages.ts +297 -0
  92. package/src/health-redaction.test.ts +642 -0
  93. package/src/host-supervisor-identity-http.test.ts +152 -0
  94. package/src/host-supervisor-identity.ts +92 -0
  95. package/src/http-gzip.test.ts +94 -0
  96. package/src/http-gzip.ts +112 -0
  97. package/src/hub-catalog-read-http.test.ts +187 -0
  98. package/src/hub-provider-config.test.ts +139 -0
  99. package/src/hub-provider-config.ts +105 -0
  100. package/src/hub-timestamp.test.ts +38 -0
  101. package/src/hub-timestamp.ts +27 -0
  102. package/src/human-dm.ts +215 -0
  103. package/src/human-inbox.ts +42 -0
  104. package/src/inbox-caller.ts +49 -0
  105. package/src/inbox-count-sse.test.ts +310 -0
  106. package/src/inbox-count.ts +29 -0
  107. package/src/index.ts +15 -2362
  108. package/src/lifecycle-guard.ts +1 -1
  109. package/src/list-host-supervisors.test.ts +5 -4
  110. package/src/mcp-context-cost-http.test.ts +123 -0
  111. package/src/mcp-projects-events-http.test.ts +162 -0
  112. package/src/mcp-route-label-http.test.ts +120 -0
  113. package/src/mcp-write-network-resolution.test.ts +432 -0
  114. package/src/member-audit-network-id-http.test.ts +93 -0
  115. package/src/member-presence-http.test.ts +210 -0
  116. package/src/messages-ack-by-agent-http.test.ts +142 -0
  117. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  118. package/src/migrate-sqlite-to-pg.ts +263 -0
  119. package/src/model-auth-notify-http.test.ts +181 -0
  120. package/src/model-auth-notify.ts +116 -0
  121. package/src/network-name-http.test.ts +75 -0
  122. package/src/network-scope.ts +276 -0
  123. package/src/network-token-membership-http.test.ts +139 -0
  124. package/src/node-alias-control.ts +12 -0
  125. package/src/node-attrs-validate.ts +88 -0
  126. package/src/node-attrs.test.ts +217 -0
  127. package/src/node-caller-identity-http.test.ts +1083 -0
  128. package/src/node-create-request-status-http.test.ts +105 -0
  129. package/src/node-daemon-bindings.test.ts +132 -0
  130. package/src/node-daemon-bindings.ts +135 -0
  131. package/src/node-files-transport.test.ts +344 -0
  132. package/src/node-health-codex-login.test.ts +120 -0
  133. package/src/node-health-dispatch-http.test.ts +279 -0
  134. package/src/node-health-guard.ts +105 -0
  135. package/src/node-health-passthrough.test.ts +115 -0
  136. package/src/node-health-store.ts +101 -0
  137. package/src/node-id-alias.test.ts +190 -0
  138. package/src/node-identity-conflict-http.test.ts +269 -0
  139. package/src/node-identity-conflict.ts +142 -0
  140. package/src/node-identity-mismatch.test.ts +74 -0
  141. package/src/node-lifecycle-controllable.test.ts +64 -0
  142. package/src/node-lifecycle-controllable.ts +53 -0
  143. package/src/node-lifecycle-read-http.test.ts +245 -0
  144. package/src/node-lifecycle-read.ts +121 -0
  145. package/src/node-logs-transport.test.ts +263 -0
  146. package/src/node-permissions-http.test.ts +276 -0
  147. package/src/node-permissions.ts +285 -0
  148. package/src/node-request-retention.test.ts +281 -0
  149. package/src/node-request-retention.ts +120 -0
  150. package/src/node-secret-rest-http.test.ts +190 -0
  151. package/src/node-skills-transport.test.ts +209 -0
  152. package/src/node-token-identity.ts +35 -0
  153. package/src/node-token-ownership.ts +51 -0
  154. package/src/node-token-privilege-http.test.ts +158 -0
  155. package/src/observer-avatar-http.test.ts +442 -0
  156. package/src/observer-push.test.ts +252 -0
  157. package/src/opencode-v2-catalog.test.ts +268 -0
  158. package/src/opencode-v2-catalog.ts +352 -0
  159. package/src/package-version-consistency.test.ts +25 -0
  160. package/src/password-network-tokens.ts +25 -0
  161. package/src/peer-reply-atomic.test.ts +455 -0
  162. package/src/peer-reply-capability.test.ts +70 -0
  163. package/src/pg-worker.ts +87 -0
  164. package/src/project-dir-warn-clear-http.test.ts +132 -0
  165. package/src/project-viewer-can-http.test.ts +114 -0
  166. package/src/push.ts +429 -10
  167. package/src/redact-tokens.test.ts +65 -0
  168. package/src/redact-tokens.ts +40 -0
  169. package/src/rename.ts +38 -10
  170. package/src/reply-target-derivation.test.ts +96 -0
  171. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  172. package/src/report-status-host-schema.test.ts +235 -0
  173. package/src/report-status-resume-id-handover.test.ts +184 -0
  174. package/src/report-status-secret-keys.test.ts +140 -0
  175. package/src/require-explicit-test-db.ts +9 -0
  176. package/src/requirement-due-reminders-http.test.ts +620 -0
  177. package/src/requirement-due-reminders.ts +416 -0
  178. package/src/requirement-events-http.test.ts +268 -0
  179. package/src/requirement-events.test.ts +45 -0
  180. package/src/requirement-events.ts +144 -0
  181. package/src/requirement-notify.ts +165 -0
  182. package/src/requirement-participant-notify-http.test.ts +309 -0
  183. package/src/requirement-tag-ops-http.test.ts +266 -0
  184. package/src/requirement-tags.ts +89 -0
  185. package/src/requirements-abandoned-http.test.ts +236 -0
  186. package/src/requirements-comment-http.test.ts +167 -0
  187. package/src/requirements-completed-at-migration.test.ts +70 -0
  188. package/src/requirements-errors-mcp-http.test.ts +164 -0
  189. package/src/requirements-errors.ts +143 -0
  190. package/src/requirements-etag-http.test.ts +91 -0
  191. package/src/requirements-http.test.ts +709 -0
  192. package/src/requirements-last-event-http.test.ts +349 -0
  193. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  194. package/src/requirements-list-slim-http.test.ts +319 -0
  195. package/src/requirements-mcp.test.ts +163 -0
  196. package/src/requirements-migrate.ts +259 -0
  197. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  198. package/src/requirements-people-http.test.ts +114 -0
  199. package/src/requirements-people-mcp-http.test.ts +225 -0
  200. package/src/requirements-people.ts +131 -0
  201. package/src/requirements-priority-lowest.test.ts +114 -0
  202. package/src/requirements-search-http.test.ts +182 -0
  203. package/src/requirements-search.test.ts +32 -0
  204. package/src/requirements-search.ts +145 -0
  205. package/src/requirements-seq-http.test.ts +193 -0
  206. package/src/requirements-seq-migration.test.ts +63 -0
  207. package/src/requirements-start-date.test.ts +125 -0
  208. package/src/requirements-stats-http.test.ts +357 -0
  209. package/src/requirements-stats.ts +195 -0
  210. package/src/requirements-status-participants-http.test.ts +222 -0
  211. package/src/requirements-upgrade-e2e.test.ts +126 -0
  212. package/src/requirements-upgrade-migration.test.ts +98 -0
  213. package/src/requirements.ts +1806 -0
  214. package/src/resolve-port.test.ts +57 -0
  215. package/src/resolve-port.ts +44 -0
  216. package/src/response-charset.test.ts +130 -0
  217. package/src/rest-explicit-columns-http.test.ts +222 -0
  218. package/src/rest-identity.test.ts +130 -0
  219. package/src/rest-identity.ts +141 -0
  220. package/src/rest-projections.ts +70 -0
  221. package/src/rest-write-network-resolution.test.ts +133 -0
  222. package/src/restricted-files.ts +76 -0
  223. package/src/retention.ts +15 -3
  224. package/src/route-callers-http.test.ts +120 -0
  225. package/src/route-timing.test.ts +211 -0
  226. package/src/route-timing.ts +258 -0
  227. package/src/rules-file-duplicate-alias.test.ts +234 -0
  228. package/src/rules-file-session-target.test.ts +207 -0
  229. package/src/rules-file-transport.test.ts +237 -0
  230. package/src/runtime-label.test.ts +22 -0
  231. package/src/runtime-label.ts +21 -0
  232. package/src/runtime-readiness.ts +90 -0
  233. package/src/schedule-agent-mcp-http.test.ts +381 -0
  234. package/src/schedule-agent.ts +192 -0
  235. package/src/scheduled-backoff-http.test.ts +193 -0
  236. package/src/scheduled-backoff.ts +64 -0
  237. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  238. package/src/scheduled-failures-http.test.ts +233 -0
  239. package/src/scheduled-failures.ts +179 -0
  240. package/src/scheduled-offline.test.ts +131 -0
  241. package/src/scheduled-offline.ts +66 -0
  242. package/src/scheduled-outcome-guidance-http.test.ts +139 -0
  243. package/src/scheduled-outcome-guidance.ts +9 -0
  244. package/src/scheduled-reply-unread-http.test.ts +212 -0
  245. package/src/scheduled-requirement-wake-http.test.ts +264 -0
  246. package/src/scheduled-requirement-wake.ts +125 -0
  247. package/src/scheduled-run-terminal.test.ts +268 -0
  248. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  249. package/src/scheduled-stuck-http.test.ts +312 -0
  250. package/src/scheduled-stuck.ts +186 -0
  251. package/src/scheduled-tasks-http.test.ts +638 -0
  252. package/src/scheduled-tasks.ts +819 -0
  253. package/src/scheduled-work-outcome-http.test.ts +154 -0
  254. package/src/scheduled-work-outcome.ts +50 -0
  255. package/src/scoped-secrets.ts +139 -0
  256. package/src/send-dedup-request-id.test.ts +229 -0
  257. package/src/send-reply-agent-warning.test.ts +202 -0
  258. package/src/send-reply-alias-optional.test.ts +131 -0
  259. package/src/send-reply-attachments.test.ts +458 -0
  260. package/src/send_dedup.test.ts +54 -0
  261. package/src/send_dedup.ts +52 -6
  262. package/src/serve-error.test.ts +38 -0
  263. package/src/serve-error.ts +27 -0
  264. package/src/server.ts +5398 -0
  265. package/src/session-task-on-dispatch.ts +14 -0
  266. package/src/session-token-ownership-http.test.ts +122 -0
  267. package/src/shared/codex-inventory.ts +41 -0
  268. package/src/shared/daemon-provider-login.ts +31 -0
  269. package/src/shared/daemon-provider-snapshot.ts +52 -0
  270. package/src/shared/external-schedule-contract.ts +60 -0
  271. package/src/shared/node-name.test.ts +62 -0
  272. package/src/shared/node-name.ts +161 -0
  273. package/src/shared/provider-application.ts +29 -0
  274. package/src/shared/provider-collection.ts +14 -0
  275. package/src/shared/provider-initialization.ts +8 -0
  276. package/src/shared/reserved-env-drift.test.ts +10 -0
  277. package/src/shared/scoped-secret-contract.ts +5 -0
  278. package/src/shared/task-content-limit-drift.test.ts +28 -0
  279. package/src/shared/task-content-limit.ts +25 -0
  280. package/src/side-thread-command-transport.test.ts +312 -0
  281. package/src/side-thread-command-transport.ts +318 -0
  282. package/src/side-thread-http-integration.test.ts +233 -0
  283. package/src/side-thread-http.ts +504 -0
  284. package/src/side-thread-production.test.ts +72 -0
  285. package/src/side-thread-production.ts +118 -0
  286. package/src/side-thread.test.ts +1067 -0
  287. package/src/side-thread.ts +2499 -0
  288. package/src/skillhub-http.test.ts +108 -0
  289. package/src/sqlite-close-checkpoint.test.ts +78 -0
  290. package/src/start-node.test.ts +264 -0
  291. package/src/status-alias-filter-http.test.ts +106 -0
  292. package/src/status-alias-resolver-http.test.ts +163 -0
  293. package/src/status-alias-resolver.ts +19 -0
  294. package/src/status-node-id-filter-http.test.ts +161 -0
  295. package/src/status-read-cache-http.test.ts +229 -0
  296. package/src/status-read-cache.ts +122 -0
  297. package/src/stop-delete-node.test.ts +509 -2
  298. package/src/task-access-http.test.ts +402 -0
  299. package/src/task-access.ts +420 -0
  300. package/src/task-activity.ts +60 -0
  301. package/src/task-auth-origin-http.test.ts +93 -0
  302. package/src/task-auth-origin.test.ts +24 -0
  303. package/src/task-auth-origin.ts +7 -0
  304. package/src/task-consumption.test.ts +482 -0
  305. package/src/task-diagnostic-http.test.ts +149 -0
  306. package/src/task-diagnostic.test.ts +115 -0
  307. package/src/task-diagnostic.ts +118 -0
  308. package/src/task-expiry-notice-http.test.ts +287 -0
  309. package/src/task-expiry-notice.ts +315 -0
  310. package/src/task-idempotency-mcp.test.ts +142 -0
  311. package/src/task-idempotency.test.ts +60 -0
  312. package/src/task-idempotency.ts +75 -0
  313. package/src/task-late-replies.ts +54 -0
  314. package/src/task-late-reply-http.test.ts +152 -0
  315. package/src/task-lifecycle-watcher.test.ts +204 -0
  316. package/src/task-lifecycle-watcher.ts +49 -0
  317. package/src/task-network-resolution-http.test.ts +140 -0
  318. package/src/task-orphan-http.test.ts +287 -0
  319. package/src/task-orphan.ts +119 -0
  320. package/src/task-queue-ahead-http.test.ts +309 -0
  321. package/src/task-queue-ahead-scale.test.ts +236 -0
  322. package/src/task-queue-ahead.ts +186 -0
  323. package/src/task-rejection-log-http.test.ts +160 -0
  324. package/src/task-rejection-log.ts +49 -0
  325. package/src/task-sender-http.test.ts +143 -0
  326. package/src/task-stale-open-http.test.ts +290 -0
  327. package/src/task-stale-open.ts +103 -0
  328. package/src/tasks-pagination-http.test.ts +141 -0
  329. package/src/tool-audience-http.test.ts +220 -0
  330. package/src/tool-audience.ts +118 -0
  331. package/src/tools-list-json-schema.test.ts +67 -0
  332. package/src/tools.ts +4008 -355
  333. package/src/upload-early-reject-guard.test.ts +155 -0
  334. package/src/uploads-http.test.ts +97 -15
  335. package/src/uploads.ts +103 -6
  336. package/src/user-avatar-http.test.ts +74 -0
  337. package/src/user-avatar-migration.test.ts +25 -0
  338. package/src/user-inbox-by-agent-http.test.ts +161 -0
  339. package/src/user-inbox-netid-notnull.test.ts +122 -0
  340. package/src/user-inbox-read-http.test.ts +223 -0
  341. package/src/user-inbox-schema.test.ts +54 -0
@@ -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
+ });
@@ -0,0 +1,264 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createNetworkTokenForNode, register } from "./auth.js";
3
+ import { db, syncScheduledRunForTask } from "./db.js";
4
+ import { consumeRequirementWake, pollRequirementWakes } from "./scheduled-requirement-wake.js";
5
+ import { refreshScheduledBackoff } from "./scheduled-backoff.js";
6
+ import { getScheduleRow, dispatchScheduledOccurrence, runDueScheduledTasks } from "./scheduled-tasks.js";
7
+
8
+ if (!process.env.COMMHUB_DB) throw new Error("test1279 requires an explicit disposable SQLite COMMHUB_DB");
9
+ let server: any, base = "", network = "", ownerToken = "", ownerId = "";
10
+ const stamp = Date.now(), nodes: Record<string, { id: string; alias: string; token: string; tokenId: string }> = {};
11
+ async function rest(token: string, method: string, path: string, body?: unknown) {
12
+ const r = await fetch(base + path, { method, headers: { Authorization: `Bearer ${token}`,
13
+ "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
14
+ return { status: r.status, body: await r.json() as any };
15
+ }
16
+ async function mcp(token: string, name: string, args: Record<string, unknown>) {
17
+ const r = await fetch(base + "/mcp", { method: "POST", headers: { Authorization: `Bearer ${token}`,
18
+ "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
19
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
20
+ const raw = await r.text(), line = raw.split("\n").find(x => x.startsWith("data: "));
21
+ const envelope = JSON.parse(line ? line.slice(6) : raw);
22
+ try { return JSON.parse(envelope.result.content[0].text); }
23
+ catch { return { ok: false, transportError: true }; }
24
+ }
25
+ const readRun = (taskId: string) => db.get<any>(
26
+ "SELECT run_id, schedule_id, status, work_outcome, work_outcome_node_id, schedule_revision FROM scheduled_task_runs WHERE task_id = ?1 AND network_id = ?2", taskId, network)!;
27
+ const readTask = (taskId: string) => db.get<any>("SELECT status, result FROM tasks WHERE task_id = ?1", taskId)!;
28
+ const countReplies = (taskId: string) => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM inbox WHERE in_reply_to = ?1 AND type = 'reply'", taskId)!.n;
29
+ async function fire(enabled = true, interval = 120) {
30
+ const created = await rest(ownerToken, "POST", "/api/scheduled-tasks", { network_id: network,
31
+ name: `test1279-${crypto.randomUUID()}`, target_node_id: nodes.a.id, task: "fixture1278 work",
32
+ schedule: { type: "interval", every_seconds: interval, ...(enabled ? { requirement_wake: { tag: "ANet" } } : {}) }, timezone: "UTC", no_change_backoff: enabled });
33
+ expect(created.status).toBe(201);
34
+ const scheduleId = created.body.schedule.schedule_id;
35
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id != ?2", [new Date(Date.now() + 86400_000).toISOString(), scheduleId]);
36
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 1000).toISOString(), scheduleId]);
37
+ await runDueScheduledTasks();
38
+ const run = db.get<any>("SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1", scheduleId)!;
39
+ expect(run.task_id).toBeTruthy();
40
+ return { taskId: run.task_id as string, scheduleId };
41
+ }
42
+ const reply = (taskId: string, meta?: unknown, status = "replied", token = nodes.a.token, extra = {}) =>
43
+ mcp(token, "send_reply", { in_reply_to: taskId, text: "本轮无新增", status, ...(meta === undefined ? {} : { meta }), ...extra });
44
+ const cadence = (id: string) => db.get<any>("SELECT next_run_at, last_run_at, revision, schedule_json, target_node_id FROM scheduled_tasks WHERE schedule_id = ?1", id)!;
45
+
46
+ beforeAll(async () => {
47
+ const owner = register(`work_owner_${stamp}`, "Fixture1274Only!x"); ownerToken = owner.token!;
48
+ network = owner.network_id!; ownerId = owner.user!.user_id;
49
+ for (const key of ["a", "b"]) {
50
+ const id = `n_work_${key}_${stamp}`, alias = `work-${key}-${stamp}`;
51
+ const token = createNetworkTokenForNode(ownerId, network, alias, id); expect(token.ok).toBe(true);
52
+ nodes[key] = { id, alias, token: token.token!, tokenId: token.token_id! };
53
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_${id}`, alias, id, network]);
54
+ }
55
+ const mod = await import("./server.js"); server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
56
+ base = `http://127.0.0.1:${server.port}`;
57
+ }, 30_000);
58
+ afterAll(() => server?.stop?.(true));
59
+
60
+
61
+ const state = (id: string) => db.get<any>("SELECT no_change_backoff, backoff_streak, backoff_effective_seconds, backoff_last_run_id, next_run_at, revision, schedule_json, status, requirement_wake_json, requirement_wake_cursor, requirement_wake_last_event_id FROM scheduled_tasks WHERE schedule_id = ?1", id)!;
62
+ let occurrence = 0;
63
+ async function nextRun(id: string) {
64
+ // Isolated fixture accelerates the clock, without touching live schedules.
65
+ const date = new Date(Date.now() + (++occurrence) * 1000).toISOString();
66
+ const out = dispatchScheduledOccurrence(getScheduleRow(id)!, date, true);
67
+ expect(out.taskId).toBeTruthy(); return out.taskId!;
68
+ }
69
+ async function empties(id: string, n: number) {
70
+ for (let i = 0; i < n; i++) expect((await reply(await nextRun(id), { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
71
+ }
72
+ async function atThree() {
73
+ const x = await fire(); expect((await reply(x.taskId, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
74
+ await empties(x.scheduleId, 2); return x;
75
+ }
76
+
77
+ async function card(body: Record<string, unknown> = {}, token = ownerToken) {
78
+ const c = await rest(token, "POST", "/api/requirements", { network_id: network, name: "domain1279", tags: ["ANet", "知识库"], ...body });
79
+ expect(c.status).toBe(201); return c.body.requirement;
80
+ }
81
+ async function column(id: string, status: string, token = ownerToken) {
82
+ const c = await rest(token, "PATCH", `/api/requirements/${id}`, { column: status }); expect(c.status).toBe(200);
83
+ }
84
+ function poll(id: string) { return consumeRequirementWake(id, network); }
85
+ describe("#1279 authoritative scoped requirement-event wake over HTTP/MCP", () => {
86
+ test("new matching work restores base with a durable cursor and fences old completion", async () => {
87
+ const { scheduleId } = await atThree(), before = state(scheduleId), old = await nextRun(scheduleId);
88
+ const c = await card(); expect(poll(scheduleId)).toBe(true);
89
+ const after = state(scheduleId); expect(after).toMatchObject({ backoff_streak: 0, backoff_effective_seconds: null, revision: before.revision + 1, schedule_json: before.schedule_json });
90
+ expect(after.requirement_wake_cursor).toBeGreaterThan(before.requirement_wake_cursor);
91
+ expect(after.requirement_wake_last_event_id).toBeTruthy();
92
+ expect(Date.parse(after.next_run_at) - Date.now()).toBeLessThanOrEqual(120_000);
93
+ expect((await reply(old, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
94
+ expect(state(scheduleId).backoff_streak).toBe(0); expect(state(scheduleId).revision).toBe(after.revision);
95
+ expect(poll(scheduleId)).toBe(false); expect(state(scheduleId)).toEqual(after);
96
+ const wire = await rest(ownerToken, "GET", `/api/scheduled-tasks/${scheduleId}`);
97
+ expect(wire.body.schedule.schedule.requirement_wake).toEqual({ tag: "ANet", project_id: null });
98
+ expect(JSON.stringify(wire.body.schedule)).not.toContain(ownerId);
99
+ expect(c.id).toBeTruthy();
100
+ });
101
+ test("a matching event is consumed within normal reply before the old empty signal can delay cadence", async () => {
102
+ const { scheduleId } = await atThree(), task = await nextRun(scheduleId), revision = state(scheduleId).revision;
103
+ await card(); expect((await reply(task, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
104
+ expect(state(scheduleId)).toMatchObject({ backoff_streak: 0, backoff_effective_seconds: null, revision: revision + 1 });
105
+ });
106
+ test("existing scheduler tick scans delayed plans even before their next due", async () => {
107
+ const { scheduleId } = await atThree(), before = state(scheduleId); await card();
108
+ runDueScheduledTasks(); expect(state(scheduleId).revision).toBe(before.revision + 1);
109
+ expect(state(scheduleId).backoff_streak).toBe(0);
110
+ });
111
+ test("old events and duplicate consumption do not replay after enable or process reopen", async () => {
112
+ await card(); const { scheduleId } = await atThree(), before = state(scheduleId); expect(poll(scheduleId)).toBe(false);
113
+ const child = Bun.spawn([process.execPath, "-e", `import {consumeRequirementWake} from './server/src/scheduled-requirement-wake.ts'; console.log(JSON.stringify({wake:consumeRequirementWake(${JSON.stringify(scheduleId)},${JSON.stringify(network)})}));`], { cwd: process.cwd(), stdout: "pipe", stderr: "pipe", env: process.env });
114
+ const output = await new Response(child.stdout).text(); expect(await child.exited).toBe(0);
115
+ expect(JSON.parse(output.trim().split("\n").at(-1)!)).toEqual({ wake: false }); expect(state(scheduleId)).toEqual(before);
116
+ });
117
+ test("status transitions wake while comments/checklists/no-op edits do not", async () => {
118
+ const c = await card({ checklist: [{ id: "one", text: "unit", done: false }] });
119
+ const { scheduleId } = await atThree(), before = state(scheduleId);
120
+ expect((await mcp(ownerToken, "requirements_comment", { id: c.id, network_id: network, text: "routine checkpoint" })).ok).toBe(true);
121
+ expect((await mcp(ownerToken, "requirements_checklist_toggle", { id: c.id, network_id: network, item_id: "one", done: true })).ok).toBe(true);
122
+ await column(c.id, "pool"); expect(poll(scheduleId)).toBe(false);
123
+ expect(state(scheduleId).revision).toBe(before.revision); expect(state(scheduleId).backoff_streak).toBe(3);
124
+ await column(c.id, "doing"); expect(poll(scheduleId)).toBe(true);
125
+ });
126
+ test("self node writes and iteration archives do not wake; knowledge-tagged real work can wake", async () => {
127
+ const { scheduleId } = await atThree(), before = state(scheduleId);
128
+ expect((await mcp(nodes.a.token, "requirements_create", { name: "self source", tags: ["ANet"], network_id: network })).ok).toBe(true);
129
+ await card({ external_ref: "iteration:fixture1279" }); expect(poll(scheduleId)).toBe(false);
130
+ expect(state(scheduleId).revision).toBe(before.revision);
131
+ await card(); expect(poll(scheduleId)).toBe(true);
132
+ });
133
+ test("other-node real work wakes and unrelated tags or networks do not", async () => {
134
+ const { scheduleId } = await atThree(), before = state(scheduleId);
135
+ await card({ tags: ["unrelated"] }); expect(poll(scheduleId)).toBe(false);
136
+ const foreign = register(`foreign1279_${crypto.randomUUID()}`, "Fixture1279Only!x");
137
+ expect((await rest(foreign.token!, "POST", "/api/requirements", { network_id: foreign.network_id!, name: "foreign", tags: ["ANet"] })).status).toBe(201);
138
+ expect(poll(scheduleId)).toBe(false); expect(state(scheduleId).revision).toBe(before.revision);
139
+ expect((await mcp(nodes.b.token, "requirements_create", { name: "other real work", tags: ["ANet"], network_id: network })).ok).toBe(true);
140
+ expect(poll(scheduleId)).toBe(true);
141
+ });
142
+ test("creator MCP config roundtrip retains watch through batch/metadata edits; another node cannot edit it", async () => {
143
+ const created = await mcp(nodes.a.token, "schedule_create", { name: "agent wake1279", task: "fixture", no_change_backoff: true, schedule: { type: "interval", every_seconds: 120, requirement_wake: { tag: "ANet" } } });
144
+ expect(created.ok).toBe(true); const id = created.schedule.schedule_id;
145
+ expect(created.schedule.schedule.requirement_wake.tag).toBe("ANet");
146
+ expect((await mcp(nodes.b.token, "schedule_update", { schedule_id: id, schedule: { type: "interval", every_seconds: 180, requirement_wake: null } })).ok).toBe(false);
147
+ expect((await mcp(nodes.a.token, "schedule_batch_interval", { schedule_ids: [id], every_seconds: 180 })).ok).toBe(true);
148
+ expect(state(id).requirement_wake_json).toBeTruthy();
149
+ expect((await mcp(nodes.a.token, "schedule_update", { schedule_id: id, name: "metadata" })).ok).toBe(true);
150
+ expect(state(id).requirement_wake_json).toBeTruthy();
151
+ expect((await mcp(nodes.a.token, "schedule_update", { schedule_id: id, no_change_backoff: false })).ok).toBe(true);
152
+ expect(state(id).requirement_wake_json).toBeNull();
153
+ });
154
+ test("paused/cancelled plans do not wake; resume rebases the cursor rather than replaying hidden time", async () => {
155
+ const { scheduleId } = await atThree();
156
+ expect((await rest(ownerToken, "PATCH", `/api/scheduled-tasks/${scheduleId}`, { revision: state(scheduleId).revision, status: "paused" })).status).toBe(200);
157
+ const before = state(scheduleId); await card(); expect(poll(scheduleId)).toBe(false); expect(state(scheduleId)).toEqual(before);
158
+ expect((await rest(ownerToken, "PATCH", `/api/scheduled-tasks/${scheduleId}`, { revision: state(scheduleId).revision, status: "active" })).status).toBe(200);
159
+ expect(poll(scheduleId)).toBe(false); expect(state(scheduleId).backoff_streak).toBe(0);
160
+ expect((await rest(ownerToken, "DELETE", `/api/scheduled-tasks/${scheduleId}`)).status).toBe(200);
161
+ const canceled = state(scheduleId); await card(); expect(poll(scheduleId)).toBe(false); expect(state(scheduleId)).toEqual(canceled);
162
+ });
163
+ test("current restricted target visibility is enforced even in permission log mode, grants never replay skipped history", async () => {
164
+ const { scheduleId } = await atThree(), before = state(scheduleId);
165
+ db.run("UPDATE nodes SET permission_mode = 'restricted' WHERE node_id = ?1", nodes.a.id);
166
+ try { await card(); expect(poll(scheduleId)).toBe(false); expect(state(scheduleId).revision).toBe(before.revision); }
167
+ finally { db.run("UPDATE nodes SET permission_mode = 'normal' WHERE node_id = ?1", nodes.a.id); }
168
+ expect(poll(scheduleId)).toBe(false);
169
+ await card(); expect(poll(scheduleId)).toBe(true);
170
+ });
171
+ test("creator membership loss, read-only target and owner replacement suppress wake and consume without replay", async () => {
172
+ for (const change of ["membership", "readonly", "owner"]) {
173
+ const { scheduleId } = await atThree(), before = state(scheduleId);
174
+ if (change === "membership") db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [network, ownerId]);
175
+ if (change === "readonly") db.run("UPDATE nodes SET permission_mode = 'readonly' WHERE node_id = ?1", nodes.a.id);
176
+ if (change === "owner") db.run("UPDATE nodes SET owner_user_id = NULL WHERE node_id = ?1", nodes.a.id);
177
+ // Fixture-only trusted event insertion after revocation; public mutation
178
+ // itself may now refuse, and must not be weakened for this scenario.
179
+ const c = db.get<any>("SELECT requirement_id,seq,title FROM requirements WHERE network_id = ?1 LIMIT 1", network)!;
180
+ db.run("INSERT INTO requirement_events(network_id,requirement_id,seq,title,actor_json,kind,field,old_json,new_json,created_at) VALUES(?1,?2,?3,?4,?5,'changed','column','\"pool\"','\"doing\"',datetime('now'))", [network,c.requirement_id,c.seq,c.title,JSON.stringify({kind:"user",id:ownerId})]);
181
+ try { expect(poll(scheduleId)).toBe(false); expect(state(scheduleId).revision).toBe(before.revision); }
182
+ finally {
183
+ if (change === "membership") db.run("INSERT INTO network_members(network_id,user_id,role,task_access) VALUES(?1,?2,'owner','all')", [network,ownerId]);
184
+ if (change === "readonly") db.run("UPDATE nodes SET permission_mode = 'normal' WHERE node_id = ?1", nodes.a.id);
185
+ if (change === "owner") db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2", [ownerId,nodes.a.id]);
186
+ }
187
+ expect(poll(scheduleId)).toBe(false); expect(state(scheduleId).revision).toBe(before.revision);
188
+ }
189
+ });
190
+ test("cursor write fault rolls back wake and the normal reply together; retry consumes exactly once", async () => {
191
+ const { scheduleId } = await atThree(), task = await nextRun(scheduleId); await card(); const before = state(scheduleId);
192
+ db.exec(`CREATE TRIGGER test1279_fault BEFORE UPDATE OF requirement_wake_cursor ON scheduled_tasks WHEN NEW.schedule_id='${scheduleId}' BEGIN SELECT RAISE(ABORT,'test1279_fault'); END`);
193
+ try { expect((await reply(task, { scheduled_work_outcome: "no_changes" })).ok).not.toBe(true); }
194
+ finally { db.exec("DROP TRIGGER test1279_fault"); }
195
+ expect(state(scheduleId)).toEqual(before); expect(readTask(task).status).toBe("delivered");
196
+ expect(readRun(task).work_outcome).toBeNull(); expect(countReplies(task)).toBe(0);
197
+ expect((await reply(task, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
198
+ expect(state(scheduleId).revision).toBe(before.revision + 1); expect(poll(scheduleId)).toBe(false);
199
+ });
200
+ test("invalid/empty/forged authority filters refuse without creating a plan; explicit null disables", async () => {
201
+ for (const policy of [true, {}, {tag:""}, {tag:"ANet", creator_user_id:ownerId}]) {
202
+ const out = await rest(ownerToken, "POST", "/api/scheduled-tasks", { network_id:network, name:"invalid1279",target_node_id:nodes.a.id,task:"fixture",no_change_backoff:true,schedule:{type:"interval",every_seconds:120,requirement_wake:policy} }); expect(out.status).toBe(400);
203
+ }
204
+ const {scheduleId}=await atThree();
205
+ expect((await rest(ownerToken,"PATCH",`/api/scheduled-tasks/${scheduleId}`,{revision:state(scheduleId).revision,schedule:{type:"interval",every_seconds:120,requirement_wake:null}})).status).toBe(200);
206
+ await card();expect(poll(scheduleId)).toBe(false);expect(state(scheduleId).requirement_wake_json).toBeNull();
207
+ });
208
+ test("project filter and current target project grants limit wake, revoked grants never replay", async () => {
209
+ const p1 = await rest(ownerToken,"POST","/api/requirements/projects",{network_id:network,name:"project1279"}); expect(p1.status).toBe(201); const project=p1.body.project.id;
210
+ const {scheduleId}=await atThree();
211
+ const scoped=register(`scoped1279_${crypto.randomUUID()}`,"Fixture1279Only!x"), uid=scoped.user!.user_id;
212
+ db.run("INSERT INTO network_members(network_id,user_id,role,task_access) VALUES(?1,?2,'member','scoped')",[network,uid]);
213
+ db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2",[uid,nodes.a.id]);
214
+ try {
215
+ const edit=await rest(ownerToken,"PATCH",`/api/scheduled-tasks/${scheduleId}`,{revision:state(scheduleId).revision,schedule:{type:"interval",every_seconds:120,requirement_wake:{project_id:project,tag:"ANet"}}}); expect(edit.status).toBe(200);
216
+ const base=state(scheduleId);
217
+ await card({project_id:project}); expect(poll(scheduleId)).toBe(false); expect(state(scheduleId).revision).toBe(base.revision);
218
+ expect((await rest(ownerToken,"PUT",`/api/networks/${network}/members/${uid}/task-grants`,{task_access:"scoped",project_grants:[{project_id:project}]})).status).toBe(200);
219
+ expect(poll(scheduleId)).toBe(false); // no replay after gaining access
220
+ await card(); expect(poll(scheduleId)).toBe(false); // tag alone is insufficient
221
+ await card({project_id:project}); expect(poll(scheduleId)).toBe(true);
222
+ expect((await rest(ownerToken,"PUT",`/api/networks/${network}/members/${uid}/task-grants`,{task_access:"scoped",project_grants:[]})).status).toBe(200);
223
+ const before=state(scheduleId); await card({project_id:project}); expect(poll(scheduleId)).toBe(false); expect(state(scheduleId).revision).toBe(before.revision);
224
+ } finally { db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2",[ownerId,nodes.a.id]); }
225
+ });
226
+ test("a scoped editor cannot borrow the original creator's event visibility", async () => {
227
+ const {scheduleId}=await atThree();
228
+ const scoped=register(`editor1279_${crypto.randomUUID()}`,"Fixture1279Only!x"), uid=scoped.user!.user_id;
229
+ db.run("INSERT INTO network_members(network_id,user_id,role,task_access) VALUES(?1,?2,'member','scoped')",[network,uid]);
230
+ const edit=await rest(scoped.token!,"PATCH",`/api/scheduled-tasks/${scheduleId}`,{revision:state(scheduleId).revision,schedule:{type:"interval",every_seconds:120,requirement_wake:{tag:"ANet"}}}); expect(edit.status).toBe(200);
231
+ const before=state(scheduleId); await card(); expect(poll(scheduleId)).toBe(false); expect(state(scheduleId).revision).toBe(before.revision);
232
+ await card({participants:[{kind:"user",id:uid}]}); expect(poll(scheduleId)).toBe(true);
233
+ const project=await rest(ownerToken,"POST","/api/requirements/projects",{network_id:network,name:"hidden1279"}); expect(project.status).toBe(201);
234
+ expect((await rest(scoped.token!,"PATCH",`/api/scheduled-tasks/${scheduleId}`,{revision:state(scheduleId).revision,schedule:{type:"interval",every_seconds:120,requirement_wake:{project_id:project.body.project.id}}})).status).toBe(400);
235
+ });
236
+ test("manual wake rebases pending events so a stale event cannot reset again",async()=>{
237
+ const {scheduleId}=await atThree();await card();const before=state(scheduleId);
238
+ const out=await rest(ownerToken,"POST",`/api/scheduled-tasks/${scheduleId}/run-now`);expect(out.status).toBe(202);
239
+ expect(state(scheduleId).revision).toBe(before.revision+1);expect(poll(scheduleId)).toBe(false);
240
+ });
241
+ test("bounded cursor pages do not lose a later matching event",async()=>{
242
+ const c=await card(),{scheduleId}=await atThree(),revision=state(scheduleId).revision;
243
+ db.transaction(()=>{for(let i=0;i<270;i++)db.run("INSERT INTO requirement_events(network_id,requirement_id,actor_json,kind,new_json,created_at) VALUES(?1,?2,?3,'comment','{}',datetime('now'))",[network,c.id,JSON.stringify({kind:"user",id:ownerId})]);});
244
+ await card();expect(poll(scheduleId)).toBe(false);expect(state(scheduleId).revision).toBe(revision);
245
+ expect(poll(scheduleId)).toBe(true);expect(state(scheduleId).revision).toBe(revision+1);expect(poll(scheduleId)).toBe(false);
246
+ });
247
+ test("two independent SQLite processes consume the event once and persist one generation",async()=>{
248
+ const {scheduleId}=await atThree();await card();const before=state(scheduleId), gate=`/tmp/wake1279-${crypto.randomUUID()}`;
249
+ const code=`import {consumeRequirementWake} from './server/src/scheduled-requirement-wake.ts'; import {existsSync} from 'node:fs'; console.log('READY'); while(!existsSync(${JSON.stringify(gate)}))await Bun.sleep(10); for(let i=0;;i++){try{console.log(JSON.stringify({wake:consumeRequirementWake(${JSON.stringify(scheduleId)},${JSON.stringify(network)})})); break;}catch(e){if(i>=4||!String(e).includes('locked'))throw e;await Bun.sleep(20);}}`;
250
+ const workers=[] as any[];
251
+ // Initialize each adapter before releasing both against the same DB;
252
+ // schema startup is not the race under qualification.
253
+ for(let i=0;i<2;i++){
254
+ const child=Bun.spawn([process.execPath,"-e",code],{cwd:process.cwd(),stdout:"pipe",stderr:"pipe",env:process.env});
255
+ const reader=child.stdout.getReader();let start="";
256
+ for(;;){const part=await reader.read();if(part.done)throw new Error('worker exited before ready');start+=new TextDecoder().decode(part.value);if(start.includes('READY'))break;}
257
+ workers.push({child,reader,start});
258
+ }
259
+ await Bun.write(gate,"go");const results=[];
260
+ for(const w of workers){let output=w.start;for(;;){const part=await w.reader.read();if(part.done)break;output+=new TextDecoder().decode(part.value);}expect(await w.child.exited).toBe(0);results.push(JSON.parse(output.trim().split("\n").at(-1)!).wake);}
261
+ expect(results.sort()).toEqual([false,true]);expect(state(scheduleId).revision).toBe(before.revision+1);expect(poll(scheduleId)).toBe(false);
262
+ });
263
+
264
+ });
@@ -0,0 +1,125 @@
1
+ import { db } from "./db.js";
2
+ import { ensureRequirementEvents } from "./requirement-events.js";
3
+ import { canSeeTask, canUseProject, type TaskCaller } from "./task-access.js";
4
+ import { setRequirementWakeHook } from "./scheduled-backoff.js";
5
+ import { parseHubTimestamp } from "./hub-timestamp.js";
6
+
7
+ ensureRequirementEvents(db);
8
+ export type RequirementWake = { project_id: string | null; tag: string | null };
9
+ type StoredWake = RequirementWake & { creator_user_id: string; target_owner_user_id: string; configured_by_user_id: string };
10
+ type Plan = { schedule_id?: string; network_id: string; created_by: string | null;
11
+ created_by_node_id: string | null; target_node_id: string };
12
+
13
+ /** No implicit broad access when identity/membership is gone. Read current
14
+ * node mode and owner, rather than a token alias or a cached permission flag. */
15
+ function principal(userId: string | null, network: string, nodeId?: string | null): TaskCaller {
16
+ let only = false;
17
+ if (nodeId) {
18
+ const node = db.get<any>("SELECT owner_user_id, permission_mode FROM nodes WHERE node_id = ?1 AND network_id = ?2", nodeId, network);
19
+ if (!node || node.permission_mode === "readonly") return null;
20
+ userId = node.owner_user_id; only = node.permission_mode === "restricted";
21
+ }
22
+ if (!userId || !db.get("SELECT 1 FROM network_members m JOIN users u ON u.user_id = m.user_id WHERE m.user_id = ?1 AND m.network_id = ?2 AND m.role != 'viewer'", userId, network)) return null;
23
+ return { userId, ...(nodeId ? { node: { id: nodeId, only } } : {}) };
24
+ }
25
+ function audience(row: Plan) {
26
+ return { creator: principal(row.created_by, row.network_id, row.created_by_node_id),
27
+ target: principal(null, row.network_id, row.target_node_id) };
28
+ }
29
+ export function parseRequirementWake(raw: unknown, fallback: string | null = null): RequirementWake | null {
30
+ if (raw === undefined) return fallback ? publicRequirementWake(fallback) : null;
31
+ if (raw === null) return null;
32
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid_requirement_wake");
33
+ const p = raw as Record<string, unknown>;
34
+ if (Object.keys(p).some(k => k !== "project_id" && k !== "tag")) throw new Error("invalid_requirement_wake");
35
+ const project = p.project_id ?? null, tag = p.tag ?? null;
36
+ if (project !== null && (typeof project !== "string" || !project || project.length > 200)
37
+ || tag !== null && (typeof tag !== "string" || !tag.trim() || tag.length > 20)
38
+ || project === null && tag === null) throw new Error("invalid_requirement_wake");
39
+ return { project_id: project as string | null, tag: tag as string | null };
40
+ }
41
+ export function publicRequirementWake(json: string): RequirementWake {
42
+ const p = JSON.parse(json); return { project_id: p.project_id, tag: p.tag };
43
+ }
44
+ export function requirementWakeHead(network: string): number {
45
+ return Number(db.get<any>("SELECT COALESCE(MAX(id), 0) AS id FROM requirement_events WHERE network_id = ?1", network)!.id);
46
+ }
47
+ /** Called by the existing authorized creator/editor paths. No production
48
+ * plans are opted in by migration. The config records the creator authority. */
49
+ export function bindRequirementWake(row: Plan, policy: RequirementWake | null, enabled: boolean, type: string, configuratorUserId: string | null): string | null {
50
+ if (!policy) return null;
51
+ if (!enabled || type !== "interval") throw new Error("requirement_wake_requires_backoff_interval");
52
+ const a = audience(row), configurator = principal(configuratorUserId, row.network_id);
53
+ if (!a.creator || !a.target || !configurator) throw new Error("requirement_wake_identity_unresolved");
54
+ if (policy.project_id && (!db.get("SELECT 1 FROM requirement_projects WHERE project_id = ?1 AND network_id = ?2", policy.project_id, row.network_id)
55
+ || !canUseProject(a.creator, row.network_id, policy.project_id) || !canUseProject(configurator, row.network_id, policy.project_id))) throw new Error("requirement_wake_scope_not_visible");
56
+ return JSON.stringify({ ...policy, creator_user_id: a.creator.userId, target_owner_user_id: a.target.userId, configured_by_user_id: configurator.userId } satisfies StoredWake);
57
+ }
58
+
59
+ /** One cursor and generation update, atomic with the calling reply/dispatch
60
+ * transaction. Any write fault propagates; no consume-then-forget window. */
61
+ export function consumeRequirementWake(id: string, network: string, now = new Date()): boolean {
62
+ if (db.transactionalFeaturesRefusal !== null) return false;
63
+ return db.transaction(() => {
64
+ const row = db.get<any>(`SELECT schedule_id, network_id, created_by, created_by_node_id, target_node_id,
65
+ status, revision, schedule_json, no_change_backoff, requirement_wake_json, requirement_wake_cursor,
66
+ next_run_at FROM scheduled_tasks WHERE schedule_id = ?1 AND network_id = ?2`, id, network);
67
+ if (!row || row.status !== "active" || row.no_change_backoff !== 1 || !row.requirement_wake_json) return false;
68
+ const spec = JSON.parse(row.schedule_json); if (spec.type !== "interval") return false;
69
+ const policy: StoredWake = JSON.parse(row.requirement_wake_json), a = audience(row);
70
+ const configurator = principal(policy.configured_by_user_id, network);
71
+ const allowed = configurator && a.creator && a.target && a.creator.userId === policy.creator_user_id
72
+ && a.target.userId === policy.target_owner_user_id;
73
+ // Cursor is advanced even for invisible/irrelevant events, so a later
74
+ // permission grant cannot replay history hidden at the consumption time.
75
+ const events = db.all<any>(`SELECT id, requirement_id, actor_json, kind, field, old_json, new_json
76
+ FROM requirement_events WHERE network_id = ?1 AND id > ?2 ORDER BY id LIMIT 256`, network, row.requirement_wake_cursor);
77
+ if (!events.length) return false;
78
+ let matching: number | null = null;
79
+ for (const event of events) {
80
+ if (!allowed || !(event.kind === "created" || event.kind === "changed" && event.field === "column")) continue;
81
+ let actor: any; try { actor = JSON.parse(event.actor_json); } catch { continue; }
82
+ if (!actor || !["user", "node"].includes(actor.kind) || typeof actor.id !== "string") continue;
83
+ if (actor.kind === "node" && [row.target_node_id, row.created_by_node_id].includes(actor.id)) continue;
84
+ if (event.kind === "changed") {
85
+ const states = ["pool", "doing", "done", "abandoned"];
86
+ let before: unknown, after: unknown;
87
+ try { before = JSON.parse(event.old_json); after = JSON.parse(event.new_json); } catch { continue; }
88
+ if (!states.includes(before as string) || !states.includes(after as string) || before === after) continue;
89
+ }
90
+ const card = db.get<any>(`SELECT network_id, project_id, tags_json, external_ref, archived, owner_json,
91
+ participants_json, created_by, created_by_json, agent_owner_json, parent_id
92
+ FROM requirements WHERE requirement_id = ?1 AND network_id = ?2`, event.requirement_id, network);
93
+ if (!card || card.archived || card.external_ref?.startsWith("iteration:")) continue;
94
+ if (policy.project_id && card.project_id !== policy.project_id) continue;
95
+ if (policy.tag) { let tags: unknown; try { tags = JSON.parse(card.tags_json); } catch { continue; }
96
+ if (!Array.isArray(tags) || !tags.includes(policy.tag)) continue; }
97
+ if (!canSeeTask(a.creator!, card) || !canSeeTask(a.target!, card) || !canSeeTask(configurator!, card)) continue;
98
+ matching = Number(event.id);
99
+ }
100
+ const cursor = Number(events.at(-1)!.id);
101
+ const due = parseHubTimestamp(row.next_run_at);
102
+ if (matching && row.next_run_at && due === null) throw new Error("requirement_wake_invalid_due_time");
103
+ const next = matching && row.next_run_at
104
+ ? new Date(Math.min(due!, now.getTime() + spec.every_seconds * 1000)).toISOString() : row.next_run_at;
105
+ const result = db.run(`UPDATE scheduled_tasks SET requirement_wake_cursor = ?1,
106
+ requirement_wake_last_event_id = CASE WHEN ?2 IS NOT NULL THEN ?2 ELSE requirement_wake_last_event_id END,
107
+ backoff_streak = CASE WHEN ?2 IS NOT NULL THEN 0 ELSE backoff_streak END,
108
+ backoff_effective_seconds = CASE WHEN ?2 IS NOT NULL THEN NULL ELSE backoff_effective_seconds END,
109
+ backoff_last_run_id = CASE WHEN ?2 IS NOT NULL THEN NULL ELSE backoff_last_run_id END,
110
+ revision = revision + CASE WHEN ?2 IS NOT NULL THEN 1 ELSE 0 END, next_run_at = ?3,
111
+ updated_at = CASE WHEN ?2 IS NOT NULL THEN datetime('now') ELSE updated_at END
112
+ WHERE schedule_id = ?4 AND network_id = ?5 AND revision = ?6 AND requirement_wake_cursor = ?7
113
+ AND status = 'active' AND no_change_backoff = 1`, [cursor, matching, next, id, network, row.revision, row.requirement_wake_cursor]);
114
+ if (result.changes !== 1) throw new Error("requirement_wake_atomic_write_failed");
115
+ return matching !== null;
116
+ });
117
+ }
118
+ /** Existing scheduler tick also scans opt-in plans whose next due is delayed. */
119
+ export function pollRequirementWakes(now = new Date()): void {
120
+ if (db.transactionalFeaturesRefusal !== null) return;
121
+ const rows = db.all<any>(`SELECT schedule_id, network_id FROM scheduled_tasks
122
+ WHERE status = 'active' AND no_change_backoff = 1 AND requirement_wake_json IS NOT NULL`);
123
+ for (const row of rows) consumeRequirementWake(row.schedule_id, row.network_id, now);
124
+ }
125
+ setRequirementWakeHook(consumeRequirementWake);