@desplega.ai/agent-swarm 1.156.0 → 1.158.0

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 (263) hide show
  1. package/dist/{acp-adapter-c1msptrw.js → acp-adapter-xaq4n6pc.js} +1 -1
  2. package/dist/{actions-wd41qtdc.js → actions-p9hz5yat.js} +15 -15
  3. package/dist/{app-tz2qrnah.js → app-g26wdcz6.js} +4 -4
  4. package/dist/{artifact-td4q4dch.js → artifact-3v5t9jtg.js} +1 -1
  5. package/dist/{assistant-c3q1zj2t.js → assistant-wydfqkb3.js} +16 -16
  6. package/dist/{boot-reembed-0be1krkm.js → boot-reembed-g5ckg9yb.js} +6 -6
  7. package/dist/{boot-reembed-r00j03f3.js → boot-reembed-se8zmdcp.js} +5 -5
  8. package/dist/{boot-scrub-logs-9j4hd0vw.js → boot-scrub-logs-wfxqan61.js} +3 -3
  9. package/dist/{seed-pricing-ncgxz1r2.js → boot-seeds-s3q01xj3.js} +10 -10
  10. package/dist/{claude-adapter-112w28qc.js → claude-adapter-tv2x4f36.js} +7 -7
  11. package/dist/{claude-managed-adapter-ea2aga6w.js → claude-managed-adapter-41b2s7fd.js} +2 -2
  12. package/dist/{claude-sdk-session-k7pb5kfz.js → claude-sdk-session-t442qz6f.js} +8 -8
  13. package/dist/{cli-e781c6f9.js → cli-0xjkj1j7.js} +1 -0
  14. package/dist/{cli-58rgvkt4.js → cli-1hkrch3k.js} +2 -2
  15. package/dist/{cli-mx3bzbv8.js → cli-20cj37vt.js} +1968 -44
  16. package/dist/{cli-h0wdadsx.js → cli-4198qc9v.js} +7 -3
  17. package/dist/{cli-24w39x6n.js → cli-47m6dkd3.js} +362 -126
  18. package/dist/{cli-90x9ewbz.js → cli-53sz8gkz.js} +1 -1
  19. package/dist/{cli-005pze6s.js → cli-5d10vd2m.js} +1 -1
  20. package/dist/{cli-08n8jxcw.js → cli-65hjmev5.js} +57 -17
  21. package/dist/{cli-scwq51gn.js → cli-68w09sjw.js} +7 -1
  22. package/dist/{cli-7j2098n5.js → cli-7gtvvpbh.js} +4 -4
  23. package/dist/{cli-b05d7szr.js → cli-7s3syk22.js} +18 -11
  24. package/dist/{cli-9w4ex8vh.js → cli-8qmme6vd.js} +2 -2
  25. package/dist/{cli-28wkjpdp.js → cli-987hg0aa.js} +2 -2
  26. package/dist/{cli-7pgcpcrg.js → cli-9httdpyq.js} +1 -1
  27. package/dist/{cli-jk26c1sz.js → cli-aky8tctm.js} +26 -93
  28. package/dist/{cli-865swy5p.js → cli-c07r9a2m.js} +3 -2
  29. package/dist/{cli-nyqza443.js → cli-fgqxwrpv.js} +291 -12
  30. package/dist/{cli-9pyah9c4.js → cli-ftfvggzx.js} +633 -166
  31. package/dist/{cli-ytxs7mn1.js → cli-g8djp6qe.js} +17 -0
  32. package/dist/{cli-avkd6mqv.js → cli-h967nb6r.js} +551 -0
  33. package/dist/{cli-y8bfxjtw.js → cli-j55n64b6.js} +23 -22
  34. package/dist/{cli-7vj52450.js → cli-j57qkrpq.js} +86 -28
  35. package/dist/{cli-tjmghmm5.js → cli-q67y4wzm.js} +6 -3
  36. package/dist/{cli-n6zq3kbd.js → cli-rj19hc34.js} +1 -1
  37. package/dist/{cli-tp0d7vzb.js → cli-rjehvrts.js} +4 -4
  38. package/dist/{cli-s8hpxeg4.js → cli-rqz8qbw7.js} +245 -75
  39. package/dist/{cli-p37sg0a9.js → cli-s8eet254.js} +1 -1
  40. package/dist/cli-t1ze64h3.js +811 -0
  41. package/dist/{cli-hc2fkhdv.js → cli-ws6rqycm.js} +3 -2
  42. package/dist/{cli-t1cd3mhp.js → cli-wscdvcqz.js} +13 -5
  43. package/dist/{cli-xa4j64zh.js → cli-x24pshtq.js} +5 -5
  44. package/dist/{cli-nbp6m6gp.js → cli-xberxa1r.js} +51 -26
  45. package/dist/{cli-szcyv1mm.js → cli-xhaeg2nc.js} +1 -1
  46. package/dist/cli.js +30 -16
  47. package/dist/{codex-adapter-g4tj1f2g.js → codex-adapter-cnm8wk11.js} +5 -5
  48. package/dist/{codex-session-runner-ad8ha7zt.js → codex-session-runner-gfg547zj.js} +5 -5
  49. package/dist/{commands-z3gd8ppc.js → commands-e3bn0p75.js} +3 -3
  50. package/dist/{db-bezm97r9.js → db-03v9kzr2.js} +17 -3
  51. package/dist/{dispatcher-a97hc33y.js → dispatcher-nrmg8yht.js} +14 -14
  52. package/dist/{dsh-adapter-1dwg97zv.js → dsh-adapter-k03nfqa6.js} +2 -2
  53. package/dist/{handlers-wtdy1x02.js → handlers-aqcedbyb.js} +17 -17
  54. package/dist/{hook-7edgmzng.js → hook-7vkskkre.js} +19 -11
  55. package/dist/{hook-fvs58a5b.js → hook-ybvmrmhq.js} +4 -4
  56. package/dist/{http-phnf93hp.js → http-2eqa8t24.js} +432 -195
  57. package/dist/{index-7x131mmf.js → index-1yks3qcr.js} +14 -14
  58. package/dist/{index-1fktvwxc.js → index-a3vn96n8.js} +31 -28
  59. package/dist/{index-yp8rasxk.js → index-c6zsv1bd.js} +5 -5
  60. package/dist/{index-50jxgw0d.js → index-chgdkq36.js} +14 -14
  61. package/dist/{index-7jwk7nsm.js → index-x71bvq0p.js} +15 -15
  62. package/dist/{keepalive-nnz07ebd.js → keepalive-vtkf5n99.js} +5 -5
  63. package/dist/{lead-mg7qcgww.js → lead-h3sp357x.js} +26 -26
  64. package/dist/{maintenance-ywmnqrpx.js → maintenance-jzeqtk3q.js} +6 -6
  65. package/dist/{oauth-refresh-sweep-c63sqfs1.js → oauth-refresh-sweep-qp3dzmdp.js} +4 -4
  66. package/dist/{onboard-2zj8sqkq.js → onboard-tmsp0qbz.js} +278 -81
  67. package/dist/{opencode-adapter-z64xjzec.js → opencode-adapter-x6x2da1z.js} +3 -3
  68. package/dist/{otel-impl-jgdfm26f.js → otel-impl-9mz73q65.js} +416 -301
  69. package/dist/{pi-mono-adapter-2b7mw16r.js → pi-mono-adapter-m6dvv0dz.js} +2 -2
  70. package/dist/{pricing-refresh-3whntb69.js → pricing-refresh-v1cbr9te.js} +5 -5
  71. package/dist/{rbac-roles-3zptcc3p.js → rbac-roles-c9wqz83x.js} +4 -4
  72. package/dist/{render-v2-dbvpyysc.js → render-v2-gryrh9h8.js} +4 -4
  73. package/dist/{setup-qwx0j8z9.js → setup-q9720bt6.js} +2 -2
  74. package/dist/{virtual-modules-8qheft18.js → virtual-modules-3htpgn8x.js} +1 -1
  75. package/dist/{work-objects-sjbcmvep.js → work-objects-b76x7deb.js} +17 -17
  76. package/dist/{worker-xezqdkb7.js → worker-6vrxbv41.js} +26 -26
  77. package/openapi.json +1626 -629
  78. package/package.json +3 -2
  79. package/src/apps/catalog.generated.json +229 -0
  80. package/src/apps/page-validator.ts +71 -16
  81. package/src/apps/row-store.ts +66 -11
  82. package/src/apps/sync.ts +59 -17
  83. package/src/artifact-sdk/browser-sdk.ts +436 -2
  84. package/src/be/approval-sweeps.ts +109 -0
  85. package/src/be/boot-seeds.ts +40 -0
  86. package/src/be/chunking.ts +1 -1
  87. package/src/be/db/agents.ts +20 -0
  88. package/src/be/db/approvals.ts +78 -0
  89. package/src/be/db/tasks/read.ts +197 -39
  90. package/src/be/db/tasks/write.ts +41 -9
  91. package/src/be/db-client.ts +107 -48
  92. package/src/be/db.ts +219 -76
  93. package/src/be/events.ts +33 -1
  94. package/src/be/memory/graph-expansion.ts +10 -3
  95. package/src/be/memory/providers/sqlite-store.ts +55 -13
  96. package/src/be/memory/raters/retrieval.ts +5 -2
  97. package/src/be/memory/reranker.ts +26 -2
  98. package/src/be/memory/types.ts +2 -0
  99. package/src/be/migrations/166_approval_request_auto_cancellation.sql +9 -0
  100. package/src/be/migrations/171_memory_retrieval_relevance.sql +8 -0
  101. package/src/be/migrations/175_workflow_runs_list_index.sql +11 -0
  102. package/src/be/migrations/177_events_event_agent_created_index.sql +11 -0
  103. package/src/be/models-catalog.ts +2 -0
  104. package/src/be/modelsdev-cache.json +1857 -0
  105. package/src/be/modelsdev-cache.ts +14 -1
  106. package/src/be/pricing-normalize.ts +2 -0
  107. package/src/be/scripts/typecheck.ts +1 -0
  108. package/src/be/seed-pricing.ts +3 -1
  109. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  110. package/src/be/steering.ts +53 -3
  111. package/src/be/swarm-config-guard.ts +3 -0
  112. package/src/be/task-citations.ts +29 -0
  113. package/src/cli.tsx +14 -1
  114. package/src/commands/onboard/compose-generator.ts +8 -3
  115. package/src/commands/onboard/container-engine.ts +220 -0
  116. package/src/commands/onboard/manifest.ts +1 -0
  117. package/src/commands/onboard/steps/health-check.tsx +4 -1
  118. package/src/commands/onboard/steps/prereq-check.tsx +50 -54
  119. package/src/commands/onboard/steps/start.tsx +31 -16
  120. package/src/commands/onboard/types.ts +7 -2
  121. package/src/commands/onboard.tsx +39 -4
  122. package/src/commands/profile-sync.ts +79 -26
  123. package/src/commands/runner.ts +8 -1
  124. package/src/extensions/catalog.generated.json +17 -0
  125. package/src/extensions/contract-types.generated.ts +1 -1
  126. package/src/heartbeat/heartbeat.ts +263 -103
  127. package/src/hooks/hook.ts +21 -12
  128. package/src/http/agents.ts +35 -0
  129. package/src/http/approval-requests.ts +136 -64
  130. package/src/http/apps.ts +14 -3
  131. package/src/http/config.ts +15 -1
  132. package/src/http/events.ts +62 -2
  133. package/src/http/index.ts +6 -4
  134. package/src/http/memory.ts +21 -7
  135. package/src/http/schedules.ts +1 -1
  136. package/src/http/session-data.ts +9 -4
  137. package/src/http/sessions.ts +25 -18
  138. package/src/http/stats.ts +19 -7
  139. package/src/http/status.ts +11 -1
  140. package/src/http/tasks.ts +40 -8
  141. package/src/http/webhooks.ts +67 -46
  142. package/src/http/workflows.ts +15 -20
  143. package/src/linear/outbound.ts +37 -15
  144. package/src/otel-impl.ts +151 -1
  145. package/src/otel.ts +14 -3
  146. package/src/prompts/memories.ts +19 -2
  147. package/src/providers/claude-managed-models.ts +8 -0
  148. package/src/providers/codex-agents-md.ts +140 -17
  149. package/src/providers/modelsdev-reasoning.json +551 -0
  150. package/src/rbac/legacy-policy.ts +16 -0
  151. package/src/rbac/permissions.ts +4 -0
  152. package/src/scheduler/deferred-task-waits.ts +28 -0
  153. package/src/scheduler/schedule-task.ts +3 -0
  154. package/src/scheduler/scheduler.ts +14 -0
  155. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  156. package/src/scripts-runtime/swarm-sdk.ts +9 -0
  157. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  158. package/src/scripts-runtime/types/swarm-extension.d.ts +1 -0
  159. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  160. package/src/server.ts +12 -7
  161. package/src/slack/app.ts +35 -4
  162. package/src/slack/connection-state.ts +17 -0
  163. package/src/tasks/context-key.ts +1 -0
  164. package/src/tasks/worker-follow-up.ts +6 -2
  165. package/src/tests/agent-task-activity.test.ts +74 -0
  166. package/src/tests/agents-list-model-display.test.ts +6 -1
  167. package/src/tests/approval-auto-cancellation-migration.test.ts +104 -0
  168. package/src/tests/approval-requests.test.ts +395 -0
  169. package/src/tests/approval-sweeps-config.test.ts +47 -0
  170. package/src/tests/approval-sweeps.test.ts +508 -0
  171. package/src/tests/apps-kibo-blocks.test.ts +77 -0
  172. package/src/tests/apps-spike.test.ts +56 -1
  173. package/src/tests/apps-sync-engine.test.ts +20 -9
  174. package/src/tests/boot-seeds.test.ts +170 -0
  175. package/src/tests/cancel-approval-request-tool.test.ts +115 -0
  176. package/src/tests/claude-managed-adapter.test.ts +6 -0
  177. package/src/tests/codex-adapter.test.ts +126 -1
  178. package/src/tests/config-key-filter.test.ts +80 -0
  179. package/src/tests/context-window.test.ts +1 -0
  180. package/src/tests/db-client-busy-retry.test.ts +96 -0
  181. package/src/tests/db-client.test.ts +60 -1
  182. package/src/tests/db-task-read-contract.test.ts +10 -0
  183. package/src/tests/db-task-write-contract.test.ts +9 -0
  184. package/src/tests/deferred-schedule-reaper.test.ts +193 -0
  185. package/src/tests/docs-index-footprint.test.js +3 -3
  186. package/src/tests/events-http.test.ts +103 -0
  187. package/src/tests/extensions-catalog.test.ts +22 -17
  188. package/src/tests/extensions-pre-task.test.ts +107 -0
  189. package/src/tests/fixtures/kibo-blocks-definition.json.txt +105 -0
  190. package/src/tests/follow-up-config-inheritance.test.ts +260 -0
  191. package/src/tests/heartbeat-orphan-resume.test.ts +84 -0
  192. package/src/tests/heartbeat-reboot-live.test.ts +95 -0
  193. package/src/tests/heartbeat-stall-cas.test.ts +80 -0
  194. package/src/tests/heartbeat.test.ts +99 -3
  195. package/src/tests/hook-mcp-config.test.ts +29 -0
  196. package/src/tests/http-api-integration.test.ts +16 -2
  197. package/src/tests/kv-storage.test.ts +62 -0
  198. package/src/tests/linear-outbound-sync.test.ts +120 -5
  199. package/src/tests/list-endpoint-slimming.test.ts +91 -0
  200. package/src/tests/memory-graph-organic-route.test.ts +155 -0
  201. package/src/tests/memory-http-recall-gating.test.ts +14 -4
  202. package/src/tests/memory-hybrid.test.ts +161 -1
  203. package/src/tests/memory-prompt-recall-crowdout.test.ts +297 -0
  204. package/src/tests/memory.test.ts +22 -0
  205. package/src/tests/onboard-container-engine.test.ts +240 -0
  206. package/src/tests/otel-event-loop-and-logs.test.ts +99 -0
  207. package/src/tests/pricing-routes.test.ts +14 -0
  208. package/src/tests/profile-sync-warning.test.ts +8 -4
  209. package/src/tests/promote-draft-task-route.test.ts +19 -0
  210. package/src/tests/provider-registration.test.ts +296 -42
  211. package/src/tests/rbac-engine.test.ts +30 -0
  212. package/src/tests/reasoning-effort.test.ts +8 -0
  213. package/src/tests/script-connections.test.ts +7 -0
  214. package/src/tests/sdk-allowlist.test.ts +39 -0
  215. package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
  216. package/src/tests/sessions-detail-batch.test.ts +250 -0
  217. package/src/tests/sessions.test.ts +118 -0
  218. package/src/tests/slack-config.test.ts +22 -0
  219. package/src/tests/sonnet-5-5-registry.test.ts +44 -0
  220. package/src/tests/status.test.ts +36 -1
  221. package/src/tests/swarm-diff.test.ts +316 -10
  222. package/src/tests/task-completion-idempotency.test.ts +2 -1
  223. package/src/tests/tasks-requested-by-filter.test.ts +3 -1
  224. package/src/tests/tool-annotations.test.ts +1 -1
  225. package/src/tests/ui-list-slim-projections.test.ts +158 -0
  226. package/src/tests/webhooks-task-blocked.test.ts +127 -0
  227. package/src/tests/workflow-approval-recovery.test.ts +203 -0
  228. package/src/tests/workflow-hitl-routing.test.ts +22 -1
  229. package/src/tests/workflow-hitl-slack-card.test.ts +52 -0
  230. package/src/tests/workflow-http-v2.test.ts +99 -8
  231. package/src/tests/workflow-retry-validation.test.ts +56 -0
  232. package/src/tests/workflow-tla-races.test.ts +389 -0
  233. package/src/tests/workflow-validation-port-routing.test.ts +113 -1
  234. package/src/tools/cancel-approval-request.ts +57 -0
  235. package/src/tools/memory-search.ts +1 -0
  236. package/src/tools/request-human-input.ts +4 -2
  237. package/src/tools/schedules/list-schedules.ts +4 -2
  238. package/src/tools/send-task.ts +4 -0
  239. package/src/tools/templates.ts +19 -0
  240. package/src/tools/tool-config.ts +2 -1
  241. package/src/tools/workflows/list-workflow-runs.ts +1 -0
  242. package/src/types.ts +38 -0
  243. package/src/utils/context-window.ts +1 -0
  244. package/src/workflows/approval-cancel.ts +152 -0
  245. package/src/workflows/approval-notifications.ts +84 -0
  246. package/src/workflows/checkpoint.ts +10 -5
  247. package/src/workflows/definition.ts +21 -0
  248. package/src/workflows/engine.ts +152 -21
  249. package/src/workflows/executors/human-in-the-loop.ts +1 -1
  250. package/src/workflows/recovery.ts +100 -45
  251. package/src/workflows/resume.ts +49 -18
  252. package/src/workflows/retry-poller.ts +71 -21
  253. package/templates/extensions/README.md +1 -0
  254. package/templates/extensions/github-sender-allowlist/README.md +18 -0
  255. package/templates/extensions/github-sender-allowlist/hooks.ts +55 -0
  256. package/templates/extensions/github-sender-allowlist/manifest.json +8 -0
  257. package/templates/skills/apps/content.md +20 -1
  258. package/templates/skills/code-quality/content.md +26 -1
  259. package/templates/skills/pages/content.md +16 -4
  260. package/templates/skills/pages/files/examples/multiplayer-board.html +3 -12
  261. package/templates/skills/scheduling/content.md +1 -1
  262. package/dist/cli-2daksqps.js +0 -378
  263. package/dist/rbac-roles-3zw68x72.js +0 -37
