@desplega.ai/agent-swarm 2.5.4 → 2.6.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 (271) hide show
  1. package/README.md +2 -2
  2. package/dist/{acp-adapter-ek38cwsv.js → acp-adapter-dgfakfpw.js} +9 -9
  3. package/dist/{actions-92mepq03.js → actions-fbqma8aa.js} +21 -21
  4. package/dist/{amp-adapter-9gaxg015.js → amp-adapter-en0jy6hs.js} +4 -4
  5. package/dist/{app-ctn8gsgx.js → app-k8a5cgpp.js} +3 -3
  6. package/dist/{assistant-zyr5kz47.js → assistant-a16433gr.js} +29 -25
  7. package/dist/{boot-reembed-dwzfttdr.js → boot-reembed-6c1sjfnr.js} +1 -1
  8. package/dist/{boot-reembed-w75maga2.js → boot-reembed-f4nn561n.js} +1 -1
  9. package/dist/{boot-scrub-sweep-e9kq43kf.js → boot-scrub-sweep-d4w9gajf.js} +4 -4
  10. package/dist/{boot-seeds-x28v3fhb.js → boot-seeds-9bmkspyw.js} +4 -4
  11. package/dist/{claude-adapter-f0afqnne.js → claude-adapter-nys1kjxa.js} +9 -9
  12. package/dist/{claude-managed-adapter-x81a7r65.js → claude-managed-adapter-55207973.js} +2 -2
  13. package/dist/{claude-managed-setup-bqzwjd9x.js → claude-managed-setup-3cm35gey.js} +10 -3
  14. package/dist/{claude-sdk-session-7b7whkaq.js → claude-sdk-session-nw5yd8cp.js} +11 -11
  15. package/dist/{cli-c61mbdds.js → cli-03gg9pd1.js} +4 -4
  16. package/dist/{cli-kxpfx2xr.js → cli-0z1xjzwa.js} +2 -2
  17. package/dist/cli-1tem6ns9.js +894 -0
  18. package/dist/{cli-jbnn4vxb.js → cli-24j1jeeb.js} +3 -3
  19. package/dist/{cli-pzatvw94.js → cli-2sjhpsns.js} +2 -2
  20. package/dist/{cli-dew6x255.js → cli-39aet655.js} +2 -2
  21. package/dist/{cli-g1cw7xtt.js → cli-39rrw8yp.js} +1 -1
  22. package/dist/{cli-x0f2v69c.js → cli-3aqd4jaa.js} +12 -7
  23. package/dist/{cli-gbfxp48k.js → cli-3fwqy71j.js} +15 -15
  24. package/dist/{cli-7p4jxgtt.js → cli-3gfvmzht.js} +6 -6
  25. package/dist/{cli-03vpq45j.js → cli-3ndmpxxv.js} +2 -2
  26. package/dist/{cli-asq5ydm4.js → cli-4hxrf0p4.js} +131 -50
  27. package/dist/{cli-mz9afxxf.js → cli-4rf0f5vg.js} +15 -3
  28. package/dist/{cli-k4tjsdx9.js → cli-5rj83bam.js} +272 -195
  29. package/dist/{cli-sbwsxvtd.js → cli-5sckpqcx.js} +2 -2
  30. package/dist/{cli-cz5nmdxt.js → cli-683y0b3a.js} +2 -2
  31. package/dist/{cli-ethgy7ha.js → cli-6y51ryva.js} +2 -2
  32. package/dist/{cli-n4c8e3s5.js → cli-708wdy32.js} +1 -1
  33. package/dist/{cli-pasyvjmm.js → cli-737pyqxr.js} +1 -1
  34. package/dist/{cli-gx4rm0qx.js → cli-7enjwya9.js} +1 -1
  35. package/dist/{cli-6x9ma5w5.js → cli-803188pb.js} +1 -1
  36. package/dist/{cli-hh5we352.js → cli-9abdgn5t.js} +7 -7
  37. package/dist/{cli-ff1tm9n9.js → cli-a2rr0fmg.js} +1 -1
  38. package/dist/{cli-c952c7ac.js → cli-a34pr3w1.js} +2 -2
  39. package/dist/{cli-s682zgqz.js → cli-abkm71vr.js} +1 -1
  40. package/dist/{cli-mmhj8vd7.js → cli-ag2yrnt5.js} +11 -2
  41. package/dist/{cli-bvpgr9ny.js → cli-aj8djyqt.js} +5 -5
  42. package/dist/{cli-gwhjmh8g.js → cli-art7c1zh.js} +5 -5
  43. package/dist/{cli-5bnsc0e1.js → cli-az25fn5f.js} +4 -4
  44. package/dist/{cli-9s05422w.js → cli-bqztfyve.js} +1 -1
  45. package/dist/{cli-bn0rewsy.js → cli-c081bj3b.js} +2 -2
  46. package/dist/{cli-1r5ad7j5.js → cli-c2habvsf.js} +12 -12
  47. package/dist/{cli-64mh9mf3.js → cli-ca6banwz.js} +69 -17
  48. package/dist/{cli-25n3mv5d.js → cli-cz37jqss.js} +5 -5
  49. package/dist/{cli-6n8axjv1.js → cli-d2dpf95n.js} +2 -2
  50. package/dist/{cli-gn5af0x5.js → cli-emhjcj2y.js} +11 -11
  51. package/dist/{cli-6a1xg1p7.js → cli-esakngh8.js} +2 -2
  52. package/dist/{cli-ha9rmwgq.js → cli-fb8m699c.js} +2 -2
  53. package/dist/{cli-4b2z6n0m.js → cli-ffvsj2qw.js} +17 -8
  54. package/dist/{cli-abs8ndtr.js → cli-g20jjwt4.js} +26 -26
  55. package/dist/{cli-jb30dzvk.js → cli-g8ka5j4j.js} +446 -164
  56. package/dist/{cli-19202kew.js → cli-gf95q3bm.js} +5 -5
  57. package/dist/{cli-eftzyh9y.js → cli-gnsb1zxa.js} +2 -2
  58. package/dist/{cli-nwyw3zhg.js → cli-gxee0bqa.js} +1 -1
  59. package/dist/{cli-e42vss7b.js → cli-hfeb68z4.js} +5 -5
  60. package/dist/{cli-g9vd9ece.js → cli-k21gx5ht.js} +14 -16
  61. package/dist/{cli-tsma7p8v.js → cli-kf4jree9.js} +2 -2
  62. package/dist/{cli-dabehxhp.js → cli-kt907da6.js} +1 -1
  63. package/dist/{cli-wx2nj19m.js → cli-m5a9m0ak.js} +6 -6
  64. package/dist/{cli-621ce7kw.js → cli-m70bajzr.js} +3 -3
  65. package/dist/{cli-9j129brq.js → cli-mtnj4fwa.js} +6 -6
  66. package/dist/{cli-xzfq4at3.js → cli-mvjz5an3.js} +15 -11
  67. package/dist/{cli-277c31dw.js → cli-n7asmce6.js} +225 -3
  68. package/dist/{cli-xtrz6bzs.js → cli-nn6vx2mq.js} +1 -1
  69. package/dist/{cli-2655k16q.js → cli-nq677csy.js} +1 -1
  70. package/dist/{cli-0shx9j93.js → cli-pfk92eqz.js} +2 -2
  71. package/dist/{cli-wghjkr0e.js → cli-sqma6vw1.js} +10 -7
  72. package/dist/{cli-nzfnt2gs.js → cli-tf1n0r0b.js} +1 -1
  73. package/dist/{cli-qqzmjxkf.js → cli-v49fkghp.js} +11 -6
  74. package/dist/{cli-x77gen2p.js → cli-w2656ka1.js} +7 -7
  75. package/dist/{cli-5kqnemen.js → cli-yvy48785.js} +1 -1
  76. package/dist/{cli-m4ecdxvz.js → cli-zqne1qcj.js} +7 -7
  77. package/dist/cli.js +17 -17
  78. package/dist/{codex-adapter-wch12q99.js → codex-adapter-d95q4qsn.js} +7 -7
  79. package/dist/{codex-login-rn7c5gxt.js → codex-login-8cg0vhf2.js} +1 -1
  80. package/dist/{codex-session-runner-41pfcr95.js → codex-session-runner-gdwd8h0c.js} +8 -8
  81. package/dist/{commands-9849dkj6.js → commands-jqmgk54b.js} +2 -2
  82. package/dist/{controller-ja0sd95p.js → controller-z3jq589f.js} +4 -4
  83. package/dist/{controllers-connect-xp05dj6x.js → controllers-connect-r2za39ef.js} +23 -23
  84. package/dist/{cursor-adapter-623qxqew.js → cursor-adapter-4mr4jgkw.js} +2 -2
  85. package/dist/{db-47vj40dy.js → db-ppv2tze7.js} +3 -3
  86. package/dist/{db-w759qmx0.js → db-w36wwb5g.js} +4 -2
  87. package/dist/{devin-adapter-4ycy4vrf.js → devin-adapter-byen2cxt.js} +1 -1
  88. package/dist/{dispatcher-7b8e4z2j.js → dispatcher-pv5h77vf.js} +20 -20
  89. package/dist/{docker-s84hjs91.js → docker-fdzyewpz.js} +2 -2
  90. package/dist/{dsh-adapter-2ezw3xea.js → dsh-adapter-d4pdpb3r.js} +2 -2
  91. package/dist/{e2b-9gkdhm3c.js → e2b-222hsajc.js} +2 -2
  92. package/dist/{e2b-cjtf8k1z.js → e2b-fn3zacvt.js} +2 -2
  93. package/dist/{easter-egg-3d-e4qv8dxj.js → easter-egg-3d-4sp4dvmc.js} +1 -1
  94. package/dist/{event-bus-3z78ep5n.js → event-bus-pghg8hqy.js} +1 -1
  95. package/dist/{execute-0xpz8950.js → execute-jev839y1.js} +1 -1
  96. package/dist/{external-channel-e2s028da.js → external-channel-y6nkzhnn.js} +2 -2
  97. package/dist/{grok-adapter-ys7gaqee.js → grok-adapter-t08b9ntm.js} +10 -10
  98. package/dist/handlers-eqsnmv95.js +41 -0
  99. package/dist/{heartbeat-w3mntghj.js → heartbeat-vn31xwdk.js} +23 -23
  100. package/dist/{hook-g7kvv6x9.js → hook-6d0zbvvt.js} +6 -6
  101. package/dist/{hook-3jjywnpb.js → hook-cpjs8q51.js} +8 -8
  102. package/dist/{http-tkp7d4kb.js → http-9c6medda.js} +856 -99
  103. package/dist/{index-xdaahthb.js → index-38t35h0n.js} +20 -20
  104. package/dist/{index-3n8m9ea2.js → index-g6k9tctj.js} +1 -1
  105. package/dist/{index-1ef1myq2.js → index-k0yt1tvq.js} +20 -20
  106. package/dist/{index-31ceyf42.js → index-pkma1yf8.js} +1 -1
  107. package/dist/{index-q7m26kvs.js → index-qhjf2htm.js} +13 -10
  108. package/dist/index-yqgbfgaj.js +43 -0
  109. package/dist/{keepalive-v4ta8wgg.js → keepalive-em0117bp.js} +4 -4
  110. package/dist/lead-23e7z59t.js +79 -0
  111. package/dist/{maintenance-84n1ex21.js → maintenance-kbme3qy9.js} +1 -1
  112. package/dist/{oauth-refresh-sweep-vjnnandw.js → oauth-refresh-sweep-xysj4yse.js} +3 -3
  113. package/dist/{onboard-sfmpe4np.js → onboard-p6q0vz97.js} +3 -3
  114. package/dist/{opencode-adapter-jmpst4q4.js → opencode-adapter-esv3k9yn.js} +7 -7
  115. package/dist/{otel-impl-v6cc0zve.js → otel-impl-54qhqh1z.js} +2 -2
  116. package/dist/{pi-mono-adapter-fk8fkcf6.js → pi-mono-adapter-mymbwhj0.js} +10 -10
  117. package/dist/{pricing-refresh-fsk1e1zc.js → pricing-refresh-yb75wv33.js} +3 -3
  118. package/dist/{rbac-roles-prmkdnkw.js → rbac-roles-1vp2mw4p.js} +2 -2
  119. package/dist/{rbac-roles-zxmxqa39.js → rbac-roles-dexhc9x8.js} +1 -1
  120. package/dist/render-v2-tjay5hhf.js +16 -0
  121. package/dist/{resume-frdtgfa0.js → resume-8ayf37g5.js} +9 -9
  122. package/dist/{runtime-bt5pkpm1.js → runtime-hqe5cqcd.js} +1 -1
  123. package/dist/{session-access-fpez4y5q.js → session-access-fdhp987s.js} +4 -4
  124. package/dist/session-source-backfill-qgtc86z1.js +110 -0
  125. package/dist/{setup-mq6m3m8q.js → setup-4rrmqcq3.js} +1 -1
  126. package/dist/{virtual-modules-mnfbh5mr.js → virtual-modules-52km00d6.js} +2 -2
  127. package/dist/{work-objects-kfahhjsq.js → work-objects-p28fgd70.js} +23 -23
  128. package/dist/worker-e3w7vye1.js +79 -0
  129. package/dist/{x-pbpezr0s.js → x-wxrzvzx8.js} +2 -2
  130. package/openapi.json +1274 -32
  131. package/package.json +1 -1
  132. package/src/assets/source-key.ts +95 -0
  133. package/src/be/db/api-keys.ts +80 -0
  134. package/src/be/db/session-labels.ts +28 -0
  135. package/src/be/db/session-moves.ts +5 -6
  136. package/src/be/db/session-tree.ts +153 -14
  137. package/src/be/db/tasks/read.ts +11 -0
  138. package/src/be/db/tasks/write.ts +6 -1
  139. package/src/be/db/workflow-runs.ts +183 -1
  140. package/src/be/db-retention.ts +61 -6
  141. package/src/be/db.ts +59 -114
  142. package/src/be/memory/providers/openai-embedding.ts +5 -1
  143. package/src/be/migrations/244_api_key_validation.sql +4 -0
  144. package/src/be/onboarding.ts +4 -0
  145. package/src/be/scripts/typecheck.ts +1 -1
  146. package/src/be/session-source-backfill.ts +163 -0
  147. package/src/be/swarm-config-guard.ts +1 -0
  148. package/src/comb/review-batch.ts +28 -1
  149. package/src/comb/templates.ts +7 -2
  150. package/src/commands/claude-managed-setup.ts +7 -1
  151. package/src/commands/credential-refresh.ts +76 -1
  152. package/src/commands/credential-wait.ts +2 -2
  153. package/src/commands/provider-credentials.ts +210 -170
  154. package/src/commands/runner.ts +25 -4
  155. package/src/extensions/apply-task-create.ts +8 -1
  156. package/src/extensions/contract-types.generated.ts +1 -1
  157. package/src/extensions/contract.ts +5 -0
  158. package/src/http/all-routes.ts +2 -0
  159. package/src/http/api-keys.ts +53 -0
  160. package/src/http/assets.ts +15 -0
  161. package/src/http/codex-oauth-accounts.ts +191 -0
  162. package/src/http/comb.ts +15 -2
  163. package/src/http/config.ts +1 -1
  164. package/src/http/core.ts +6 -0
  165. package/src/http/fleet-benchmark.ts +5 -2
  166. package/src/http/index.ts +11 -0
  167. package/src/http/integrations.ts +5 -1
  168. package/src/http/kv.ts +8 -3
  169. package/src/http/onboarding.ts +6 -1
  170. package/src/http/pool-keys.ts +354 -0
  171. package/src/http/sessions.ts +37 -12
  172. package/src/http/stats.ts +1 -0
  173. package/src/http/status.ts +26 -12
  174. package/src/http/tasks.ts +3 -0
  175. package/src/http/used-by.ts +7 -2
  176. package/src/http/workflows.ts +78 -9
  177. package/src/integrations/chat-sdk/inbound.ts +27 -0
  178. package/src/kv-reserved-namespaces.ts +19 -0
  179. package/src/providers/acp-targets.ts +1 -1
  180. package/src/providers/amp-adapter.ts +1 -1
  181. package/src/providers/claude-adapter.ts +5 -1
  182. package/src/providers/claude-managed-adapter.ts +16 -9
  183. package/src/providers/codex-adapter.ts +71 -11
  184. package/src/scheduler/scheduler.ts +3 -2
  185. package/src/scripts-runtime/swarm-sdk.ts +7 -1
  186. package/src/scripts-runtime/types/stdlib.d.ts +1 -1
  187. package/src/scripts-runtime/types/swarm-extension.d.ts +17 -8
  188. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -1
  189. package/src/slack/assistant.ts +7 -0
  190. package/src/slack/channel-labels.ts +74 -0
  191. package/src/slack/handlers.ts +8 -0
  192. package/src/slack/inbound-files.ts +5 -2
  193. package/src/slack/thread-buffer.ts +4 -0
  194. package/src/tasks/sibling-awareness.ts +12 -3
  195. package/src/telemetry-context.ts +5 -6
  196. package/src/telemetry-identity.ts +3 -3
  197. package/src/tests/api-keys-validation.test.ts +170 -0
  198. package/src/tests/claude-managed-adapter.test.ts +46 -0
  199. package/src/tests/claude-routes-e2e.test.ts +7 -2
  200. package/src/tests/codex-credential-pools.test.ts +120 -0
  201. package/src/tests/codex-gateway-route.test.ts +165 -0
  202. package/src/tests/codex-oauth-accounts.test.ts +357 -0
  203. package/src/tests/comb-review-batch.test.ts +63 -2
  204. package/src/tests/config-pool-keys.test.ts +533 -0
  205. package/src/tests/credential-check.test.ts +290 -2
  206. package/src/tests/credential-refresh.test.ts +172 -1
  207. package/src/tests/db-retention.test.ts +9 -3
  208. package/src/tests/db-task-read-contract.test.ts +1 -0
  209. package/src/tests/db-task-write-contract.test.ts +1 -3
  210. package/src/tests/docs-index-footprint.test.js +3 -3
  211. package/src/tests/extensions-pre-task.test.ts +53 -1
  212. package/src/tests/fixtures/extensions/record-task-create/hooks.ts +9 -0
  213. package/src/tests/fixtures/extensions/record-task-create/manifest.json +7 -0
  214. package/src/tests/fleet-benchmark.test.ts +8 -0
  215. package/src/tests/get-workflow-run-tool.test.ts +98 -0
  216. package/src/tests/integrations-http.test.ts +32 -2
  217. package/src/tests/internal-ai/complete-structured.test.ts +37 -1
  218. package/src/tests/internal-ai/credentials.test.ts +23 -0
  219. package/src/tests/kv-http.test.ts +32 -0
  220. package/src/tests/kv-tool.test.ts +25 -0
  221. package/src/tests/multi-runtime-registration.test.ts +12 -12
  222. package/src/tests/onboarding.test.ts +108 -1
  223. package/src/tests/openai-embedding-credentials.test.ts +35 -0
  224. package/src/tests/openrouter-attribution-call-sites.test.ts +2 -2
  225. package/src/tests/org-website.test.ts +36 -0
  226. package/src/tests/provider-key-probes.test.ts +181 -0
  227. package/src/tests/rbac-e2e-helpers.test.ts +88 -1
  228. package/src/tests/rbac-e2e-helpers.ts +36 -11
  229. package/src/tests/script-connections.test.ts +2 -0
  230. package/src/tests/script-runs-retention.test.ts +212 -0
  231. package/src/tests/sdk-allowlist.test.ts +39 -0
  232. package/src/tests/session-source-backfill.test.ts +429 -0
  233. package/src/tests/session-source-keys.test.ts +236 -0
  234. package/src/tests/session-source-labels.test.ts +419 -0
  235. package/src/tests/sessions-detail-batch.test.ts +2 -0
  236. package/src/tests/sessions-move.test.ts +131 -0
  237. package/src/tests/sessions-tree.test.ts +299 -2
  238. package/src/tests/slack-inbound-files.test.ts +103 -23
  239. package/src/tests/status.test.ts +101 -18
  240. package/src/tests/tasks-workflow-run-filter.test.ts +146 -0
  241. package/src/tests/test-net.ts +31 -0
  242. package/src/tests/transcription.test.ts +8 -0
  243. package/src/tests/used-by-route.test.ts +11 -0
  244. package/src/tests/workflow-cascade-cancel.test.ts +172 -0
  245. package/src/tests/workflow-http-v2.test.ts +131 -0
  246. package/src/tests/workflow-step-order.test.ts +92 -0
  247. package/src/tests/workflow-sub-workflow.test.ts +30 -0
  248. package/src/tools/get-tasks.ts +8 -0
  249. package/src/tools/kv/kv-read-auth.ts +4 -2
  250. package/src/tools/task-action.ts +1 -1
  251. package/src/tools/workflows/get-workflow-run.ts +14 -4
  252. package/src/types.ts +24 -0
  253. package/src/utils/company-profile.ts +51 -1
  254. package/src/utils/credentials.ts +5 -2
  255. package/src/utils/internal-ai/complete-structured.ts +5 -0
  256. package/src/utils/internal-ai/credentials.ts +4 -3
  257. package/src/utils/provider-key-probes.ts +328 -0
  258. package/src/utils/transcription.ts +4 -1
  259. package/src/workflows/executors/sub-workflow.ts +8 -1
  260. package/templates/ai-toolbox.manifest.json +2 -2
  261. package/templates/skills/researching/content.md +2 -0
  262. package/templates/skills/swarm-scripts/SKILL.md +1 -0
  263. package/templates/skills/swarm-scripts/content.md +1 -0
  264. package/dist/cli-7421jzsw.js +0 -84
  265. package/dist/cli-e45e4m6b.js +0 -413
  266. package/dist/cli-zw88zjba.js +0 -307
  267. package/dist/handlers-wy1dr6ja.js +0 -41
  268. package/dist/index-nmm79qgr.js +0 -43
  269. package/dist/lead-sawnyqxk.js +0 -79
  270. package/dist/render-v2-pjwxvnkw.js +0 -12
  271. package/dist/worker-v5e5rxj3.js +0 -79
