@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
package/src/http/kv.ts CHANGED
@@ -12,7 +12,11 @@ import {
12
12
  upsertKv,
13
13
  } from "../be/db";
14
14
  import { mcpOverflowAuthError } from "../kv-overflow";
15
- import { reservedNamespaceError, reservedRoomKeyError } from "../kv-reserved-namespaces";
15
+ import {
16
+ privateNamespaceError,
17
+ reservedNamespaceError,
18
+ reservedRoomKeyError,
19
+ } from "../kv-reserved-namespaces";
16
20
  import { hasKvViewArgs, resolveKvView } from "../kv-view";
17
21
  import { can } from "../rbac";
18
22
  import { agentContextKey, pageContextKey } from "../tasks/context-key";
@@ -405,8 +409,9 @@ function authorizeRead(
405
409
  namespace: string,
406
410
  ctx: AuthCtx,
407
411
  ): { status: number; message: string } | null {
408
- const overflowAuthErr = mcpOverflowAuthError(namespace, ctx.callerAgentId);
409
- return overflowAuthErr ? { status: 403, message: overflowAuthErr } : null;
412
+ const readErr =
413
+ privateNamespaceError(namespace) ?? mcpOverflowAuthError(namespace, ctx.callerAgentId);
414
+ return readErr ? { status: 403, message: readErr } : null;
410
415
  }
411
416
 
412
417
  function encodeValueOrError(
@@ -21,6 +21,7 @@ import {
21
21
  } from "../be/onboarding";
22
22
  import { assertUrlSafe, publicEndpointSsrfOptions } from "../oauth/mcp-wrapper";
23
23
  import { ProviderNameSchema } from "../types";
24
+ import { splitCredentialPool } from "../utils/credentials";
24
25
  import { getOpenRouterAttributionHeaders } from "../utils/openrouter-base-url";
25
26
  import { ensureConfigAdmin } from "./config";
26
27
  import { scheduleIntegrationsReload } from "./core";
@@ -36,11 +37,14 @@ async function buildSignals(state: OnboardingState): Promise<OnboardingSignals>
36
37
  const provider = ProviderNameSchema.safeParse(entry.provider);
37
38
  if (!provider.success) continue;
38
39
  const rollup = await rollupCredStatusForProvider(provider.data);
40
+ const live = rollup.latestLiveTest;
39
41
  providers.push({
40
42
  provider: provider.data,
41
43
  state: rollup.state,
42
44
  workers: rollup.workers,
43
45
  verifiedWorkers: rollup.verifiedWorkers,
46
+ error: live && !live.ok ? (live.error ?? null) : null,
47
+ warning: live?.ok ? (live.warning ?? null) : null,
44
48
  });
45
49
  }
46
50
 
@@ -241,7 +245,8 @@ async function handleMemoryProbe(body: OnboardingMemoryRequest): Promise<Onboard
241
245
  body.preset === "existing"
242
246
  ? (process.env.EMBEDDING_API_KEY ?? process.env.OPENAI_API_KEY)
243
247
  : undefined;
244
- const apiKey = explicitKey ?? reusedKey ?? implicitKey;
248
+ // Test and save the same pool member.
249
+ const apiKey = splitCredentialPool(explicitKey ?? reusedKey ?? implicitKey ?? "")[0];
245
250
 