@@ -202,6 +202,102 @@ describe("db-client SQLITE_BUSY retry", () => {
202
202
  expect(Date.now() - started).toBeLessThan(500);
203
203
  });
204
204
 
205
+ describe("under the production 5000ms ambient busy_timeout", () => {
206
+ /** Largest gap between 5ms ticks while `work` runs: the longest the loop was frozen. */
207
+ async function maxLoopFreezeMs(work: () => Promise<unknown>): Promise<number> {
208
+ let last = performance.now();
209
+ let maxGap = 0;
210
+ const ticker = setInterval(() => {
211
+ const now = performance.now();
212
+ maxGap = Math.max(maxGap, now - last);
213
+ last = now;
214
+ }, 5);
215
+ try {
216
+ await work();
217
+ // One more tick so a freeze that ended on the last attempt is seen.
218
+ await new Promise((resolve) => setTimeout(resolve, 20));
219
+ } finally {
220
+ clearInterval(ticker);
221
+ }
222
+ return maxGap;
223
+ }
224
+
225
+ beforeEach(() => {
226
+ main.exec("PRAGMA busy_timeout = 5000");
227
+ });
228
+
229
+ test("a contended write still succeeds via the async retry", async () => {
230
+ const released = holdWriteLock(300);
231
+ const freezeMs = await maxLoopFreezeMs(() =>
232
+ client.run("INSERT INTO items (name) VALUES (?)", ["contended"]),
233
+ );
234
+ await released;
235
+ const landed = await client.get<{ name: string }>(
236
+ "SELECT name FROM items WHERE name = 'contended'",
237
+ );
238
+ expect(landed?.name).toBe("contended");
239
+ // Every attempt spins at most attemptSpinMs (25ms) or finalAttemptSpinMs;
240
+ // the release happened during an async backoff sleep.
241
+ expect(freezeMs).toBeLessThan(1_000);
242
+ });
243
+
244
+ test("the final statement attempt spins finalAttemptSpinMs, not the ambient timeout", async () => {
245
+ const tight = createBunSqliteClient(() => main, {
246
+ maxWaitMs: 60,
247
+ backoffMs: [20, 20],
248
+ attemptSpinMs: 25,
249
+ finalAttemptSpinMs: 50,
250
+ });
251
+ // The holder releases on a timer, which cannot fire while the final
252
+ // attempt blocks the loop: pre-fix this froze for the full 5000ms.
253
+ const released = holdWriteLock(3_000);
254
+ let caught: unknown;
255
+ const startedAt = performance.now();
256
+ const freezeMs = await maxLoopFreezeMs(async () => {
257
+ try {
258
+ await tight.run("INSERT INTO items (name) VALUES (?)", ["never"]);
259
+ } catch (err) {
260
+ caught = err;
261
+ }
262
+ });
263
+ const wallMs = performance.now() - startedAt;
264
+ await released;
265
+ expect((caught as { code?: string }).code).toBe("SQLITE_BUSY");
266
+ expect(wallMs).toBeLessThan(2_000);
267
+ expect(freezeMs).toBeLessThan(1_000);
268
+ const row = main.query<{ timeout: number }, []>("PRAGMA busy_timeout").get();
269
+ expect(row?.timeout).toBe(5000);
270
+ });
271
+
272
+ test("the final BEGIN IMMEDIATE attempt spins finalAttemptSpinMs, not the ambient timeout", async () => {
273
+ const tight = createBunSqliteClient(() => main, {
274
+ maxWaitMs: 60,
275
+ backoffMs: [20, 20],
276
+ attemptSpinMs: 25,
277
+ finalAttemptSpinMs: 50,
278
+ });
279
+ const released = holdWriteLock(3_000);
280
+ let caught: unknown;
281
+ const startedAt = performance.now();
282
+ const freezeMs = await maxLoopFreezeMs(async () => {
283
+ try {
284
+ await tight.transaction(async (tx) => {
285
+ await tx.run("INSERT INTO items (name) VALUES (?)", ["never-tx"]);
286
+ });
287
+ } catch (err) {
288
+ caught = err;
289
+ }
290
+ });
291
+ const wallMs = performance.now() - startedAt;
292
+ await released;
293
+ expect((caught as { code?: string }).code).toBe("SQLITE_BUSY");
294
+ expect(wallMs).toBeLessThan(2_000);
295
+ expect(freezeMs).toBeLessThan(1_000);
296
+ const row = main.query<{ timeout: number }, []>("PRAGMA busy_timeout").get();
297
+ expect(row?.timeout).toBe(5000);
298
+ });
299
+ });
300
+
205
301
  test("reads proceed under an external write lock without needing the retry path", async () => {
206
302
  await client.run("INSERT INTO items (name) VALUES (?)", ["pre-existing"]);
207
303
  const released = holdWriteLock(150);
@@ -1,5 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
3
3
  import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
@@ -427,3 +427,62 @@ describe("db-client transactions on a shared WAL file (second connection)", () =
427
427
  expect(row?.n).toBe(11);
428
428
  });
429
429
  });