@@ -0,0 +1,181 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import { checkProviderPoolKey } from "../utils/provider-key-probes";
3
+
4
+ describe("API pool key probes", () => {
5
+ const realFetch = globalThis.fetch;
6
+ const originalNodeEnv = process.env.NODE_ENV;
7
+ const originalPrivateUrls = process.env.ALLOW_PRIVATE_NETWORK_URLS;
8
+
9
+ afterEach(() => {
10
+ globalThis.fetch = realFetch;
11
+ if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
12
+ else process.env.NODE_ENV = originalNodeEnv;
13
+ if (originalPrivateUrls === undefined) delete process.env.ALLOW_PRIVATE_NETWORK_URLS;
14
+ else process.env.ALLOW_PRIVATE_NETWORK_URLS = originalPrivateUrls;
15
+ });
16
+
17
+ test("uses the supplied OpenRouter URL and forbids redirects", async () => {
18
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
19
+ globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
20
+ calls.push({ url: String(url), init });
21
+ return new Response("{}", { status: calls.length === 1 ? 404 : 200 });
22
+ }) as typeof fetch;
23
+ expect(
24
+ await checkProviderPoolKey("OPENROUTER_API_KEY", "fake-abcde", {
25
+ OPENROUTER_BASE_URL: "https://gateway.example/v1/",
26
+ }),
27
+ ).toMatchObject({ ok: true });
28
+ expect(calls.map((call) => call.url)).toEqual([
29
+ "https://gateway.example/v1/key",
30
+ "https://gateway.example/v1/models",
31
+ ]);
32
+ expect(calls.every((call) => call.init?.redirect === "manual")).toBe(true);
33
+ });
34
+
35
+ test("rejects a private registry URL before sending credentials", async () => {
36
+ process.env.NODE_ENV = "production";
37
+ delete process.env.ALLOW_PRIVATE_NETWORK_URLS;
38
+ const fetch = mock(async () => new Response("{}"));
39
+ globalThis.fetch = fetch as unknown as typeof globalThis.fetch;
40
+ const result = await checkProviderPoolKey("NAN_API_KEY", "fake-abcde", {
41
+ NAN_BASE_URL: "https://127.0.0.1/v1",
42
+ });
43
+ expect(result.ok).toBe(false);
44
+ expect(result.httpStatus).toBeUndefined();
45
+ expect(fetch).not.toHaveBeenCalled();
46
+ });
47
+
48
+ test("supports registry aliases and configured URLs", async () => {
49
+ const fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
50
+ expect(String(url)).toBe("https://gateway.example/v1/models");
51
+ expect(init?.headers).toEqual({ Authorization: "Bearer fake-abcde" });
52
+ expect(init?.redirect).toBe("manual");
53
+ return new Response("Unauthorized", { status: 401 });
54
+ });
55
+ globalThis.fetch = fetch as typeof globalThis.fetch;
56
+ expect(
57
+ await checkProviderPoolKey("HELMCODE_API_NAN", "fake-abcde", {
58
+ NAN_BASE_URL: "https://gateway.example/v1",
59
+ }),
60
+ ).toMatchObject({ ok: false, httpStatus: 401 });
61
+ });
62
+
63
+ test.each([
64
+ [200, "{}", true, false],
65
+ [403, '{"error":{"type":"permission_error"}}', true, false],
66
+ [403, '{"error":{"type":"other"}}', false, false],
67
+ [401, "Unauthorized", false, false],
68
+ [429, "Throttled", true, true],
69
+ [503, "Unavailable", true, true],
70
+ ])("preserves Claude OAuth semantics for HTTP %i", async (status, body, ok, skipped) => {
71
+ globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
72
+ expect(String(url)).toBe("https://api.anthropic.com/api/oauth/profile");
73
+ expect(init?.redirect).toBe("manual");
74
+ expect(init?.signal).toBeInstanceOf(AbortSignal);
75
+ return new Response(body, { status });
76
+ }) as typeof fetch;
77
+ const result = await checkProviderPoolKey("CLAUDE_CODE_OAUTH_TOKEN", "fake-abcde", {});
78
+ expect(result.ok).toBe(ok);
79
+ expect(result.skipped === true).toBe(skipped);
80
+ if (!ok) expect(result.httpStatus).toBe(status);
81
+ });
82
+
83
+ test("checks an Anthropic key at its gateway, keeping the URL path prefix", async () => {
84
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
85
+ globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
86
+ calls.push({ url: String(url), init });
87
+ return new Response("Unauthorized", { status: 401 });
88
+ }) as unknown as typeof fetch;
89
+ const result = await checkProviderPoolKey("ANTHROPIC_API_KEY", "fake-abcde", {
90
+ ANTHROPIC_BASE_URL: "https://gateway.example/api",
91
+ });
92
+ expect(result).toMatchObject({ ok: false, httpStatus: 401 });
93
+ expect(calls.map((call) => call.url)).toEqual(["https://gateway.example/api/v1/models"]);
94
+ expect(new Headers(calls[0]?.init?.headers).get("x-api-key")).toBe("fake-abcde");
95
+ expect(calls[0]?.init?.redirect).toBe("manual");
96
+
97
+ globalThis.fetch = mock(async () => new Response("{}")) as unknown as typeof fetch;
98
+ expect(
99
+ await checkProviderPoolKey("ANTHROPIC_API_KEY", "fake-abcde", {
100
+ ANTHROPIC_BASE_URL: "https://gateway.example/api",
101
+ }),
102
+ ).toMatchObject({ ok: true });
103
+ });
104
+
105
+ test("a gateway that lists no models leaves the Anthropic key unchecked", async () => {
106
+ globalThis.fetch = mock(
107
+ async () => new Response("Not found", { status: 404 }),
108
+ ) as unknown as typeof fetch;
109
+ expect(
110
+ await checkProviderPoolKey("ANTHROPIC_API_KEY", "fake-abcde", {
111
+ ANTHROPIC_BASE_URL: "https://gateway.example",
112
+ }),
113
+ ).toMatchObject({ ok: true, skipped: true });
114
+ });
115
+
116
+ test("an Anthropic key stays on api.anthropic.com when the gateway uses ANTHROPIC_AUTH_TOKEN", async () => {
117
+ const urls: string[] = [];
118
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
119
+ urls.push(String(url));
120
+ return new Response("{}");
121
+ }) as unknown as typeof fetch;
122
+ expect(
123
+ await checkProviderPoolKey("ANTHROPIC_API_KEY", "fake-abcde", {
124
+ ANTHROPIC_BASE_URL: "https://gateway.example",
125
+ ANTHROPIC_AUTH_TOKEN: "gateway-token",
126
+ }),
127
+ ).toMatchObject({ ok: true });
128
+ expect(urls).toEqual(["https://api.anthropic.com/v1/models"]);
129
+ });
130
+
131
+ test("a private Anthropic gateway gets no credentials and no rejection", async () => {
132
+ process.env.NODE_ENV = "production";
133
+ delete process.env.ALLOW_PRIVATE_NETWORK_URLS;
134
+ const fetch = mock(async () => new Response("{}"));
135
+ globalThis.fetch = fetch as unknown as typeof globalThis.fetch;
136
+ const result = await checkProviderPoolKey("ANTHROPIC_API_KEY", "fake-abcde", {
137
+ ANTHROPIC_BASE_URL: "http://127.0.0.1:4000",
138
+ });
139
+ expect(result.ok).toBe(false);
140
+ expect(result.httpStatus).toBeUndefined();
141
+ expect(fetch).not.toHaveBeenCalled();
142
+ });
143
+
144
+ test("skips keys without an API-safe probe", async () => {
145
+ const fetch = mock(async () => new Response("{}"));
146
+ globalThis.fetch = fetch as unknown as typeof globalThis.fetch;
147
+ for (const keyType of ["DEVIN_API_KEY", "AMP_API_KEY", "CURSOR_API_KEY"]) {
148
+ expect(await checkProviderPoolKey(keyType, "fake-abcde", {})).toMatchObject({
149
+ ok: true,
150
+ skipped: true,
151
+ });
152
+ }
153
+ expect(fetch).not.toHaveBeenCalled();
154
+ });
155
+
156
+ test("reports network errors without exposing the submitted key", async () => {
157
+ globalThis.fetch = mock(async () => {
158
+ throw new Error("Rejected fake-abcde");
159
+ }) as unknown as typeof fetch;
160
+ const result = await checkProviderPoolKey("OPENAI_API_KEY", "fake-abcde", {});
161
+ expect(result.ok).toBe(false);
162
+ expect(result.error).not.toContain("fake-abcde");
163
+ });
164
+
165
+ test.each([
166
+ "ANTHROPIC_API_KEY",
167
+ "OPENAI_API_KEY",
168
+ "OPENROUTER_API_KEY",
169
+ "NAN_API_KEY",
170
+ "CLAUDE_CODE_OAUTH_TOKEN",
171
+ ])("redacts a long echoed %s before truncating the provider error", async (keyType) => {
172
+ const apiKey = "Z".repeat(240);
173
+ globalThis.fetch = mock(
174
+ async () => new Response(`Rejected ${apiKey}`, { status: 403 }),
175
+ ) as unknown as typeof fetch;
176
+ const result = await checkProviderPoolKey(keyType, apiKey, {});
177
+ expect(result).toMatchObject({ ok: false, httpStatus: 403 });
178
+ expect(result.error).toContain("[REDACTED]");
179
+ expect(result.error).not.toContain(apiKey.slice(0, 40));
180
+ });
181
+ });
@@ -1,9 +1,23 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import { closeSync, openSync } from "node:fs";
3
3
  import { mkdtemp, rm } from "node:fs/promises";