246
251
  if (!apiKey) {
247
252
  return {
@@ -0,0 +1,354 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { baseUrlEnvVar, providerForKeyVar } from "@desplega/model-catalog";
3
+ import { z } from "zod";
4
+ import {
5
+ deleteSwarmConfigByKey,
6
+ getDbClient,
7
+ getKeyStatuses,
8
+ getSwarmConfigs,
9
+ recordKeyValidation,
10
+ } from "../be/db";
11
+ import { retirePoolKeyStatus } from "../be/db/api-keys";
12
+ import { upsertSwarmConfigWithPolicyMirror } from "../be/multi-runtime";
13
+ import { validateConfigEntry } from "../be/swarm-config-guard";
14
+ import type { SwarmConfig } from "../types";
15
+ import { CREDENTIAL_POOL_VARS, splitCredentialPool } from "../utils/credentials";
16
+ import { checkProviderPoolKey } from "../utils/provider-key-probes";
17
+ import { registerEnvSecrets, scrubSecrets } from "../utils/secret-scrubber";
18
+ import { ensureConfigAdmin, requestSecretReadScope, runConfigWriteSideEffects } from "./config";
19
+ import { getDeploymentEnvValue } from "./core";
20
+ import { route } from "./route-def";
21
+ import { jsonError } from "./utils";
22
+
23
+ export const PoolKeyStatusSchema = z.enum(["ok", "rejected", "rate_limited", "unchecked"]);
24
+ export type PoolKeyStatus = z.infer<typeof PoolKeyStatusSchema>;
25
+ export const PoolKeySchema = z.object({
26
+ index: z.number().int().min(0),
27
+ suffix: z.string(),
28
+ status: PoolKeyStatusSchema,
29
+ error: z.string().nullable(),
30
+ checkedAt: z.string().nullable(),
31
+ rateLimitedUntil: z.string().nullable(),
32
+ });
33
+ export type PoolKey = z.infer<typeof PoolKeySchema>;
34
+ export const PoolKeysResponseSchema = z.object({
35
+ key: z.string(),
36
+ source: z.enum(["stored", "env", "none"]),
37
+ readOnly: z.boolean(),
38
+ keys: z.array(PoolKeySchema),
39
+ });
40
+ export type PoolKeysResponse = z.infer<typeof PoolKeysResponseSchema>;
41
+ export const PoolKeyCheckResultSchema = z.enum(["verified", "rejected", "not_tested"]);
42
+ export type PoolKeyCheckResult = z.infer<typeof PoolKeyCheckResultSchema>;
43
+ export const PoolKeyCheckSchema = z.object({
44
+ suffix: z.string(),
45
+ result: PoolKeyCheckResultSchema,
46
+ httpStatus: z.number().nullable(),
47
+ error: z.string().nullable(),
48
+ });
49
+ export type PoolKeyCheck = z.infer<typeof PoolKeyCheckSchema>;
50
+
51
+ const poolVar = z
52
+ .string()
53
+ .refine(
54
+ (key) => key !== "CODEX_OAUTH" && CREDENTIAL_POOL_VARS.includes(key),
55
+ "Choose a credential pool variable.",
56
+ );
57
+ const getPoolKeys = route({
58
+ method: "get",
59
+ path: "/api/config/pool-keys",
60
+ pattern: ["api", "config", "pool-keys"],
61
+ summary: "List credential pool suffixes and status",
62
+ tags: ["Config"],
63
+ query: z.object({ key: poolVar }),
64
+ responses: {
65
+ 200: { description: "Credential pool", schema: PoolKeysResponseSchema },
66
+ 400: { description: "Invalid pool variable" },
67
+ 403: { description: "Secret read permission required" },
68
+ },
69
+ });
70
+ const addPoolKeys = route({
71
+ method: "post",
72
+ path: "/api/config/pool-keys",
73
+ pattern: ["api", "config", "pool-keys"],
74
+ summary: "Test and append keys to a credential pool",
75
+ tags: ["Config"],
76
+ rbac: { permission: "config.write.any" },
77
+ body: z.object({
78
+ key: poolVar,
79
+ value: z.string(),
80
+ // The OpenRouter card can switch away from a stored gateway before the new key is saved.
81
+ baseUrl: z
82
+ .string()
83
+ .nullable()
84
+ .optional()
85
+ .describe(
86
+ "Base URL for the check only. Null tests the provider default. Omit to use the stored URL.",
87
+ ),
88
+ }),
89
+ responses: {
90
+ 200: {
91
+ description: "Updated credential pool and checks",
92
+ schema: PoolKeysResponseSchema.extend({ checks: z.array(PoolKeyCheckSchema) }),
93
+ },
94
+ 400: { description: "Invalid key" },
95
+ 403: { description: "Config administration and permission to read all secrets required" },
96
+ 409: { description: "Duplicate key or deployment env pool" },
97
+ 422: {
98
+ description: "Provider rejected a key. Nothing was saved.",
99
+ schema: z.object({ error: z.string(), checks: z.array(PoolKeyCheckSchema) }),
100
+ },
101
+ },
102
+ });
103
+ const deletePoolKey = route({
104
+ method: "delete",
105
+ path: "/api/config/pool-keys",
106
+ pattern: ["api", "config", "pool-keys"],
107
+ summary: "Remove one credential pool member",
108
+ tags: ["Config"],
109
+ rbac: { permission: "config.delete.any" },
110
+ query: z.object({
111
+ key: poolVar,
112
+ index: z.coerce.number().int().min(0),
113
+ suffix: z.string().min(1).max(5),
114
+ }),
115
+ responses: {
116
+ 200: { description: "Updated credential pool", schema: PoolKeysResponseSchema },
117
+ 400: { description: "Invalid pool member" },
118
+ 403: { description: "Config administration and permission to read all secrets required" },
119
+ 409: { description: "The pool changed or comes from the deployment env" },
120
+ },
121
+ });
122
+
123
+ const ENV_POOL_ERROR = "These keys come from the deployment env. Change them there.";
124
+
125
+ async function readPool(key: string) {
126
+ const [row] = await getSwarmConfigs({ scope: "global", key });
127
+ const envValue = getDeploymentEnvValue(key);
128
+ const source = row ? "stored" : envValue ? "env" : "none";
129
+ const value = row?.value ?? envValue ?? "";
130
+ registerEnvSecrets({ [key]: value });
131
+ return { row, source, members: splitCredentialPool(value) } as const;
132
+ }
133
+
134
+ async function poolResponse(key: string): Promise<PoolKeysResponse> {
135
+ const pool = await readPool(key);
136
+ const statuses = await getKeyStatuses(key, "global", null);
137
+ return {
138
+ key,
139
+ source: pool.source,
140
+ readOnly: pool.source === "env",
141
+ keys: pool.members.map((member, index) => {
142
+ const suffix = member.slice(-5);
143
+ const row = statuses.find((status) => status.keySuffix === suffix);
144
+ const rejected = !!row?.lastValidationFailedAt;
145
+ const limited =
146
+ row?.status === "rate_limited" &&
147
+ !!row.rateLimitedUntil &&
148
+ Date.parse(row.rateLimitedUntil) > Date.now();
149
+ return {
150
+ index,
151
+ suffix,
152
+ status: rejected ? "rejected" : limited ? "rate_limited" : row ? "ok" : "unchecked",
153
+ error: rejected ? scrubSecrets(row?.lastValidationError) || null : null,
154
+ checkedAt: rejected ? row!.lastValidationFailedAt : (row?.updatedAt ?? null),
155
+ rateLimitedUntil: row?.rateLimitedUntil ?? null,
156
+ };
157
+ }),
158
+ };
159
+ }
160
+
161
+ function duplicateError(members: readonly string[], additions: string[]): string | null {
162
+ const seen = new Set(members);
163
+ for (const member of additions) {
164
+ if (seen.has(member)) return `The key ending ${member.slice(-5)} is already in the pool.`;
165
+ seen.add(member);
166
+ }
167
+ return null;
168
+ }
169
+
170
+ async function savePool(key: string, members: string[], row: SwarmConfig | undefined) {
171
+ const value = members.join(",");
172
+ const validationError = validateConfigEntry({
173
+ scope: "global",
174
+ key,
175
+ value,
176
+ exportToEnv: row?.exportToEnv,
177
+ });
178
+ if (validationError) return validationError;
179
+ await upsertSwarmConfigWithPolicyMirror({
180
+ scope: "global",
181
+ key,
182
+ value,
183
+ isSecret: true,
184
+ description: row?.description ?? null,
185
+ envPath: row?.envPath ?? null,
186
+ exportToEnv: row?.exportToEnv,
187
+ });
188
+ return null;
189
+ }
190
+
191
+ /** Every response lists the pool's key suffixes and statuses, so each route needs this gate. */
192
+ async function ensureSecretRead(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
193
+ if ((await requestSecretReadScope(req)) === "all") return true;
194
+ jsonError(res, "Reading credential pools requires permission to read all secrets.", 403);
195
+ return false;
196
+ }
197
+
198
+ export async function handlePoolKeys(
199
+ req: IncomingMessage,
200
+ res: ServerResponse,
201
+ pathSegments: string[],
202
+ queryParams: URLSearchParams,
203
+ ): Promise<boolean> {
204
+ if (getPoolKeys.match(req.method, pathSegments)) {
205
+ const parsed = await getPoolKeys.parse(req, res, pathSegments, queryParams);
206
+ if (!parsed) return true;
207
+ if (!(await ensureSecretRead(req, res))) return true;
208
+ getPoolKeys.respond(res, 200, await poolResponse(parsed.query.key));
209
+ return true;
210
+ }
211
+ if (addPoolKeys.match(req.method, pathSegments)) {
212
+ const parsed = await addPoolKeys.parse(req, res, pathSegments, queryParams);
213
+ if (!parsed) return true;
214
+ if (!(await ensureConfigAdmin(req, res, "config.write.any"))) return true;
215
+ if (!(await ensureSecretRead(req, res))) return true;
216
+ const { key, value, baseUrl } = parsed.body;
217
+ const additions = value
218
+ .split(",")
219
+ .map((part) => part.trim())
220
+ .filter(Boolean);
221
+ if (!additions.length) {
222
+ jsonError(res, "Enter a key.", 400);
223
+ return true;
224
+ }
225
+ if (additions.some((part) => /\s/.test(part))) {
226
+ jsonError(res, "A key cannot contain spaces or line breaks.", 400);
227
+ return true;
228
+ }
229
+ registerEnvSecrets({ [key]: additions.join(",") });
230
+ const initial = await readPool(key);
231
+ if (initial.source === "env") {
232
+ jsonError(res, ENV_POOL_ERROR, 409);
233
+ return true;
234
+ }
235
+ const duplicate = duplicateError(initial.members, additions);
236
+ if (duplicate) {
237
+ jsonError(res, scrubSecrets(duplicate), 409);
238
+ return true;
239
+ }
240
+ const env = { ...process.env };
241
+ const provider = providerForKeyVar(key);
242
+ const baseUrlKey =
243
+ key === "OPENROUTER_API_KEY"
244
+ ? "OPENROUTER_BASE_URL"
245
+ : provider
246
+ ? baseUrlEnvVar(provider)
247
+ : undefined;
248
+ if (baseUrlKey && baseUrl !== undefined) {
249
+ env[baseUrlKey] = baseUrl ?? "";
250
+ } else if (baseUrlKey) {
251
+ const [row] = await getSwarmConfigs({ scope: "global", key: baseUrlKey });
252
+ if (row) env[baseUrlKey] = row.value;
253
+ }
254
+ const checks = await Promise.all(
255
+ additions.map(async (member): Promise<PoolKeyCheck> => {
256
+ try {
257
+ const probe = await checkProviderPoolKey(key, member, env);
258
+ return {
259
+ suffix: member.slice(-5),
260
+ result: probe.skipped
261
+ ? "not_tested"
262
+ : probe.ok
263
+ ? "verified"
264
+ : probe.httpStatus === 401
265
+ ? "rejected"
266
+ : "not_tested",
267
+ httpStatus: probe.httpStatus ?? null,
268
+ error: scrubSecrets(probe.error?.replaceAll(member, "[REDACTED]")) || null,
269
+ };
270
+ } catch {
271
+ return {
272
+ suffix: member.slice(-5),
273
+ result: "not_tested",
274
+ httpStatus: null,
275
+ error: "The provider check did not complete.",
276
+ };
277
+ }
278
+ }),
279
+ );
280
+ const rejected = checks.find((check) => check.result === "rejected");
281
+ if (rejected) {
282
+ addPoolKeys.respond(res, 422, {
283
+ error: scrubSecrets(
284
+ `The key ending ${rejected.suffix} was rejected (HTTP 401). Nothing was saved.`,
285
+ ),
286
+ checks,
287
+ });
288
+ return true;
289
+ }
290
+ const result = await getDbClient().transaction(async () => {
291
+ const current = await readPool(key);
292
+ if (current.source === "env") return { error: ENV_POOL_ERROR, status: 409 };
293
+ const duplicate = duplicateError(current.members, additions);
294
+ if (duplicate) return { error: duplicate, status: 409 };
295
+ const members = [...current.members, ...additions];
296
+ const error = await savePool(key, members, current.row);
297
+ if (error) return { error, status: 400 };
298
+ await recordKeyValidation(
299
+ key,
300
+ checks.flatMap((check, index) =>
301
+ check.result === "verified"
302
+ ? [{ keyIndex: current.members.length + index, keySuffix: check.suffix, ok: true }]
303
+ : [],
304
+ ),
305
+ "global",
306
+ null,
307
+ { retireAbsent: false },
308
+ );
309
+ return { value: members.join(",") };
310
+ });
311
+ if ("error" in result) {
312
+ jsonError(res, scrubSecrets(result.error), result.status);
313
+ return true;
314
+ }
315
+ await runConfigWriteSideEffects("global", key, result.value);
316
+ addPoolKeys.respond(res, 200, { ...(await poolResponse(key)), checks });
317
+ return true;
318
+ }
319
+ if (deletePoolKey.match(req.method, pathSegments)) {
320
+ const parsed = await deletePoolKey.parse(req, res, pathSegments, queryParams);
321
+ if (!parsed) return true;
322
+ if (!(await ensureConfigAdmin(req, res, "config.delete.any"))) return true;
323
+ if (!(await ensureSecretRead(req, res))) return true;
324
+ const { key, index, suffix } = parsed.query;
325
+ const result = await getDbClient().transaction(async () => {
326
+ const current = await readPool(key);
327
+ if (current.source === "env") return { error: ENV_POOL_ERROR, status: 409 };
328
+ if (!current.members[index] || current.members[index].slice(-5) !== suffix) {
329
+ return { error: "The key list changed. Reload it and try again.", status: 409 };
330
+ }
331
+ const members = current.members.filter((_, position) => position !== index);
332
+ if (members.length) {
333
+ const error = await savePool(key, members, current.row);
334
+ if (error) return { error, status: 400 };
335
+ } else {
336
+ await deleteSwarmConfigByKey("global", null, key);
337
+ }
338
+ await retirePoolKeyStatus(
339
+ key,
340
+ suffix,
341
+ members.map((member, keyIndex) => ({ keySuffix: member.slice(-5), keyIndex })),
342
+ );
343
+ return { value: members.join(",") };
344
+ });
345
+ if ("error" in result) {
346
+ jsonError(res, scrubSecrets(result.error), result.status);
347
+ return true;
348
+ }
349
+ await runConfigWriteSideEffects("global", key, result.value);
350
+ deletePoolKey.respond(res, 200, await poolResponse(key));
351
+ return true;
352
+ }
353
+ return false;
354
+ }
@@ -6,6 +6,7 @@ import {
6
6
  normalizeAssetKey,
7
7
  parseAssetKey,
8
8
  } from "../assets/key";
9
+ import { SOURCE_GROUPS_READ_ONLY, sourceMoveError, sourceOfKey } from "../assets/source-key";
9
10
  import { AssetKeyAuthorizationError, authorizeAssetKeyWrite } from "../be/asset-key-auth";
10
11
  import { resolveHttpAuditUserId } from "../be/audit-user";
11
12
  import {
@@ -20,13 +21,19 @@ import {
20
21
  getTaskById,
21
22
  listRecentSessions,
22
23
  } from "../be/db";
24
+ import { getSessionLabels } from "../be/db/session-labels";
23
25
  import {
24
26
  listPrefixRoots,
25
27
  listSharedPrefixRoots,
26
28
  moveSessionChains,
27
29
  type SessionKeyMove,
28
30
  } from "../be/db/session-moves";
29
- import { getSessionReadState, getSessionTree, triageSessionList } from "../be/db/session-tree";
31
+ import {
32
+ getSessionGroupKey,
33
+ getSessionReadState,
34
+ getSessionTree,
35
+ triageSessionList,
36
+ } from "../be/db/session-tree";
30
37
  import { getUserGrant } from "../be/rbac-roles";
31
38
  import {
32
39
  getSessionRoot,
@@ -53,6 +60,7 @@ import {
53
60
  import { can, isRbacEnabled } from "../rbac";
54
61
  import { sessionAccessTopic } from "../realtime/auth";
55
62
  import { realtimeBus } from "../realtime/bus";
63
+ import { warmSlackChannelLabels } from "../slack/channel-labels";
56
64
  import {
57
65
  type AgentTask,
58
66
  AgentTaskSchema,
@@ -152,6 +160,7 @@ const SessionTreeGroupSchema = SessionSectionCountsSchema.extend({
152
160
  lastActivityAt: z.string(),
153
161
  });
154
162
  const SessionTreeResponseSchema = z.object({
163
+ labels: z.record(z.string(), z.string()).optional(),
155
164
  groups: z.array(SessionTreeGroupSchema),
156
165
  inbox: SessionSectionCountsSchema,
157
166
  later: SessionSectionCountsSchema,
@@ -738,7 +747,7 @@ async function checkSessionMove(
738
747
  actor: SessionActor,
739
748
  req: IncomingMessage,
740
749
  myAgentId: string | undefined,
741
- options: { ownerOnly?: boolean } = {},
750
+ options: { ownerOnly?: boolean; destination?: string | null } = {},
742
751
  ): Promise<{ reason: string } | { taskIds: string[] }> {
743
752
  const root = await getTaskById(rootTaskId);
744
753
  if (!root) return { reason: SESSION_MOVE_UNAVAILABLE };
@@ -752,6 +761,8 @@ async function checkSessionMove(
752
761
  if (!hasSessionAccess(access, "write")) return { reason: "Session write access required" };
753
762
  }
754
763
  if (root.parentTaskId) return { reason: "Task is not a session root" };
764
+ const sourceError = sourceMoveError(options.destination ?? null, root);
765
+ if (sourceError) return { reason: sourceError };
755
766
  const tasks = await getRootTaskChain(root.id);
756
767
  for (const task of tasks) {
757
768
  if (!(await canMutateTaskNamespace(task, myAgentId, req))) {
@@ -778,6 +789,8 @@ const getSession = route({
778
789
  chain: z.array(TaskWithSteeringSchema),
779
790
  access: SessionAccessSchema,
780
791
  owner: SessionOwnerSchema.nullable(),
792
+ /** The group of `root` with the list's rule: a source thread with two or more roots is its own group. */
793
+ groupKey: z.string(),
781
794
  approvals: z.array(SessionApprovalSchema).optional(),
782
795
  /** The viewer's read state, with the list's rule. Omitted for callers that act for no user. */
783
796
  readState: z.enum(["unread", "read", "later", "done"]).optional(),
@@ -1012,15 +1025,18 @@ export async function handleSessions(
1012
1025
  const parsed = await sessionTree.parse(req, res, pathSegments, queryParams);
1013
1026
  if (!parsed) return true;
1014
1027
  const actor = await resolveSessionActor(req, myAgentId);
1015
- sessionTree.respond(
1016
- res,
1017
- 200,
1018
- await getSessionTree(actor, undefined, {
1019
- mine: parsed.query.show !== "all",
1020
- hideSource: parsed.query.hideSource,
1021
- showSource: parsed.query.showSource,
1022
- }),
1028
+ const tree = await getSessionTree(actor, undefined, {
1029
+ mine: parsed.query.show !== "all",
1030
+ hideSource: parsed.query.hideSource,
1031
+ showSource: parsed.query.showSource,
1032
+ });
1033
+ // Labels cover hidden sources too: a pane or tab of a hidden source still names its group.
1034
+ const { sourceGroups, slackChannels, threadLabels, ...counts } = tree;
1035
+ const labels = { ...threadLabels, ...(await getSessionLabels(sourceGroups)) };
1036
+ warmSlackChannelLabels(
1037
+ slackChannels.map((id) => `shared/slack/${id}/`).filter((key) => labels[key] === undefined),
1023
1038
  );
1039
+ sessionTree.respond(res, 200, { ...counts, labels });
1024
1040
  return true;
1025
1041
  }
1026
1042
 
@@ -1054,7 +1070,9 @@ export async function handleSessions(
1054
1070
  const skipped: { rootTaskId: string; reason: string }[] = [];
1055
1071
  const moves: SessionKeyMove[] = [];
1056
1072
  for (const rootId of new Set(parsed.body.rootTaskIds)) {
1057
- const checked = await checkSessionMove(rootId, actor, req, myAgentId);
1073
+ const checked = await checkSessionMove(rootId, actor, req, myAgentId, {
1074
+ destination: key,
1075
+ });
1058
1076
  if ("reason" in checked) {
1059
1077
  skipped.push({ rootTaskId: rootId, reason: checked.reason });
1060
1078
  } else {
@@ -1076,6 +1094,10 @@ export async function handleSessions(
1076
1094
  const parsed = await moveSessionPrefix.parse(req, res, pathSegments, queryParams);
1077
1095
  if (!parsed) return true;
1078
1096
  const from = normalizeAssetKey(parsed.body.from);
1097
+ if (sourceOfKey(from) || (parsed.body.to && sourceOfKey(normalizeAssetKey(parsed.body.to)))) {
1098
+ jsonError(res, SOURCE_GROUPS_READ_ONLY, 400);
1099
+ return true;
1100
+ }
1079
1101
  if (parseAssetKey(from).relativeSegments.length === 0) {
1080
1102
  jsonError(res, "Cannot move a namespace root", 400);
1081
1103
  return true;
@@ -1126,6 +1148,7 @@ export async function handleSessions(
1126
1148
  }
1127
1149
  const checked = await checkSessionMove(root.id, actor, req, myAgentId, {
1128
1150
  ownerOnly: true,
1151
+ destination: nextKey,
1129
1152
  });
1130
1153
  if ("reason" in checked) {
1131
1154
  skipped.push({ rootTaskId: root.id, reason: checked.reason });
@@ -1175,7 +1198,7 @@ export async function handleSessions(
1175
1198
  const chain = await getRootTaskChain(parsed.params.rootTaskId);
1176
1199
  // Load steering, citations and pending approvals for the already loaded chain.
1177
1200
  const tasks = [root, ...chain];
1178
- const [steering, citations, approvals, read] = await Promise.all([
1201
+ const [steering, citations, approvals, read, groupKey] = await Promise.all([
1179
1202
  getTaskSteeringFieldsForTasks(tasks),
1180
1203
  getTaskCitationsForTasks(tasks.map((task) => task.id)),
1181
1204
  getPendingApprovalsForTasks(tasks.map((task) => task.id)),
@@ -1184,6 +1207,7 @@ export async function handleSessions(
1184
1207
  actor.kind === "user" && actor.userId
1185
1208
  ? getSessionReadState(sessionRoot.id, actor.userId)
1186
1209
  : Promise.resolve(null),
1210
+ getSessionGroupKey(root),
1187
1211
  ]);
1188
1212
  const decorate = (task: AgentTask) => ({
1189
1213
  ...task,
@@ -1195,6 +1219,7 @@ export async function handleSessions(
1195
1219
  chain: chain.map(decorate),
1196
1220
  access,
1197
1221
  owner,
1222
+ groupKey,
1198
1223
  ...(approvals.length ? { approvals } : {}),
1199
1224
  ...(read ?? {}),
1200
1225
  });
package/src/http/stats.ts CHANGED
@@ -86,6 +86,7 @@ const SwarmMetricsSchema = z.object({
86
86
  agentLog: RetentionTableStatsSchema.optional(),
87
87
  events: RetentionTableStatsSchema.optional(),
88
88
  contextVersions: RetentionTableStatsSchema.optional(),
89
+ scriptRuns: RetentionTableStatsSchema.optional(),
89
90
  }),
90
91
  });
91
92
 
@@ -29,6 +29,7 @@ import {
29
29
  getDbClient,
30
30
  getInstanceActivity,
31
31
  getLiveAgentCounts,
32
+ getReadyAgentCounts,
32
33
  hasFirstCompletedTask,
33
34
  listAgentsWithCredStatusByProvider,
34
35
  NOT_EXTENSION_AGENT_SQL,
@@ -121,6 +122,12 @@ export type StatusIdentity = z.infer<typeof StatusIdentitySchema>;
121
122
  export const StatusActivitySchema = z.object({
122
123
  agents_online: z.number().int().nonnegative(),
123
124
  leads_online: z.number().int().nonnegative(),
125
+ /**
126
+ * Leads that can take work now (status `idle` or `busy`). Unlike
127
+ * `leads_online`, this does not depend on a recent heartbeat: an idle lead
128
+ * stops updating its activity time.
129
+ */
130
+ leads_ready: z.number().int().nonnegative(),
124
131
  recent_tasks_count: z.number().int().nonnegative(),
125
132
  });
126
133
  export type StatusActivity = z.infer<typeof StatusActivitySchema>;
@@ -194,12 +201,13 @@ export type AutomationStatus = z.infer<typeof AutomationStatusSchema>;
194
201
  /**
195
202
  * Phase 2: Aggregate health derived from setup milestones.
196
203
  *
197
- * - `broken` — harness or workers blocking (creds missing, no live workers ever).
198
- * - `degraded` — at least one optional integration `unverified`/`configured`
199
- * while another is configured, OR harness creds present but
200
- * the latest live test failed or never ran (`configured`).
201
- * - `ok` — harness + workers `verified`; no integration left in
202
- * `configured` state.
204
+ * - `broken`: harness or workers blocking (creds missing, no live workers ever).
205
+ * - `degraded`: no lead can take work (none is `idle` or `busy`), OR at
206
+ * least one optional integration `unverified`/`configured` while another
207
+ * is configured, OR harness creds present but the latest live test failed
208
+ * or never ran (`configured`).
209
+ * - `ok`: harness + workers `verified`, a lead can take work, and no
210
+ * integration left in `configured` state.
203
211
  */
204
212
  export const StatusHealthSchema = z.enum(["ok", "degraded", "broken"]);
205
213
  export type StatusHealth = z.infer<typeof StatusHealthSchema>;
@@ -692,19 +700,23 @@ export async function buildSetup(
692
700
  * Decision matrix:
693
701
  * - Harness `unverified` (no creds at all) → `broken` — the swarm cannot run a task.
694
702
  * - Workers `unverified` (no agents ever) → `broken` — same reason.
703
+ * - No lead can take work (`readyLeads` is 0) → `degraded`. Tasks wait until a lead is online.
695
704
  * - Harness `configured` (creds present, never live-tested) → `degraded`.
696
705
  * - Any of {slack, github, linear, jira} `configured` (i.e. half-set-up) → `degraded`.
697
706
  * - Otherwise → `ok`.
698
707
  *
699
708
  * Notes:
709
+ * - `readyLeads` counts leads with status `idle` or `busy`
710
+ * (`getReadyAgentCounts`). It does not use the heartbeat time: an agent
711
+ * updates `lastActivityAt` only while a session runs, so an idle, healthy
712
+ * lead looks stale.
700
713
  * - Worker `configured` (fleet exists but missed recent heartbeats) does NOT
701
- * degrade the rollup. Heartbeat drift is a runtime concern surfaced on the
702
- * /agents page and the dashboard canvas — not a setup health signal.
714
+ * degrade the rollup on its own, for the same reason.
703
715
  * - Integrations in `unverified` are NOT degrading on their own — most
704
716
  * deployments don't connect every integration. They only nudge the rollup if
705
717
  * paired with another integration in `configured` (the brainstorm contract).
706
718
  */
707
- export function computeHealth(setup: SetupMilestone[]): StatusHealth {
719
+ export function computeHealth(setup: SetupMilestone[], readyLeads: number): StatusHealth {
708
720
  const byId = new Map(setup.map((m) => [m.id, m] as const));
709
721
  const harness = byId.get("harness");
710
722
  const workers = byId.get("workers");
@@ -714,6 +726,7 @@ export function computeHealth(setup: SetupMilestone[]): StatusHealth {
714
726
  if (!workers || workers.state === "unverified") return "broken";
715
727
 
716
728
  // `degraded` rules.
729
+ if (readyLeads === 0) return "degraded";
717
730
  if (harness.state === "configured") return "degraded";
718
731
 
719
732
  for (const id of ["slack", "github", "linear", "jira"] as const) {
@@ -740,12 +753,13 @@ export async function buildStatusPayload(): Promise<StatusResponse> {
740
753
  .map((automation) => toStatus(preflightAutomation(automation, automationSetup)))
741
754
  .sort((a, b) => a.name.localeCompare(b.name) || a.kind.localeCompare(b.kind));
742
755
  const activity = await getInstanceActivity();
756
+ const readyLeads = (await getReadyAgentCounts()).leads;
743
757
  const instance = await getInstanceFeedbackSignals();
744
758
  return {
745
759
  runtime_pools: { enabled: isRuntimePoolsEnabled(), supervisor: runtimeSupervisorStatus() },
746
760
  identity: buildIdentity(),
747
761
  setup,
748
- activity,
762
+ activity: { ...activity, leads_ready: readyLeads },
749
763
  agent_fs: {
750
764
  configured: !!process.env.AGENT_FS_API_URL,
751
765
  base_url: process.env.AGENT_FS_API_URL ?? null,
@@ -753,7 +767,7 @@ export async function buildStatusPayload(): Promise<StatusResponse> {
753
767
  comb: getCombStatus(await combServiceUserId),
754
768
  },
755
769
  automations,
756
- health: computeHealth(setup),
770
+ health: computeHealth(setup, readyLeads),
757
771
  telemetry: { enabled: isTelemetryEnabled() },
758
772
  instance,
759
773
  };
@@ -797,7 +811,7 @@ const getStatus = route({
797
811
  pattern: ["status"],
798
812
  summary: "Identity + setup readiness + live activity for the swarm dashboard",
799
813
  description:
800
- "Single source of truth consumed by the UI home page. Identity comes from SWARM_* envs; setup milestones each emit `unverified | configured | verified`; automations report `running | needs_setup` from the same runtime preflight used at dispatch; activity counts agents alive in the last 5 min and tasks created in the last 24h; agent_fs reports whether AGENT_FS_API_URL is set, plus the Comb settings (COMB_ENABLED, the browser-facing agent-fs URL, the shared org and drive ids, and the agent-fs user id of the swarm service account); telemetry reports the effective ANONYMIZED_TELEMETRY opt-out state.",
814
+ "Single source of truth consumed by the UI home page. Identity comes from SWARM_* envs; setup milestones each emit `unverified | configured | verified`; automations report `running | needs_setup` from the same runtime preflight used at dispatch; activity counts agents alive in the last 5 min, leads that can take work (status idle or busy), and tasks created in the last 24h; agent_fs reports whether AGENT_FS_API_URL is set, plus the Comb settings (COMB_ENABLED, the browser-facing agent-fs URL, the shared org and drive ids, and the agent-fs user id of the swarm service account); telemetry reports the effective ANONYMIZED_TELEMETRY opt-out state.",
801
815
  tags: ["Status"],
802
816
  responses: {
803
817
  200: { description: "Status payload", schema: StatusResponseSchema },