430
+
431
+ describe("db-client slow statement warning", () => {
432
+ // A recursive CTE is pure CPU inside one driver call, so it blocks the
433
+ // event loop the same way a slow production query does.
434
+ const SLOW_SQL = `/* ${"padding ".repeat(40)} */ WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c WHERE x < ?) SELECT count(*) AS n FROM c WHERE ? IS NOT NULL`;
435
+ const SLOW_ROWS = 3_000_000;
436
+ const PARAM_SECRET = "param-value-that-must-not-be-logged";
437
+
438
+ const slowWarnings = (spy: ReturnType<typeof spyOn>): string[] =>
439
+ spy.mock.calls
440
+ .map((args) => String(args[0]))
441
+ .filter((line) => line.includes("[db-client] slow sync statement"));
442
+
443
+ test("logs a statement that blocked the loop past 100ms, with truncated SQL and no params", async () => {
444
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
445
+ try {
446
+ const startedAt = performance.now();
447
+ const row = await client.get<{ n: number }>(SLOW_SQL, [SLOW_ROWS, PARAM_SECRET]);
448
+ const elapsedMs = performance.now() - startedAt;
449
+ expect(row?.n).toBe(SLOW_ROWS);
450
+ // Guard the premise: on a machine fast enough to finish under the
451
+ // threshold, the warning is correctly absent and this test says so.
452
+ expect(elapsedMs).toBeGreaterThan(100);
453
+ const lines = slowWarnings(warn);
454
+ expect(lines).toHaveLength(1);
455
+ expect(lines[0]).toMatch(/blocked the event loop for \d+ms: \/\* padding/);
456
+ expect(lines[0]).not.toContain(PARAM_SECRET);
457
+ expect(lines[0]).not.toContain("WITH RECURSIVE");
458
+ expect(lines[0]?.endsWith("…")).toBe(true);
459
+ } finally {
460
+ warn.mockRestore();
461
+ }
462
+ });
463
+
464
+ test("logs a slow statement run inside a transaction", async () => {
465
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
466
+ try {
467
+ await client.transaction(async (tx) => {
468
+ await tx.get(SLOW_SQL, [SLOW_ROWS, PARAM_SECRET]);
469
+ });
470
+ const lines = slowWarnings(warn);
471
+ expect(lines).toHaveLength(1);
472
+ expect(lines[0]).not.toContain(PARAM_SECRET);
473
+ } finally {
474
+ warn.mockRestore();
475
+ }
476
+ });
477
+
478
+ test("does not log fast statements", async () => {
479
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
480
+ try {
481
+ await client.run("INSERT INTO items (name) VALUES (?)", ["fast"]);
482
+ await client.query("SELECT name FROM items");
483
+ expect(slowWarnings(warn)).toHaveLength(0);
484
+ } finally {
485
+ warn.mockRestore();
486
+ }
487
+ });
488
+ });
@@ -41,6 +41,7 @@ const baseFacade = `type:AgentMailInboxMapping
41
41
  type:ApiKeyStatus
42
42
  type:AppVersion
43
43
  type:ApprovalRequest
44
+ type:ApprovalRequestSummary
44
45
  type:AssetSummaryFilters
45
46
  type:AttributionByPersonRow
46
47
  type:AvailableKeyIndicesResult
@@ -64,6 +65,7 @@ type:InboxSummary
64
65
  type:InsertPricingRowInput
65
66
  type:InsertTaskAttachmentInput
66
67
  type:KeyCostSummary
68
+ type:KvJsonFieldEquals
67
69
  type:McpServerFilters
68
70
  type:McpServerInsert
69
71
  type:MentionPreview
@@ -113,6 +115,7 @@ value:autoEncryptLegacyPlaintextSecrets
113
115
  value:backfillSupersedeTaskResumeTaskId
114
116
  value:bindSlackMessageTimestamp
115
117
  value:buildRoutingAffinityFromAgent
118
+ value:cancelApprovalRequestById
116
119
  value:cancelPendingApprovalRequestsForRun
117
120
  value:cancelPendingSteeringForTask
118
121
  value:cancelTask
@@ -223,6 +226,7 @@ value:getActiveSessions
223
226
  value:getActiveTaskCount
224
227
  value:getAgentById
225
228
  value:getAgentCurrentTask
229
+ value:getAgentDailyTaskCounts
226
230
  value:getAgentHarnessProviders
227
231
  value:getAgentMailInboxMapping
228
232
  value:getAgentMailInboxMappingsByAgent
@@ -265,6 +269,7 @@ value:getContextSnapshotsByTaskId
265
269
  value:getContextSummaryByTaskId
266
270
  value:getContextVersion
267
271
  value:getContextVersionHistory
272
+ value:getCurrentStepForNode
268
273
  value:getDailySpendForAgent
269
274
  value:getDailySpendForUser
270
275
  value:getDailySpendGlobal
@@ -370,6 +375,7 @@ value:getSlackTasksMissingTree
370
375
  value:getSlackTreeMessage
371
376
  value:getSlackTreeMessageByThread
372
377
  value:getSlackTreeMessages
378
+ value:getStaleApprovalRequests
373
379
  value:getStalePinnedResumes
374
380
  value:getStaleUnassignedAffinityTasks
375
381
  value:getStalledInProgressTasks
@@ -380,6 +386,7 @@ value:getStepCountForNode
380
386
  value:getStuckApprovalRuns
381
387
  value:getStuckWaitRuns
382
388
  value:getStuckWorkflowRuns
389
+ value:getSupersededTasksWithoutResume
383
390
  value:getSwarmConfigById
384
391
  value:getSwarmConfigLookupById
385
392
  value:getSwarmConfigs
@@ -436,9 +443,11 @@ value:isPendingSlackMessage
436
443
  value:isPoolAffinityEnforcementEnabled
437
444
  value:isSettledSlackMessage
438
445
  value:isSqliteVecAvailable
446
+ value:kvPrefixUpperBound
439
447
  value:listAgentsWithCredStatusByProvider
440
448
  value:listAllMetrics
441
449
  value:listAllPages
450
+ value:listApprovalRequestSummaries
442
451
  value:listApprovalRequests
443
452
  value:listAssetSummaries
444
453
  value:listCancelledApprovalRequestsForRun
@@ -595,6 +604,7 @@ value:getMostRecentTaskInThread
595
604
  value:getPendingSlackRelayTasks
596
605
  value:getRecentlyFinishedWorkerTasks
597
606
  value:getSlackTasksMissingTree
607
+ value:getSupersededTasksWithoutResume
598
608
  value:getTaskById
599
609
  value:getTaskStats
600
610
  value:getTasksByAgentId
@@ -42,6 +42,7 @@ const baseFacade = `type:AgentMailInboxMapping
42
42
  type:ApiKeyStatus