4
+ import { createServer } from "node:net";
4
5
  import { tmpdir } from "node:os";
5
6
  import { join } from "node:path";
6
- import { type SwarmServer, waitForListen } from "./rbac-e2e-helpers";
7
+ import {
8
+ makeScratchDir,
9
+ removeScratchDir,
10
+ type SwarmServer,
11
+ spawnSwarmServer,
12
+ waitForListen,
13
+ } from "./rbac-e2e-helpers";
14
+ import {
15
+ getFreePort,
16
+ listenOnFreePort,
17
+ PortInUseError,
18
+ SERVER_BOOT_HOOK_TIMEOUT_MS,
19
+ startOnFreePort,
20
+ } from "./test-net";
7
21
 
8
22
  test("waitForListen includes a bounded server log when the process exits early", async () => {
9
23
  const dir = await mkdtemp(join(tmpdir(), "rbac-e2e-helper-"));
@@ -51,3 +65,76 @@ test("waitForListen includes a bounded server log when the process exits early",
51
65
  await rm(dir, { recursive: true, force: true });
52
66
  }
53
67
  });
68
+
69
+ test("startOnFreePort retries only PortInUseError, up to its attempt bound", async () => {
70
+ const ports = [41001, 41002, 41003, 41004];
71
+ const getPort = async () => ports.shift() ?? 0;
72
+ const tried: number[] = [];
73
+
74
+ const port = await startOnFreePort(
75
+ async (p) => {
76
+ tried.push(p);
77
+ if (p === 41001) throw new PortInUseError("taken");
78
+ return p;
79
+ },
80
+ { getPort },
81
+ );
82
+ expect(port).toBe(41002);
83
+ expect(tried).toEqual([41001, 41002]);
84
+
85
+ const other = await startOnFreePort(
86
+ async () => {
87
+ throw new Error("boot crashed");
88
+ },
89
+ { getPort },
90
+ ).catch((error: unknown) => error);
91
+ expect((other as Error).message).toBe("boot crashed");
92
+ expect(ports).toEqual([41004]);
93
+
94
+ let calls = 0;
95
+ const exhausted = await startOnFreePort(
96
+ async () => {
97
+ calls++;
98
+ throw new PortInUseError(`taken ${calls}`);
99
+ },
100
+ { getPort: async () => 0 },
101
+ ).catch((error: unknown) => error);
102
+ expect(exhausted).toBeInstanceOf(PortInUseError);
103
+ expect((exhausted as Error).message).toBe("taken 3");
104
+ expect(calls).toBe(3);
105
+ });
106
+
107
+ test(
108
+ "spawnSwarmServer starts again on a new port when its first port is taken",
109
+ async () => {
110
+ const dir = await makeScratchDir();
111
+ const holder = createServer();
112
+ const heldPort = await listenOnFreePort(holder);
113
+ let server: SwarmServer | undefined;
114
+ try {
115
+ const offered: number[] = [];
116
+ server = await spawnSwarmServer({
117
+ dbPath: join(dir, "test.sqlite"),
118
+ logPath: join(dir, "server.log"),
119
+ getPort: async () => {
120
+ const port = offered.length === 0 ? heldPort : await getFreePort();
121
+ offered.push(port);
122
+ return port;
123
+ },
124
+ });
125
+
126
+ expect(offered).toHaveLength(2);
127
+ expect(offered[0]).toBe(heldPort);
128
+ expect(server.port).toBe(offered[1]);
129
+ expect(server.port).not.toBe(heldPort);
130
+ expect((await fetch(`${server.base}/docs`)).ok).toBe(true);
131
+ const log = await Bun.file(server.logPath).text();
132
+ expect(log).toContain(`Is port ${heldPort} in use?`);
133
+ } finally {
134
+ await server?.stop();
135
+ holder.close();
136
+ await removeScratchDir(dir);
137
+ }
138
+ },
139
+ SERVER_BOOT_HOOK_TIMEOUT_MS * 2,
140
+ );
@@ -14,7 +14,7 @@ import { openSync } from "node:fs";
14
14
  import { mkdtemp, rm } from "node:fs/promises";