43
43
  type:AppVersion
44
44
  type:ApprovalRequest
45
+ type:ApprovalRequestSummary
45
46
  type:AssetSummaryFilters
46
47
  type:AttributionByPersonRow
47
48
  type:AvailableKeyIndicesResult
@@ -65,6 +66,7 @@ type:InboxSummary
65
66
  type:InsertPricingRowInput
66
67
  type:InsertTaskAttachmentInput
67
68
  type:KeyCostSummary
69
+ type:KvJsonFieldEquals
68
70
  type:McpServerFilters
69
71
  type:McpServerInsert
70
72
  type:MentionPreview
@@ -114,6 +116,7 @@ value:autoEncryptLegacyPlaintextSecrets
114
116
  value:backfillSupersedeTaskResumeTaskId
115
117
  value:bindSlackMessageTimestamp
116
118
  value:buildRoutingAffinityFromAgent
119
+ value:cancelApprovalRequestById
117
120
  value:cancelPendingApprovalRequestsForRun
118
121
  value:cancelPendingSteeringForTask
119
122
  value:cancelTask
@@ -224,6 +227,7 @@ value:getActiveSessions
224
227
  value:getActiveTaskCount
225
228
  value:getAgentById
226
229
  value:getAgentCurrentTask
230
+ value:getAgentDailyTaskCounts
227
231
  value:getAgentHarnessProviders
228
232
  value:getAgentMailInboxMapping
229
233
  value:getAgentMailInboxMappingsByAgent
@@ -266,6 +270,7 @@ value:getContextSnapshotsByTaskId
266
270
  value:getContextSummaryByTaskId
267
271
  value:getContextVersion
268
272
  value:getContextVersionHistory
273
+ value:getCurrentStepForNode
269
274
  value:getDailySpendForAgent
270
275
  value:getDailySpendForUser
271
276
  value:getDailySpendGlobal
@@ -371,6 +376,7 @@ value:getSlackTasksMissingTree
371
376
  value:getSlackTreeMessage
372
377
  value:getSlackTreeMessageByThread
373
378
  value:getSlackTreeMessages
379
+ value:getStaleApprovalRequests
374
380
  value:getStalePinnedResumes
375
381
  value:getStaleUnassignedAffinityTasks
376
382
  value:getStalledInProgressTasks
@@ -381,6 +387,7 @@ value:getStepCountForNode
381
387
  value:getStuckApprovalRuns
382
388
  value:getStuckWaitRuns
383
389
  value:getStuckWorkflowRuns
390
+ value:getSupersededTasksWithoutResume
384
391
  value:getSwarmConfigById
385
392
  value:getSwarmConfigLookupById
386
393
  value:getSwarmConfigs