15
15
  import { tmpdir } from "node:os";
16
16
  import { dirname, join } from "node:path";
17
- import { getFreePort } from "./test-net";
17
+ import { getFreePort, PORT_IN_USE_LOG, PortInUseError, startOnFreePort } from "./test-net";
18
18
 
19
19
  // Re-exported for the suites that imported it from here before test-net.ts existed.
20
20
  export { getFreePort };
@@ -50,6 +50,9 @@ export async function removeScratchDir(dir: string): Promise<void> {
50
50
  * Spawn `bun src/http.ts` against `dbPath` on a free port. Integrations are
51
51
  * disabled; both API-key env vars are pinned so a developer's shell env can't
52
52
  * leak in (AGENT_SWARM_API_KEY takes precedence in getApiKey()).
53
+ *
54
+ * With waitForListen, a child that exits because another process took its
55
+ * port during boot is started again on a new port (startOnFreePort).
53
56
  */
54
57
  export async function spawnSwarmServer(opts: {
55
58
  dbPath: string;
@@ -57,9 +60,38 @@ export async function spawnSwarmServer(opts: {
57
60
  env?: Record<string, string>;
58
61
  /** Poll /docs until the server accepts requests (default true). */
59
62
  waitForListen?: boolean;
63
+ /** Port source for each attempt (default getFreePort). Tests use it to force a taken port. */
64
+ getPort?: () => Promise<number>;
60
65
  }): Promise<SwarmServer> {
61
- const port = await getFreePort();
62
66
  const logFd = openSync(opts.logPath, "a");
67
+ return startOnFreePort(
68
+ async (port) => {
69
+ const log = Bun.file(opts.logPath);
70
+ const logStart = (await log.exists()) ? log.size : 0;
71
+ const server = launchSwarmServer(opts, port, logFd);
72
+ if (opts.waitForListen === false) return server;
73
+ try {
74
+ await waitForListen(server);
75
+ } catch (error) {
76
+ if (server.proc.exitCode !== null) {
77
+ const attemptLog = await Bun.file(opts.logPath).slice(logStart).text();
78
+ if (PORT_IN_USE_LOG.test(attemptLog)) {
79
+ throw new PortInUseError((error as Error).message, { cause: error });
80
+ }
81
+ }
82
+ throw error;
83
+ }
84
+ return server;
85
+ },
86
+ { getPort: opts.getPort },
87
+ );
88
+ }
89
+
90
+ function launchSwarmServer(
91
+ opts: { dbPath: string; logPath: string; env?: Record<string, string> },
92
+ port: number,
93
+ logFd: number,
94
+ ): SwarmServer {
63
95
  const proc = Bun.spawn(["bun", "src/http.ts"], {
64
96
  cwd: REPO_ROOT,
65
97
  stdout: logFd,
@@ -93,12 +125,10 @@ export async function spawnSwarmServer(opts: {
93
125
  ...opts.env,
94
126
  },
95
127
  });
96
- const base = `http://localhost:${port}`;
97
-
98
- const server: SwarmServer = {
128
+ return {
99
129
  proc,
100
130
  port,
101
- base,
131
+ base: `http://localhost:${port}`,
102
132
  dbPath: opts.dbPath,
103
133
  logPath: opts.logPath,
104
134
  async stop() {
@@ -107,11 +137,6 @@ export async function spawnSwarmServer(opts: {
107
137
  return proc.exitCode;
108
138
  },
109
139
  };
110
-
111
- if (opts.waitForListen !== false) {
112
- await waitForListen(server);
113
- }
114
- return server;
115
140
  }
116
141
 
117
142
  export async function waitForListen(server: SwarmServer, deadlineMs = 90_000): Promise<void> {
@@ -651,6 +651,8 @@ describe("script connections", () => {
651
651
  markMigrationApplied(database, "240_workflow_run_parent_step_attempt.sql");
652
652
  // 241 indexes skills, which this migration-112-only fixture does not create.
653
653
  markMigrationApplied(database, "241_skills_meta_fts.sql");
654
+ // 244 alters api_key_status, which this migration-112-only fixture does not create.
655
+ markMigrationApplied(database, "244_api_key_validation.sql");
654
656
 
655
657
  const id = crypto.randomUUID();
656
658
  const now = new Date().toISOString();
@@ -0,0 +1,212 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb, getDbClient, initDb } from "../be/db";
4
+ import {
5
+ DB_RETENTION_TABLES,
6
+ getDbRetentionStats,
7
+ resetDbRetentionForTests,
8
+ runDbRetentionTick,
9
+ sweepStatements,
10
+ } from "../be/db-retention";
11
+
12
+ const TEST_DB_PATH = "./test-script-runs-retention.sqlite";
13
+ const DAYS_KEY = "SCRIPT_RUNS_RETENTION_DAYS";
14
+ // With 1 day of retention the cutoff is 2026-08-22T12:00:00.000Z.
15
+ const NOW = new Date("2026-08-23T12:00:00.000Z");
16
+ const OLD_ISO = "2026-08-01T00:00:00.000Z";
17
+ const OLD_SPACED = "2026-08-01 00:00:00";
18
+ const RECENT_ISO = "2026-08-23T11:00:00.000Z";
19
+
20
+ const SCRIPT_RUNS = DB_RETENTION_TABLES.find((table) => table.table === "script_runs");
21
+ if (!SCRIPT_RUNS) throw new Error("script_runs is not in the retention allowlist");
22
+
23
+ async function removeDbFiles(): Promise<void> {
24
+ for (const suffix of ["", "-wal", "-shm"])
25
+ await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => undefined);
26
+ }
27
+
28
+ async function insertRun(
29
+ id: string,
30
+ status: string,
31
+ startedAt: string,
32
+ options: { kind?: "inline" | "workflow"; idempotencyKey?: string } = {},
33
+ ): Promise<void> {
34
+ await getDbClient().run(
35
+ `INSERT INTO script_runs (id, agentId, scriptName, source, args, status, startedAt, kind, idempotencyKey)
36
+ VALUES (?, 'agent-1', 'retention-test', 'export default () => 1', '{}', ?, ?, ?, ?)`,
37
+ [id, status, startedAt, options.kind ?? "inline", options.idempotencyKey ?? null],
38
+ );
39
+ }
40
+
41
+ async function insertJournal(runId: string, stepKey: string): Promise<void> {
42
+ await getDbClient().run(
43
+ "INSERT INTO script_run_journal (id, runId, stepKey, stepType, status) VALUES (?, ?, ?, 'tool', 'completed')",
44
+ [`${runId}-${stepKey}`, runId, stepKey],
45
+ );
46
+ }
47
+
48
+ async function runIds(): Promise<string[]> {
49
+ const rows = await getDbClient().query<{ id: string }>("SELECT id FROM script_runs ORDER BY id");
50
+ return rows.map((row) => row.id);
51
+ }
52
+
53
+ async function journalRunIds(): Promise<string[]> {
54
+ const rows = await getDbClient().query<{ runId: string }>(
55
+ "SELECT DISTINCT runId FROM script_run_journal ORDER BY runId",
56
+ );
57
+ return rows.map((row) => row.runId);
58
+ }
59
+
60
+ beforeAll(async () => {
61
+ closeDb();
62
+ await removeDbFiles();
63
+ initDb(TEST_DB_PATH);
64
+ });
65
+
66
+ afterAll(async () => {
67
+ await resetDbRetentionForTests();
68
+ delete process.env[DAYS_KEY];
69
+ delete process.env.DB_RETENTION_DRY_RUN;
70
+ closeDb();
71
+ await removeDbFiles();
72
+ });
73
+
74
+ beforeEach(async () => {
75
+ await resetDbRetentionForTests();
76
+ delete process.env[DAYS_KEY];
77
+ delete process.env.DB_RETENTION_DRY_RUN;
78
+ await getDbClient().run("DELETE FROM script_run_journal");
79
+ await getDbClient().run("DELETE FROM script_runs");
80
+ });
81
+
82
+ describe("script_runs age-based retention", () => {
83
+ test("is opt-in: an unset key deletes nothing", async () => {
84
+ await insertRun("old-completed", "completed", OLD_ISO);
85
+
86
+ await runDbRetentionTick({ now: NOW });
87
+
88
+ expect(await runIds()).toEqual(["old-completed"]);
89
+ expect(getDbRetentionStats().scriptRuns).toBeUndefined();
90
+ });
91
+
92
+ test("deletes old finished runs and keeps running, paused, recent and keyed runs", async () => {
93
+ for (const status of ["completed", "failed", "cancelled", "aborted_limit"]) {
94
+ await insertRun(`old-${status}-iso`, status, OLD_ISO);
95
+ await insertRun(`old-${status}-spaced`, status, OLD_SPACED, { kind: "workflow" });
96
+ }
97
+ await insertRun("old-running", "running", OLD_SPACED, { kind: "workflow" });
98
+ await insertRun("old-paused", "paused", OLD_SPACED, { kind: "workflow" });
99
+ await insertRun("old-keyed", "completed", OLD_SPACED, {
100
+ kind: "workflow",
101
+ idempotencyKey: "launch-once",
102
+ });
103
+ await insertRun("recent-completed", "completed", RECENT_ISO);
104
+ process.env[DAYS_KEY] = "1";
105
+
106
+ await runDbRetentionTick({ now: NOW });
107
+
108
+ expect(await runIds()).toEqual(["old-keyed", "old-paused", "old-running", "recent-completed"]);
109
+ expect(getDbRetentionStats().scriptRuns).toMatchObject({
110
+ rowsDeleted: 8,
111
+ drained: true,
112
+ backlogRemaining: 0,
113
+ dryRun: false,
114
+ });
115
+ });
116
+
117
+ test("journal rows leave with their run and stay with a surviving one", async () => {
118
+ await insertRun("old-done", "completed", OLD_SPACED, { kind: "workflow" });
119
+ await insertRun("old-paused", "paused", OLD_SPACED, { kind: "workflow" });
120
+ for (const step of ["a", "b", "c"]) {
121
+ await insertJournal("old-done", step);
122
+ await insertJournal("old-paused", step);
123
+ }
124
+ process.env[DAYS_KEY] = "1";
125
+
126
+ await runDbRetentionTick({ now: NOW });
127
+
128
+ expect(await runIds()).toEqual(["old-paused"]);
129
+ expect(await journalRunIds()).toEqual(["old-paused"]);
130
+ const journal = await getDbClient().get<{ n: number }>(
131
+ "SELECT COUNT(*) AS n FROM script_run_journal",
132
+ );
133
+ expect(journal?.n).toBe(3);
134
+ });
135
+
136
+ test("never deletes a row early across the two stored timestamp formats", async () => {
137
+ // Cutoff: 2026-08-22T12:00:00.000Z, compared as '2026-08-22 12:00:00'.
138
+ await insertRun("spaced-before", "completed", "2026-08-22 11:59:59", { kind: "workflow" });
139
+ await insertRun("spaced-at", "completed", "2026-08-22 12:00:00", { kind: "workflow" });
140
+ await insertRun("iso-previous-day", "completed", "2026-08-21T23:59:59.999Z");
141
+ // Older than the cutoff, but on the cutoff's own date: kept until a later tick.
142
+ await insertRun("iso-cutoff-day", "completed", "2026-08-22T11:00:00.000Z");
143
+ await insertRun("iso-after", "completed", "2026-08-22T12:00:00.001Z");
144
+ process.env[DAYS_KEY] = "1";
145
+
146
+ await runDbRetentionTick({ now: NOW });
147
+
148
+ expect(await runIds()).toEqual(["iso-after", "iso-cutoff-day", "spaced-at"]);
149
+
150
+ // A day later every one of them is past the cutoff's date.
151
+ await runDbRetentionTick({ now: new Date("2026-08-24T00:00:00.000Z") });
152
+
153
+ expect(await runIds()).toEqual([]);
154
+ });
155
+
156
+ test("dry run counts the would-delete rows without deleting", async () => {
157
+ await insertRun("old-completed", "completed", OLD_ISO);
158
+ await insertRun("old-failed", "failed", OLD_SPACED, { kind: "workflow" });
159
+ await insertRun("old-running", "running", OLD_SPACED, { kind: "workflow" });
160
+ process.env[DAYS_KEY] = "1";
161
+ process.env.DB_RETENTION_DRY_RUN = "true";
162
+
163
+ await runDbRetentionTick({ now: NOW });
164
+
165
+ expect(await runIds()).toHaveLength(3);
166
+ expect(getDbRetentionStats().scriptRuns).toMatchObject({
167
+ rowsDeleted: 0,
168
+ backlogRemaining: 2,
169
+ dryRun: true,
170
+ });
171
+ });
172
+
173
+ test("drains a backlog larger than one batch within the batch ceiling", async () => {
174
+ await getDbClient().run(
175
+ `WITH RECURSIVE n(v) AS (SELECT 1 UNION ALL SELECT v + 1 FROM n WHERE v < 1200)
176
+ INSERT INTO script_runs (id, agentId, source, status, startedAt)
177
+ SELECT 'bulk-' || v, 'agent-1', 'x', 'completed', ? FROM n`,
178
+ [OLD_ISO],
179
+ );
180
+ process.env[DAYS_KEY] = "1";
181
+
182
+ await runDbRetentionTick({ now: NOW });
183
+
184
+ const stats = getDbRetentionStats().scriptRuns;
185
+ expect(await runIds()).toEqual([]);
186
+ expect(stats?.rowsDeleted).toBe(1200);
187
+ expect(stats?.batches).toBeGreaterThan(1);
188
+ expect(stats?.batchSize).toBeLessThanOrEqual(500);
189
+ });
190
+
191
+ test("the delete, the backlog count and the journal cascade all use an index", async () => {
192
+ const statements = sweepStatements(SCRIPT_RUNS, 30, NOW);
193
+ const client = getDbClient();
194
+
195
+ for (const [sql, params] of [
196
+ [statements.deleteSql, [...statements.params, 100]],
197
+ [statements.backlogSql, statements.params],
198
+ ] as const) {
199
+ const plan = await client.query<{ detail: string }>(`EXPLAIN QUERY PLAN ${sql}`, [...params]);
200
+ const detail = plan.map((row) => row.detail).join("\n");
201
+ expect(detail).toContain("USING COVERING INDEX idx_script_runs_status_startedAt");
202
+ expect(detail).not.toMatch(/SCAN script_runs(?! USING)/);
203
+ }
204
+
205
+ // ON DELETE CASCADE looks up journal rows of every deleted run by runId.
206
+ const fkPlan = await client.query<{ detail: string }>(
207
+ "EXPLAIN QUERY PLAN SELECT rowid FROM script_run_journal WHERE runId = ?",
208
+ ["x"],
209
+ );
210
+ expect(fkPlan.map((row) => row.detail).join("\n")).toContain("idx_srj_runId");
211
+ });
212
+ });
@@ -98,6 +98,45 @@ describe("script SDK allowlist", () => {
98
98
  }
99
99
  });
100
100
 
101
+ test("workflow_getRun forwards fields to the run route and rejects an unknown value", async () => {
102
+ const requests: URL[] = [];
103
+ const httpServer = Bun.serve({
104
+ port: 0,
105
+ fetch(req) {
106
+ requests.push(new URL(req.url));
107
+ return Response.json({ run: { id: "run-1" }, steps: [] });
108
+ },
109
+ });
110
+ const config = new SwarmConfig({
111
+ system: {
112
+ apiKey: { value: "sdk-test-key", isSecret: true },
113
+ agentId: { value: "sdk-test-agent", isSecret: false },
114
+ mcpBaseUrl: { value: `http://127.0.0.1:${httpServer.port}`, isSecret: false },
115
+ },
116
+ user: {},
117
+ });
118
+ const sdk = createSwarmSdk(config);
119
+
120
+ try {
121
+ await sdk.workflow_getRun({ id: "run-1", fields: "slim" });
122
+ await sdk.workflow_getRun({ id: "run-1" });
123
+ expect(requests.map((url) => url.pathname)).toEqual([
124
+ "/api/workflow-runs/run-1",
125
+ "/api/workflow-runs/run-1",
126
+ ]);
127
+ expect(requests[0]?.searchParams.get("fields")).toBe("slim");
128
+ // Omitted `fields` sends no query, so the route keeps its full default.
129
+ expect(requests[1]?.search).toBe("");
130
+
131
+ await expect(sdk.workflow_getRun({ id: "run-1", fields: "tiny" })).rejects.toThrow(
132
+ '`fields` must be "full" or "slim"',
133
+ );
134
+ expect(requests).toHaveLength(2);
135
+ } finally {
136
+ httpServer.stop(true);
137
+ }
138
+ });
139
+
101
140
  test("approval_cancel posts to the cancel route with the reason", async () => {
102
141
  let request: { method: string; path: string; body: unknown } | undefined;
103
142
  const httpServer = Bun.serve({