@@ -437,9 +444,11 @@ value:isPendingSlackMessage
437
444
  value:isPoolAffinityEnforcementEnabled
438
445
  value:isSettledSlackMessage
439
446
  value:isSqliteVecAvailable
447
+ value:kvPrefixUpperBound
440
448
  value:listAgentsWithCredStatusByProvider
441
449
  value:listAllMetrics
442
450
  value:listAllPages
451
+ value:listApprovalRequestSummaries
443
452
  value:listApprovalRequests
444
453
  value:listAssetSummaries
445
454
  value:listCancelledApprovalRequestsForRun
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Spent deferral timers are reaped once they age past the retention window,
3
+ * whether the ceiling fired them or a watched task's settlement woke the
4
+ * waiter early. The wake-up task keeps its `scheduleId` and stays readable.
5
+ */
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { unlink } from "node:fs/promises";
8
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
+ import {
10
+ closeDb,
11
+ completeTask,
12
+ createAgent,
13
+ createScheduledTask,
14
+ createTaskExtended,
15
+ getDbClient,
16
+ getScheduledTaskById,
17
+ getTaskById,
18
+ initDb,
19
+ startTask,
20
+ updateScheduledTask,
21
+ } from "../be/db";
22
+ import {
23
+ reapSpentDeferredSchedules,
24
+ reconcileDeferredTaskWaits,
25
+ SPENT_DEFERRED_SCHEDULE_RETENTION_MS,
26
+ } from "../scheduler/deferred-task-waits";
27
+ import { executeSchedule } from "../scheduler/scheduler";
28
+ import { registerDeferTaskTool } from "../tools/defer-task";
29
+
30
+ const TEST_DB_PATH = "./test-deferred-schedule-reaper.sqlite";
31
+ const DAY_MS = 24 * 60 * 60 * 1000;
32
+
33
+ type Result = { structuredContent: { success: boolean; message: string; scheduleId?: string } };
34
+ type Tool = { handler: (args: unknown, extra: unknown) => Promise<Result> };
35
+
36
+ let agentId: string;
37
+ let tool: Tool;
38
+
39
+ async function removeDbFiles() {
40
+ for (const suffix of ["", "-wal", "-shm"]) {
41
+ try {
42
+ await unlink(`${TEST_DB_PATH}${suffix}`);
43
+ } catch {}
44
+ }
45
+ }
46
+
47
+ beforeAll(async () => {
48
+ await removeDbFiles();
49
+ initDb(TEST_DB_PATH);
50
+ agentId = (
51
+ await createAgent({
52
+ name: "Reap worker",
53
+ isLead: false,
54
+ status: "busy",
55
+ capabilities: [],
56
+ maxTasks: 10,
57
+ })
58
+ ).id;
59
+ const server = new McpServer({ name: "reap-test", version: "1.0.0" });
60
+ registerDeferTaskTool(server);
61
+ tool = (server as unknown as { _registeredTools: Record<string, Tool> })._registeredTools[
62
+ "defer-task"
63
+ ]!;
64
+ });
65
+
66
+ afterAll(async () => {
67
+ closeDb();
68
+ await removeDbFiles();
69
+ });
70
+
71
+ async function activeTask() {
72
+ const task = await createTaskExtended("work in progress", { agentId });
73
+ await startTask(task.id);
74
+ return task;
75
+ }
76
+
77
+ async function defer(taskId: string, extra: Record<string, unknown> = {}) {
78
+ const result = await tool.handler(
79
+ { taskId, delayMs: 3600000, summary: "Submitted", note: "Check the result", ...extra },
80
+ { sessionId: crypto.randomUUID(), requestInfo: { headers: { "x-agent-id": agentId } } },
81
+ );
82
+ expect(result.structuredContent.success).toBe(true);
83
+ return (await getScheduledTaskById(result.structuredContent.scheduleId!))!;
84
+ }
85
+
86
+ async function wakeTask(scheduleId: string) {
87
+ const row = await getDbClient().get<{ id: string }>(
88
+ "SELECT id FROM agent_tasks WHERE scheduleId = ?",
89
+ [scheduleId],
90
+ );
91
+ return row ? getTaskById(row.id) : null;
92
+ }
93
+
94
+ async function waitRows(scheduleId: string) {
95
+ const row = await getDbClient().get<{ waits: number; members: number }>(
96
+ `SELECT (SELECT COUNT(*) FROM deferred_task_waits WHERE scheduleId = ?) AS waits,
97
+ (SELECT COUNT(*) FROM deferred_task_wait_members WHERE scheduleId = ?) AS members`,
98
+ [scheduleId, scheduleId],
99
+ );
100
+ return row!;
101
+ }
102
+
103
+ /** `now` just inside and just past the retention window, measured from lastRunAt. */
104
+ async function horizons(scheduleId: string) {
105
+ const lastRunAt = (await getScheduledTaskById(scheduleId))!.lastRunAt!;
106
+ const firedAt = new Date(lastRunAt).getTime();
107
+ return {
108
+ inside: new Date(firedAt + SPENT_DEFERRED_SCHEDULE_RETENTION_MS - DAY_MS),
109
+ past: new Date(firedAt + SPENT_DEFERRED_SCHEDULE_RETENTION_MS + DAY_MS),
110
+ };
111
+ }
112
+
113
+ describe("reapSpentDeferredSchedules", () => {
114
+ test("timer fires: the spent row is kept inside the window and deleted past it", async () => {
115
+ const parent = await activeTask();
116
+ const schedule = await defer(parent.id);
117
+ await executeSchedule(schedule);
118
+
119
+ const fired = (await getScheduledTaskById(schedule.id))!;
120
+ expect(fired.enabled).toBe(false);
121
+ expect(fired.lastRunAt).toBeDefined();
122
+ const wake = await wakeTask(schedule.id);
123
+ expect(wake?.parentTaskId).toBe(parent.id);
124
+
125
+ const { inside, past } = await horizons(schedule.id);
126
+ await reapSpentDeferredSchedules(inside);
127
+ expect(await getScheduledTaskById(schedule.id)).not.toBeNull();
128
+
129
+ expect(await reapSpentDeferredSchedules(past)).toBeGreaterThanOrEqual(1);
130
+ expect(await getScheduledTaskById(schedule.id)).toBeNull();
131
+ // The wake-up task survives with its provenance id intact.
132
+ const after = await getTaskById(wake!.id);
133
+ expect(after?.scheduleId).toBe(schedule.id);
134
+ expect(after?.parentTaskId).toBe(parent.id);
135
+ });
136
+
137
+ test("settlement wakes early: the disabled timer is reaped and its wait cascades", async () => {
138
+ const parent = await activeTask();
139
+ const producer = await activeTask();
140
+ const schedule = await defer(parent.id, {
141
+ wakeOn: { taskId: producer.id, event: "settled" },
142
+ });
143
+ expect(await waitRows(schedule.id)).toEqual({ waits: 1, members: 1 });
144
+
145
+ await completeTask(producer.id, "ready");
146
+ await reconcileDeferredTaskWaits(producer.id);
147
+ const woken = (await getScheduledTaskById(schedule.id))!;
148
+ // Woken before its ceiling: disabled with lastRunAt set, never left armed.
149
+ expect(woken.enabled).toBe(false);
150
+ expect(woken.nextRunAt).toBeUndefined();
151
+ expect(woken.lastRunAt).toBeDefined();
152
+ const wake = await wakeTask(schedule.id);
153
+ expect(wake?.task).toContain(`Wake-up cause: task.completed for task ${producer.id}`);
154
+
155
+ const { inside, past } = await horizons(schedule.id);
156
+ await reapSpentDeferredSchedules(inside);
157
+ expect(await getScheduledTaskById(schedule.id)).not.toBeNull();
158
+
159
+ await reapSpentDeferredSchedules(past);
160
+ expect(await getScheduledTaskById(schedule.id)).toBeNull();
161
+ expect(await waitRows(schedule.id)).toEqual({ waits: 0, members: 0 });
162
+ expect((await getTaskById(wake!.id))?.scheduleId).toBe(schedule.id);
163
+ });
164
+
165
+ test("armed, never-fired, and ordinary one-time schedules are never reaped", async () => {
166
+ const farFuture = new Date(Date.now() + 10 * 365 * DAY_MS);
167
+
168
+ const armed = await defer((await activeTask()).id);
169
+
170
+ // Auto-disabled after a dispatch failure: no lastRunAt, kept as evidence.
171
+ const neverFired = await defer((await activeTask()).id);
172
+ await updateScheduledTask(neverFired.id, { enabled: false, lastErrorMessage: "boom" });
173
+
174
+ const ordinary = await createScheduledTask({
175
+ name: `ordinary-one-time-${crypto.randomUUID()}`,
176
+ taskTemplate: "one-off",
177
+ targetType: "agent-task",
178
+ scheduleType: "one_time",
179
+ nextRunAt: new Date(Date.now() + DAY_MS).toISOString(),
180
+ targetAgentId: agentId,
181
+ });
182
+ await updateScheduledTask(ordinary.id, {
183
+ enabled: false,
184
+ nextRunAt: null,
185
+ lastRunAt: new Date().toISOString(),
186
+ });
187
+
188
+ await reapSpentDeferredSchedules(farFuture);
189
+ expect((await getScheduledTaskById(armed.id))?.enabled).toBe(true);
190
+ expect(await getScheduledTaskById(neverFired.id)).not.toBeNull();
191
+ expect(await getScheduledTaskById(ordinary.id)).not.toBeNull();
192
+ });
193
+ });
@@ -78,7 +78,7 @@ describe("docs API index footprint", () => {
78
78
  expect(tasks).toContain('"path":"/api/task-templates","method":"get"');
79
79
  });
80
80
 
81
- test("OpenAPI and generated pages keep all 381 operations", async () => {
81
+ test("OpenAPI and generated pages keep all 383 operations", async () => {
82
82
  const spec = await Bun.file(resolve(repoRoot, "openapi.json")).json();
83
83
  const operationKeys = [];
84
84
  const tags = new Set();
@@ -103,8 +103,8 @@ describe("docs API index footprint", () => {
103
103
  expect(spec.paths["/api/agents/{id}/runtime"]?.get).toBeDefined();
104
104
 
105
105
  const generatedKeys = await generatedOperationKeys();
106
- expect(operationKeys).toHaveLength(381);
107
- expect(generatedKeys).toHaveLength(381);
106
+ expect(operationKeys).toHaveLength(383);
107
+ expect(generatedKeys).toHaveLength(383);
108
108
  expect(new Set(generatedKeys)).toEqual(new Set(operationKeys));
109
109
  });
110
110
 
@@ -1,6 +1,7 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import type { Subprocess } from "bun";
4
+ import { fetchProfileSyncRejectionBanner } from "../commands/profile-sync";
4
5
  import { getFreePort, SERVER_BOOT_HOOK_TIMEOUT_MS } from "./test-net";
5
6
 
6
7
  let TEST_PORT = 0;
@@ -270,6 +271,108 @@ describe("GET /api/events", () => {
270
271
  });
271
272
  });
272
273
 
274
+ describe("GET /api/events — latestPerDataField", () => {
275
+ const AGENT = "profile-sync-latest-agent";
276
+ const ids: Record<string, string> = {};
277
+
278
+ beforeAll(async () => {
279
+ const seed: [string, string, string][] = [
280
+ ["tools-old", "system.profile_sync_rejected", "toolsMd"],
281
+ ["claude-rejected", "system.profile_sync_rejected", "claudeMd"],
282
+ ["soul-reconciled", "system.profile_sync_reconciled", "soulMd"],
283
+ ["tools-new", "system.profile_sync_rejected", "toolsMd"],
284
+ ["claude-reconciled", "system.profile_sync_reconciled", "claudeMd"],
285
+ ["soul-rejected", "system.profile_sync_rejected", "soulMd"],
286
+ ];
287
+ for (const [key, event, field] of seed) {
288
+ const { body } = await post("/api/events", {
289
+ category: "system",
290
+ event,
291
+ status: event.endsWith("rejected") ? "error" : "ok",
292
+ source: "api",
293
+ agentId: AGENT,
294
+ data: { field, diskSize: 30_000, dbSize: 19_000, budget: 20_000, delta: 11_000 },
295
+ });
296
+ ids[key] = (body.event as { id: string }).id;
297
+ // createdAt has millisecond precision: keep the seed strictly ordered.
298
+ await Bun.sleep(5);
299
+ }
300
+ await post("/api/events", {
301
+ category: "system",
302
+ event: "system.profile_sync_conflict",
303
+ source: "api",
304
+ agentId: AGENT,
305
+ data: { field: "toolsMd" },
306
+ });
307
+ });
308
+
309
+ test("returns the newest event per (event, data.field) and confirms the mode", async () => {
310
+ const { status, body } = await get(
311
+ `/api/events?events=system.profile_sync_rejected,system.profile_sync_reconciled&dataFields=soulMd,claudeMd,toolsMd,identityMd&agentId=${AGENT}&latestPerDataField=true`,
312
+ );
313
+ expect(status).toBe(200);
314
+ expect(body.latestPerDataField).toBe(true);
315
+ const returned = (body.events as { id: string }[]).map((event) => event.id);
316
+ expect(returned).toEqual([
317
+ ids["soul-rejected"],
318
+ ids["claude-reconciled"],
319
+ ids["tools-new"],
320
+ ids["soul-reconciled"],
321
+ ids["claude-rejected"],
322
+ ]);
323
+ });
324
+
325
+ test("a single-event request keeps today's response shape", async () => {
326
+ const { status, body } = await get(
327
+ `/api/events?event=system.profile_sync_rejected&agentId=${AGENT}&dataField=toolsMd&limit=1`,
328
+ );
329
+ expect(status).toBe(200);
330
+ expect(Object.keys(body)).toEqual(["events"]);
331
+ expect((body.events as { id: string }[]).map((event) => event.id)).toEqual([ids["tools-new"]]);
332
+ });
333
+
334
+ test("rejects an unknown name in events", async () => {
335
+ const { status } = await get("/api/events?events=system.profile_sync_rejected,not.an.event");
336
+ expect(status).toBe(400);
337
+ });
338
+
339
+ test("rejects latestPerDataField without dataFields", async () => {
340
+ const { status } = await get(
341
+ "/api/events?events=system.profile_sync_rejected&latestPerDataField=true",
342
+ );
343
+ expect(status).toBe(400);
344
+ });
345
+
346
+ test("the profile-sync banner is identical through one batched call and 8 lookups", async () => {
347
+ const config = { apiUrl: BASE, apiKey: TEST_API_KEY, agentId: AGENT };
348
+ const eventRequests: string[] = [];
349
+ const recording = (async (input: string | URL | Request, init?: RequestInit) => {
350
+ const url = new URL(String(input));
351
+ if (url.pathname === "/api/events") eventRequests.push(url.search);
352
+ return fetch(input, init);
353
+ }) as typeof fetch;
354
+ // Simulates an API that predates latestPerDataField: drop the confirmation.
355
+ const legacy = (async (input: string | URL | Request, init?: RequestInit) => {
356
+ const response = await recording(input, init);
357
+ if (!new URL(String(input)).searchParams.has("latestPerDataField")) return response;
358
+ const { latestPerDataField: _dropped, ...rest } = await response.json();
359
+ return new Response(JSON.stringify(rest), { status: response.status });
360
+ }) as typeof fetch;
361
+
362
+ const batched = await fetchProfileSyncRejectionBanner(config, recording);
363
+ const batchedCalls = eventRequests.length;
364
+ eventRequests.length = 0;
365
+ const perField = await fetchProfileSyncRejectionBanner(config, legacy);
366
+
367
+ expect(batchedCalls).toBe(1);
368
+ expect(eventRequests).toHaveLength(9);
369
+ expect(batched).toContain(ids["tools-new"] ?? "missing");
370
+ expect(batched).toContain(ids["soul-rejected"] ?? "missing");
371
+ expect(batched).not.toContain(ids["claude-rejected"] ?? "missing");
372
+ expect(batched).toBe(perField);
373
+ });
374
+ });
375
+
273
376
  describe("GET /api/events/counts", () => {
274
377
  test("returns counts grouped by event name", async () => {
275
378
  const { status, body } = await get("/api/events/counts");
@@ -32,23 +32,28 @@ afterEach(async () => {
32
32
  });
33
33
 
34
34
  describe("predefined extension catalog", () => {
35
- test("every bundled entry passes the manifest schema and bundle validation", async () => {
36
- const catalog = getExtensionCatalog();
37
- expect(Object.keys(catalog).sort()).toEqual([
38
- "notify-on-complete",
39
- "require-ticket-ref",
40
- "require-verification-note",
41
- "task-digest",
42
- ]);
43
- for (const [name, entry] of Object.entries(catalog)) {
44
- expect(entry.manifest.name).toBe(name);
45
- expect(ExtensionManifestSchema.safeParse(entry.manifest).success).toBe(true);
46
- const result = await validateBundle({ manifest: entry.manifest, files: entry.files });
47
- expect(result).toMatchObject({ ok: true });
48
- }
49
- expect(getCatalogEntry("task-digest")?.manifestFile).toBe("manifest.yaml");
50
- expect(getCatalogEntry("toString")).toBeNull();
51
- });
35
+ test(
36
+ "every bundled entry passes the manifest schema and bundle validation",
37
+ async () => {
38
+ const catalog = getExtensionCatalog();
39
+ expect(Object.keys(catalog).sort()).toEqual([
40
+ "github-sender-allowlist",
41
+ "notify-on-complete",
42
+ "require-ticket-ref",
43
+ "require-verification-note",
44
+ "task-digest",
45
+ ]);
46
+ for (const [name, entry] of Object.entries(catalog)) {
47
+ expect(entry.manifest.name).toBe(name);
48
+ expect(ExtensionManifestSchema.safeParse(entry.manifest).success).toBe(true);
49
+ const result = await validateBundle({ manifest: entry.manifest, files: entry.files });
50
+ expect(result).toMatchObject({ ok: true });
51
+ }
52
+ expect(getCatalogEntry("task-digest")?.manifestFile).toBe("manifest.yaml");
53
+ expect(getCatalogEntry("toString")).toBeNull();
54
+ },
55
+ { timeout: 15_000 },
56
+ );
52
57
 
53
58
  test("the generator reads YAML and JSON templates and their referenced files", async () => {
54
59
  const dir = await templatesDir({