@desplega.ai/agent-swarm 2.3.0 → 2.4.1

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 (332) hide show
  1. package/README.md +1 -0
  2. package/dist/{acp-adapter-v85a2bqy.js → acp-adapter-zvzr538d.js} +9 -9
  3. package/dist/{actions-zgcxfspj.js → actions-jhtqdw69.js} +21 -19
  4. package/dist/{amp-adapter-jw9xfz42.js → amp-adapter-bd84rhs2.js} +4 -4
  5. package/dist/{app-4e1s4675.js → app-kreq1gwg.js} +3 -3
  6. package/dist/{assistant-v238n9dk.js → assistant-3k22sbsq.js} +23 -21
  7. package/dist/{boot-reembed-p666cw5n.js → boot-reembed-e53x6b9v.js} +1 -1
  8. package/dist/{boot-reembed-w6pm64j4.js → boot-reembed-f6z923ny.js} +1 -1
  9. package/dist/{boot-scrub-sweep-jcyjagn3.js → boot-scrub-sweep-0538vsb7.js} +4 -4
  10. package/dist/{boot-seeds-gbedmamp.js → boot-seeds-6wxqkfk2.js} +4 -4
  11. package/dist/{claude-adapter-q7wspzt8.js → claude-adapter-gfts5044.js} +9 -9
  12. package/dist/{claude-managed-adapter-n7ahjyx6.js → claude-managed-adapter-h2y889ge.js} +3 -3
  13. package/dist/{claude-managed-setup-wza784fb.js → claude-managed-setup-h00hsgtz.js} +2 -2
  14. package/dist/{claude-sdk-session-yf400ptr.js → claude-sdk-session-56besmfz.js} +13 -13
  15. package/dist/{cli-65q70v2v.js → cli-0bh477zn.js} +33 -6
  16. package/dist/{cli-4ync7m60.js → cli-0ebey9hg.js} +1 -1
  17. package/dist/{cli-665j3feb.js → cli-0xk505e8.js} +1 -1
  18. package/dist/{cli-7gh7020n.js → cli-1dmcq2eh.js} +1 -1
  19. package/dist/{cli-gzvb3d4a.js → cli-1jx0srjt.js} +3 -3
  20. package/dist/{cli-zj4d6afs.js → cli-1pa3rz69.js} +21 -6
  21. package/dist/{cli-7ayprxp7.js → cli-2dfbv8qr.js} +8 -0
  22. package/dist/{cli-30m5q8mc.js → cli-3kh4kpqy.js} +6 -6
  23. package/dist/{cli-qrwp40st.js → cli-3p8fqcnh.js} +2 -2
  24. package/dist/{cli-y3dvz6ge.js → cli-3r448dqn.js} +3 -3
  25. package/dist/{cli-a17tzmn0.js → cli-3tnpj2bh.js} +7 -7
  26. package/dist/{cli-ea7chd26.js → cli-471qk6s2.js} +93 -46
  27. package/dist/{cli-a49vpbf3.js → cli-5kqnemen.js} +1 -1
  28. package/dist/{cli-0rh20bp3.js → cli-6ncjvx55.js} +3 -3
  29. package/dist/{cli-fb6t92v8.js → cli-6x9ma5w5.js} +1 -1
  30. package/dist/{cli-k28p6xh7.js → cli-7225gwj7.js} +2 -2
  31. package/dist/{cli-8cvvytad.js → cli-8nyhf7za.js} +3 -3
  32. package/dist/{cli-e09hj50q.js → cli-8wkb09q5.js} +11 -11
  33. package/dist/{cli-j0v75y13.js → cli-a0kcty51.js} +4 -4
  34. package/dist/{cli-zes4tsbb.js → cli-a4gvwttf.js} +4 -4
  35. package/dist/{cli-jhp7w1fy.js → cli-a5ebc95n.js} +2 -2
  36. package/dist/{cli-5d1m1qca.js → cli-ash6xxk7.js} +2 -2
  37. package/dist/{cli-fp4xbw07.js → cli-axh29tyr.js} +14 -12
  38. package/dist/{cli-p22zdvnt.js → cli-bn0rewsy.js} +3 -3
  39. package/dist/{cli-hz64bcnj.js → cli-bn5kevnf.js} +11 -373
  40. package/dist/{cli-8jtrekpy.js → cli-d2mb5s8a.js} +7 -7
  41. package/dist/{cli-c5sbmw1v.js → cli-d82btycs.js} +94 -15
  42. package/dist/{cli-vjfpshn3.js → cli-ejs3m1sx.js} +1 -1
  43. package/dist/{cli-yv0vgscc.js → cli-fpmsqq59.js} +18 -2
  44. package/dist/cli-fz9kewbh.js +382 -0
  45. package/dist/{cli-4s1kqrqg.js → cli-g0hgtz4p.js} +6 -6
  46. package/dist/{cli-hn3pftj2.js → cli-gf0kfg81.js} +7 -7
  47. package/dist/{cli-d43xqzbe.js → cli-hqhzg1qq.js} +2 -2
  48. package/dist/{cli-azs9bhc5.js → cli-j4rqdf2n.js} +2 -2
  49. package/dist/{cli-wtg6edvd.js → cli-jh0qcrga.js} +1 -1
  50. package/dist/{cli-kqypty34.js → cli-k9z4zd4p.js} +6 -5
  51. package/dist/{cli-jhmjwqcg.js → cli-kmqgmcnb.js} +47 -14
  52. package/dist/{cli-8qsx817b.js → cli-kr2pmfqx.js} +583 -769
  53. package/dist/{cli-4g629sgr.js → cli-m2qgh5sv.js} +20 -1
  54. package/dist/{cli-9e9kjqsj.js → cli-mfe7f7b5.js} +2 -2
  55. package/dist/{cli-x3en6ct3.js → cli-nwyw3zhg.js} +1 -1
  56. package/dist/{cli-6vcyw6v7.js → cli-nx462e4v.js} +5 -3
  57. package/dist/{cli-tr24eeny.js → cli-p0rakbrk.js} +9 -9
  58. package/dist/{cli-4hmaheb6.js → cli-p6argxh9.js} +9 -9
  59. package/dist/{cli-3avfh5az.js → cli-pemywhfq.js} +2 -2
  60. package/dist/{cli-etvn5ej2.js → cli-pnp2j5zg.js} +2 -2
  61. package/dist/{cli-wk8ccrz6.js → cli-pw7v69vp.js} +2 -2
  62. package/dist/{cli-ka01m5rb.js → cli-rdq7tzwm.js} +17 -14
  63. package/dist/{cli-re4wkbzx.js → cli-rqqsfz30.js} +677 -104
  64. package/dist/{cli-xgn4rwyv.js → cli-s48yvzb8.js} +4 -4
  65. package/dist/{cli-zv4vy2b0.js → cli-s95dknxh.js} +1 -1
  66. package/dist/{cli-g6p7c2my.js → cli-sjyetj21.js} +2 -2
  67. package/dist/{cli-rdr166ht.js → cli-smvcw9z5.js} +1 -1
  68. package/dist/{cli-fhvqt2fp.js → cli-sv5gb0yb.js} +1 -1
  69. package/dist/{cli-yv3n83x8.js → cli-syhq378v.js} +2 -2
  70. package/dist/{cli-q1812jbw.js → cli-tbhwrxpt.js} +9 -9
  71. package/dist/{cli-w7z94e6c.js → cli-tx00c0h6.js} +4 -2
  72. package/dist/{cli-dxwh4j32.js → cli-vy0fay6k.js} +46 -37
  73. package/dist/{cli-ptkpaqfy.js → cli-wt645v7q.js} +1 -1
  74. package/dist/{cli-ppapy34w.js → cli-wxbn3yfx.js} +2 -2
  75. package/dist/{cli-cby329ny.js → cli-x5mq1fyq.js} +3 -3
  76. package/dist/{cli-rc3r0bnh.js → cli-xbzvet96.js} +6 -6
  77. package/dist/{cli-4mkvwgzr.js → cli-xf234ws8.js} +1 -1
  78. package/dist/{cli-6p7ddg72.js → cli-xftrvxag.js} +5 -5
  79. package/dist/cli-z071fb95.js +848 -0
  80. package/dist/{cli-1v8w782f.js → cli-z1ew8vbq.js} +14 -7
  81. package/dist/{cli-9td5ss65.js → cli-zfg9enh9.js} +3 -3
  82. package/dist/{cli-yxqag76b.js → cli-zw88zjba.js} +1 -1
  83. package/dist/cli.js +19 -19
  84. package/dist/{codex-adapter-nhjj2veb.js → codex-adapter-p6rmm7vv.js} +6 -6
  85. package/dist/{codex-hook-x6tm6y5m.js → codex-hook-x7nejyrr.js} +8 -5
  86. package/dist/{codex-login-35p4sk85.js → codex-login-m819c41r.js} +1 -1
  87. package/dist/{codex-session-runner-6hggrjbx.js → codex-session-runner-8vd5t9t0.js} +7 -7
  88. package/dist/{commands-mrw1ph2w.js → commands-7ems2p13.js} +2 -2
  89. package/dist/{controller-pt1zam5w.js → controller-m263fg43.js} +4 -4
  90. package/dist/{controllers-connect-ymw5541d.js → controllers-connect-bg33ae53.js} +28 -24
  91. package/dist/{cursor-adapter-cf4zh5x0.js → cursor-adapter-cz6nm6mj.js} +3 -3
  92. package/dist/{db-hy80219q.js → db-w505dwa8.js} +2 -2
  93. package/dist/{db-hhpy6m4x.js → db-ys2rbdft.js} +3 -2
  94. package/dist/{devin-adapter-7egzmt0k.js → devin-adapter-zgqadvnm.js} +1 -1
  95. package/dist/{dispatcher-6pdjtkr1.js → dispatcher-0s0mckqr.js} +20 -18
  96. package/dist/{docker-njavd3fb.js → docker-fkgtr75t.js} +2 -2
  97. package/dist/{dsh-adapter-fbx06re2.js → dsh-adapter-3n3net33.js} +3 -3
  98. package/dist/{e2b-5r6a5jeb.js → e2b-7am6tmq6.js} +2 -2
  99. package/dist/{e2b-pw1nvtmh.js → e2b-thdcxtjd.js} +2 -2
  100. package/dist/{easter-egg-3d-6hg2r942.js → easter-egg-3d-5cgynaja.js} +1 -1
  101. package/dist/{event-bus-101fenkh.js → event-bus-9vpk8gmq.js} +1 -1
  102. package/dist/{execute-bcqc69a5.js → execute-t4e6m3rw.js} +1 -1
  103. package/dist/{external-channel-j6vb6ya1.js → external-channel-5b23fv8z.js} +2 -2
  104. package/dist/{google-generative-ai-phpmwxvg.js → google-generative-ai-bmq8gkf3.js} +1 -1
  105. package/dist/{google-vertex-es9ybgdy.js → google-vertex-b1rvp52s.js} +1 -1
  106. package/dist/{grok-adapter-1193sprp.js → grok-adapter-q9fghe5q.js} +10 -10
  107. package/dist/handlers-mqrym347.js +39 -0
  108. package/dist/{heartbeat-h1w1ktce.js → heartbeat-tww6yx1w.js} +23 -21
  109. package/dist/{hook-ejja4bpe.js → hook-dx0pkvar.js} +16 -9
  110. package/dist/{hook-skrbja18.js → hook-pvmh9b6m.js} +6 -6
  111. package/dist/{http-mzh41mfj.js → http-k2ejbt05.js} +975 -292
  112. package/dist/{index-6rmnn89d.js → index-2z710v4x.js} +10 -10
  113. package/dist/{index-xmmkyx82.js → index-dgz61nff.js} +20 -18
  114. package/dist/{index-wgsw9xcz.js → index-g6tbb6ve.js} +1 -1
  115. package/dist/index-h2ek08d6.js +40 -0
  116. package/dist/index-pqfwh9nn.js +41 -0
  117. package/dist/{index-epr2gnbw.js → index-x5aktmp8.js} +1 -1
  118. package/dist/{index-x0q2a4f9.js → index-ysd45kgc.js} +1 -1
  119. package/dist/{index-g909y089.js → index-zgvqzjsw.js} +8 -8
  120. package/dist/{keepalive-4djvbm2j.js → keepalive-nhvpw10z.js} +4 -4
  121. package/dist/lead-jb6sf69z.js +78 -0
  122. package/dist/{maintenance-f3xhnjsm.js → maintenance-agakc519.js} +1 -1
  123. package/dist/{oauth-refresh-sweep-fpd6x6rw.js → oauth-refresh-sweep-4dcbhtn0.js} +3 -3
  124. package/dist/{onboard-196tjgyn.js → onboard-4n2kwa4v.js} +3 -3
  125. package/dist/{opencode-adapter-eeesgfqk.js → opencode-adapter-3s7ax02e.js} +7 -7
  126. package/dist/{otel-impl-g8fh4c21.js → otel-impl-tfa12080.js} +2 -2
  127. package/dist/{pi-mono-adapter-zasp3ze3.js → pi-mono-adapter-ytp7gz8b.js} +24 -13
  128. package/dist/{pricing-refresh-sdcf5yaz.js → pricing-refresh-3cqx3f3x.js} +3 -3
  129. package/dist/{rbac-roles-bsp5657v.js → rbac-roles-9fae5rnv.js} +5 -3
  130. package/dist/{rbac-roles-2x4g1wb5.js → rbac-roles-y6zbfzve.js} +2 -2
  131. package/dist/{render-v2-0pyb39bb.js → render-v2-emawjvc4.js} +3 -3
  132. package/dist/{resume-a86tkj6r.js → resume-zhgts7h7.js} +10 -8
  133. package/dist/{runtime-ar8310ba.js → runtime-xe3aqy4x.js} +1 -1
  134. package/dist/session-access-cm823e06.js +25 -0
  135. package/dist/{setup-ypbzcapd.js → setup-tkp99w1e.js} +1 -1
  136. package/dist/{virtual-modules-wzk6jf3a.js → virtual-modules-4r3zkc20.js} +2 -2
  137. package/dist/{work-objects-yjp8de12.js → work-objects-f722h263.js} +23 -21
  138. package/dist/worker-p7f336rq.js +78 -0
  139. package/dist/{x-8ewt8gxn.js → x-y45mthwn.js} +2 -2
  140. package/openapi.json +1086 -305
  141. package/package.json +2 -2
  142. package/src/apps/action-caller.ts +6 -0
  143. package/src/apps/action-runner.ts +146 -0
  144. package/src/apps/row-store.ts +4 -2
  145. package/src/apps/script-types.ts +17 -4
  146. package/src/be/db/active-sessions.ts +5 -0
  147. package/src/be/db/session-visibility.ts +29 -0
  148. package/src/be/db/tasks/read.ts +61 -3
  149. package/src/be/db/tasks/write.ts +15 -6
  150. package/src/be/db.ts +106 -37
  151. package/src/be/events.ts +11 -0
  152. package/src/be/fleet-benchmark-stats.ts +10 -5
  153. package/src/be/migrations/230_session_shares.sql +23 -0
  154. package/src/be/models-catalog.ts +4 -0
  155. package/src/be/onboarding.ts +6 -0
  156. package/src/be/rbac-roles.ts +2 -0
  157. package/src/be/scripts/typecheck.ts +13 -0
  158. package/src/be/session-shares.ts +244 -0
  159. package/src/be/swarm-config-guard.ts +33 -3
  160. package/src/be/telemetry-enable.ts +1 -1
  161. package/src/commands/provider-credentials.ts +12 -2
  162. package/src/commands/runner.ts +14 -0
  163. package/src/heartbeat/heartbeat.ts +2 -2
  164. package/src/hooks/codex-hook.ts +6 -2
  165. package/src/hooks/hook.ts +7 -0
  166. package/src/hooks/pr-body-guard.ts +38 -11
  167. package/src/hooks/pr-room-guard.ts +88 -0
  168. package/src/http/active-sessions.ts +13 -2
  169. package/src/http/agents.ts +11 -3
  170. package/src/http/all-routes.ts +1 -0
  171. package/src/http/api-keys.ts +3 -1
  172. package/src/http/approval-requests.ts +2 -1
  173. package/src/http/apps.ts +118 -190
  174. package/src/http/config.ts +3 -0
  175. package/src/http/context.ts +2 -0
  176. package/src/http/core.ts +39 -2
  177. package/src/http/events.ts +6 -2
  178. package/src/http/favorite-owner.ts +1 -1
  179. package/src/http/fleet-benchmark.ts +79 -30
  180. package/src/http/fs.ts +38 -3
  181. package/src/http/global-config.ts +7 -0
  182. package/src/http/index.ts +3 -1
  183. package/src/http/memory.ts +3 -1
  184. package/src/http/ranking.ts +351 -0
  185. package/src/http/realtime.ts +26 -21
  186. package/src/http/route-def.ts +77 -11
  187. package/src/http/script-runs.ts +2 -1
  188. package/src/http/scripts.ts +8 -0
  189. package/src/http/session-access.ts +179 -0
  190. package/src/http/session-data.ts +10 -2
  191. package/src/http/sessions.ts +212 -12
  192. package/src/http/stats.ts +7 -3
  193. package/src/http/tasks.ts +85 -8
  194. package/src/http/telemetry-ui.ts +1 -1
  195. package/src/http/used-by.ts +64 -20
  196. package/src/http/users.ts +2 -2
  197. package/src/prompts/session-templates.ts +3 -1
  198. package/src/providers/amp-adapter.ts +1 -1
  199. package/src/providers/pi-mono-adapter.ts +15 -0
  200. package/src/providers/reasoning-effort.ts +2 -1
  201. package/src/providers/types.ts +2 -0
  202. package/src/ranking/telemetry-ranking.ts +119 -0
  203. package/src/ranking/usage.ts +57 -0
  204. package/src/rbac/admission.ts +22 -7
  205. package/src/rbac/legacy-policy.ts +2 -0
  206. package/src/rbac/permissions.ts +9 -0
  207. package/src/realtime/auth.ts +69 -0
  208. package/src/realtime/tickets.ts +4 -1
  209. package/src/realtime/transport.ts +77 -9
  210. package/src/scripts-runtime/sdk-allowlist.ts +7 -0
  211. package/src/scripts-runtime/types/stdlib.d.ts +31 -0
  212. package/src/scripts-runtime/types/swarm-sdk.d.ts +31 -0
  213. package/src/server.ts +4 -0
  214. package/src/slack/blocks.ts +18 -0
  215. package/src/slack/dm.ts +29 -0
  216. package/src/telemetry-context.ts +19 -1
  217. package/src/telemetry-contract/EVENTS.md +45 -8
  218. package/src/telemetry-contract/SOURCE.json +38 -4
  219. package/src/telemetry-contract/catalog.json +294 -5
  220. package/src/telemetry-contract/ranking/fixtures/expected.json +30 -0
  221. package/src/telemetry-contract/ranking/fixtures/invalid/bad-category.json +11 -0
  222. package/src/telemetry-contract/ranking/fixtures/invalid/bad-claim.json +11 -0
  223. package/src/telemetry-contract/ranking/fixtures/invalid/blank-description.json +11 -0
  224. package/src/telemetry-contract/ranking/fixtures/invalid/blank-name.json +11 -0
  225. package/src/telemetry-contract/ranking/fixtures/invalid/description-too-long.json +11 -0
  226. package/src/telemetry-contract/ranking/fixtures/invalid/domain-uppercase.json +11 -0
  227. package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-scheme.json +11 -0
  228. package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-www.json +11 -0
  229. package/src/telemetry-contract/ranking/fixtures/invalid/http-source-url.json +11 -0
  230. package/src/telemetry-contract/ranking/fixtures/invalid/missing-description.json +10 -0
  231. package/src/telemetry-contract/ranking/fixtures/invalid/missing-version.json +10 -0
  232. package/src/telemetry-contract/ranking/fixtures/invalid/name-not-string.json +11 -0
  233. package/src/telemetry-contract/ranking/fixtures/invalid/name-too-long.json +11 -0
  234. package/src/telemetry-contract/ranking/fixtures/invalid/not-object.json +1 -0
  235. package/src/telemetry-contract/ranking/fixtures/invalid/null.json +1 -0
  236. package/src/telemetry-contract/ranking/fixtures/invalid/other-with-subcategory.json +11 -0
  237. package/src/telemetry-contract/ranking/fixtures/invalid/oversize.json +1 -0
  238. package/src/telemetry-contract/ranking/fixtures/invalid/short-claim.json +11 -0
  239. package/src/telemetry-contract/ranking/fixtures/invalid/subcategory-other-category.json +11 -0
  240. package/src/telemetry-contract/ranking/fixtures/invalid/trailing-data.json +1 -0
  241. package/src/telemetry-contract/ranking/fixtures/invalid/unknown-field.json +12 -0
  242. package/src/telemetry-contract/ranking/fixtures/invalid/unsupported-version.json +11 -0
  243. package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-not-date.json +11 -0
  244. package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-offset.json +11 -0
  245. package/src/telemetry-contract/ranking/fixtures/invalid/wrong-case-field.json +11 -0
  246. package/src/telemetry-contract/ranking/fixtures/valid/category-without-subcategory.json +11 -0
  247. package/src/telemetry-contract/ranking/fixtures/valid/full.json +11 -0
  248. package/src/telemetry-contract/ranking/fixtures/valid/minimal.json +11 -0
  249. package/src/telemetry-contract/ranking/fixtures/valid/no-claim.json +10 -0
  250. package/src/telemetry-contract/ranking/fixtures/valid/padded-text.json +11 -0
  251. package/src/telemetry-contract/ranking/fixtures/valid/unicode-name.json +11 -0
  252. package/src/telemetry-contract/ranking/listing.v1.schema.json +61 -0
  253. package/src/telemetry-contract/ranking/taxonomy.json +8 -0
  254. package/src/telemetry-contract/ranking-taxonomy.gen.ts +11 -0
  255. package/src/telemetry-contract/types.gen.ts +963 -58
  256. package/src/telemetry-identity.ts +47 -9
  257. package/src/telemetry-snapshot.ts +9 -1
  258. package/src/telemetry-ui-events.ts +9 -2
  259. package/src/telemetry.ts +40 -5
  260. package/src/tests/app-action-run-tool.test.ts +178 -0
  261. package/src/tests/app-row-tools.test.ts +360 -0
  262. package/src/tests/apps-rbac.test.ts +189 -2
  263. package/src/tests/apps-script-types.test.ts +16 -3
  264. package/src/tests/credential-check.test.ts +38 -0
  265. package/src/tests/db-extraction-contract.test.ts +1 -0
  266. package/src/tests/db-task-read-contract.test.ts +1 -0
  267. package/src/tests/docs-index-footprint.test.js +2 -2
  268. package/src/tests/fleet-benchmark.test.ts +122 -2
  269. package/src/tests/http-users.test.ts +62 -1
  270. package/src/tests/model-catalog-table.test.ts +63 -0
  271. package/src/tests/model-profiles-api.test.ts +19 -7
  272. package/src/tests/models-catalog.test.ts +50 -0
  273. package/src/tests/onboarding.test.ts +21 -0
  274. package/src/tests/org-identity.test.ts +44 -0
  275. package/src/tests/org-website.test.ts +62 -0
  276. package/src/tests/pi-mono-adapter.test.ts +23 -0
  277. package/src/tests/pr-body-leaks.test.ts +4 -0
  278. package/src/tests/pr-room-guard.test.ts +242 -0
  279. package/src/tests/ranking-listing.test.ts +97 -0
  280. package/src/tests/ranking-route.test.ts +321 -0
  281. package/src/tests/ranking-usage.test.ts +50 -0
  282. package/src/tests/rbac-admission-e2e.test.ts +8 -0
  283. package/src/tests/rbac-admission.test.ts +38 -0
  284. package/src/tests/rbac-charact-http.test.ts +4 -3
  285. package/src/tests/rbac-engine.test.ts +2 -0
  286. package/src/tests/rbac-roles.test.ts +2 -0
  287. package/src/tests/realtime-room-surfaces.test.ts +3 -1
  288. package/src/tests/realtime-session-join-race.test.ts +142 -0
  289. package/src/tests/realtime-tickets.test.ts +24 -17
  290. package/src/tests/realtime-transport.test.ts +91 -0
  291. package/src/tests/reasoning-effort.test.ts +39 -3
  292. package/src/tests/runner-harness-telemetry-env.test.ts +56 -1
  293. package/src/tests/session-route-posture.test.ts +639 -0
  294. package/src/tests/session-sharing-routes.test.ts +282 -0
  295. package/src/tests/session-sharing.test.ts +526 -0
  296. package/src/tests/sessions-detail-batch.test.ts +7 -1
  297. package/src/tests/slack-tool-blocks.test.ts +101 -0
  298. package/src/tests/slack-update.test.ts +55 -0
  299. package/src/tests/slack-user-dm.test.ts +177 -0
  300. package/src/tests/swarm-tool-result-gate.test.ts +2 -4
  301. package/src/tests/telemetry-context.test.ts +128 -2
  302. package/src/tests/telemetry-ui-route.test.ts +123 -0
  303. package/src/tests/tool-annotations.test.ts +3 -2
  304. package/src/tests/used-by-route.test.ts +153 -9
  305. package/src/tests/workflow-agent-task-retry.test.ts +130 -4
  306. package/src/tests/workflow-wait-recovery.test.ts +116 -4
  307. package/src/tools/app-action-run.ts +63 -0
  308. package/src/tools/app-rows.ts +234 -0
  309. package/src/tools/model-catalog/index.ts +1 -1
  310. package/src/tools/slack-post.ts +18 -12
  311. package/src/tools/slack-read.ts +13 -1
  312. package/src/tools/slack-reply.ts +7 -10
  313. package/src/tools/slack-start-thread.ts +18 -11
  314. package/src/tools/slack-update.ts +2 -9
  315. package/src/tools/tool-config.ts +8 -0
  316. package/src/tools/utils.ts +1 -1
  317. package/src/tools/workflows/retry-workflow-run.ts +1 -1
  318. package/src/types.ts +52 -0
  319. package/src/utils/company-profile.ts +24 -0
  320. package/src/utils/org-identity.ts +33 -0
  321. package/src/utils/org-website.ts +58 -21
  322. package/src/workflows/recovery.ts +10 -22
  323. package/src/workflows/resume.ts +16 -0
  324. package/src/workflows/task-step-routing.ts +47 -7
  325. package/templates/skills/apps/SKILL.md +58 -2
  326. package/templates/skills/apps/content.md +58 -2
  327. package/dist/handlers-tb3xgjnm.js +0 -37
  328. package/dist/index-nyfcdpky.js +0 -39
  329. package/dist/index-vjn3wbzf.js +0 -38
  330. package/dist/lead-61kqp604.js +0 -76
  331. package/dist/worker-4ptnn1ge.js +0 -76
  332. package/dist/{cli-n5mrsg0f.js → cli-60cgh1rv.js} +3 -3
@@ -5,6 +5,7 @@ import { closeDb, createAgent, getSwarmConfigs, initDb, upsertSwarmConfig } from
5
5
  import { fillOrgLogoFromWebsite } from "../be/org-profile";
6
6
  import { handleUsedBy } from "../http/used-by";
7
7
  import { getPathSegments, parseQueryParams } from "../http/utils";
8
+ import { _resetTelemetryStateForTests, initTelemetry } from "../telemetry";
8
9
  import type { HttpRequestAuth } from "../utils/request-auth-context";
9
10
  import { setRequestAuth } from "../utils/request-auth-context";
10
11
 
@@ -94,26 +95,155 @@ describe("POST /api/used-by", () => {
94
95
  });
95
96
 
96
97
  test("a non-lead agent gets 403 and nothing reaches the proxy", async () => {
97
- const result = await post({ granted: true }, null, WORKER_ID);
98
+ const result = await post({ granted: true, contactEmail: "admin@acme.com" }, null, WORKER_ID);
98
99
  expect(result.statusCode).toBe(403);
99
100
  expect(proxied).toEqual([]);
100
101
  });
101
102
 
103
+ test("consent telemetry reports quote and function presence without their contents", async () => {
104
+ const savedFlag = process.env.ANONYMIZED_TELEMETRY;
105
+ try {
106
+ process.env.ANONYMIZED_TELEMETRY = "true";
107
+ _resetTelemetryStateForTests();
108
+ await initTelemetry("api-server", configValue, async () => {}, { generateIfMissing: true });
109
+ await post(
110
+ {
111
+ granted: true,
112
+ contactEmail: "admin@acme.com",
113
+ contactFunction: "founder",
114
+ quote: "Private quote",
115
+ },
116
+ OPERATOR,
117
+ );
118
+ await post({ granted: false }, OPERATOR);
119
+ await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
120
+ const events = proxied.filter((request) => request.body.event === "used_by.consent_changed");
121
+ expect(events).toHaveLength(3);
122
+ expect(events.map((event) => event.body.properties)).toMatchObject([
123
+ { granted: true, has_quote: true, has_function: true },
124
+ { granted: false, has_quote: false, has_function: false },
125
+ { granted: true, has_quote: false, has_function: false },
126
+ ]);
127
+ for (const event of events) {
128
+ expect(JSON.stringify(event.body)).not.toContain("Private quote");
129
+ expect(JSON.stringify(event.body)).not.toContain("admin@acme.com");
130
+ }
131
+ } finally {
132
+ if (savedFlag === undefined) delete process.env.ANONYMIZED_TELEMETRY;
133
+ else process.env.ANONYMIZED_TELEMETRY = savedFlag;
134
+ _resetTelemetryStateForTests();
135
+ }
136
+ });
137
+
102
138
  test("grant and revoke both reach the proxy and are stored", async () => {
103
- const grant = await post({ granted: true }, OPERATOR);
139
+ const grant = await post(
140
+ {
141
+ granted: true,
142
+ contactEmail: "admin@acme.com",
143
+ contactFunction: "founder",
144
+ quote: "Our swarm ships features.",
145
+ },
146
+ OPERATOR,
147
+ );
104
148
  expect(grant.statusCode).toBe(200);
105
149
  expect(proxied[0]?.url).toBe("https://proxy.desplega.sh/v1/used-by");
106
150
  expect(proxied[0]?.body).toMatchObject({
107
151
  org_name: "Acme",
108
152
  org_website: "acme.com",
109
153
  granted: true,
154
+ contact_email: "admin@acme.com",
155
+ contact_function: "founder",
156
+ quote: "Our swarm ships features.",
110
157
  });
111
158
  expect(await configValue("SWARM_USED_BY_CONSENT")).toBe("true");
159
+ expect(await configValue("SWARM_USED_BY_CONTACT_EMAIL")).toBe("admin@acme.com");
160
+ expect(await configValue("SWARM_USED_BY_CONTACT_FUNCTION")).toBe("founder");
161
+ expect(await configValue("SWARM_USED_BY_QUOTE")).toBe("Our swarm ships features.");
162
+ // Contact details never reach agent env, and readers see the email masked.
163
+ const rows = await getSwarmConfigs({ scope: "global" });
164
+ const row = (key: string) => rows.find((entry) => entry.key === key);
165
+ for (const key of [
166
+ "SWARM_USED_BY_CONTACT_EMAIL",
167
+ "SWARM_USED_BY_CONTACT_FUNCTION",
168
+ "SWARM_USED_BY_QUOTE",
169
+ ]) {
170
+ expect(row(key)?.exportToEnv).toBe(false);
171
+ }
172
+ expect(row("SWARM_USED_BY_CONTACT_EMAIL")?.isSecret).toBe(true);
112
173
 
113
174
  const revoke = await post({ granted: false }, OPERATOR);
114
175
  expect(revoke.statusCode).toBe(200);
115
176
  expect(proxied[1]?.body).toMatchObject({ granted: false });
116
177
  expect(await configValue("SWARM_USED_BY_CONSENT")).toBe("false");
178
+ expect(await configValue("SWARM_USED_BY_CONTACT_EMAIL")).toBe("");
179
+ expect(await configValue("SWARM_USED_BY_CONTACT_FUNCTION")).toBe("");
180
+ expect(await configValue("SWARM_USED_BY_QUOTE")).toBe("");
181
+ expect(proxied[1]?.body).not.toHaveProperty("contact_email");
182
+ expect(proxied[1]?.body).not.toHaveProperty("contact_function");
183
+ expect(proxied[1]?.body).not.toHaveProperty("quote");
184
+ });
185
+
186
+ test("a grant requires valid contact details and a quote of at most 280 characters", async () => {
187
+ for (const details of [
188
+ {},
189
+ { contactEmail: "" },
190
+ { contactEmail: "invalid" },
191
+ { contactEmail: "admin@acme.com", contactFunction: "CEO" },
192
+ { contactEmail: "admin@acme.com", quote: "x".repeat(281) },
193
+ ]) {
194
+ const result = await post({ granted: true, ...details }, OPERATOR);
195
+ expect(result.statusCode).toBe(400);
196
+ expect(result.body).toContain("Validation error");
197
+ }
198
+ expect(proxied).toEqual([]);
199
+ expect((await post({ granted: false, contactEmail: "invalid" }, OPERATOR)).statusCode).toBe(
200
+ 400,
201
+ );
202
+ expect(proxied).toEqual([]);
203
+ });
204
+
205
+ test("contact email accepts 320 characters and rejects longer values", async () => {
206
+ const contactEmail = `${"a".repeat(64)}@${Array(4).fill("b".repeat(63)).join(".")}`;
207
+ expect(contactEmail).toHaveLength(320);
208
+ expect((await post({ granted: true, contactEmail }, OPERATOR)).statusCode).toBe(200);
209
+ proxied = [];
210
+ const result = await post({ granted: true, contactEmail: `a${contactEmail}` }, OPERATOR);
211
+ expect(result.statusCode).toBe(400);
212
+ expect(result.body).toContain("Contact email must be at most 320 characters");
213
+ expect(proxied).toEqual([]);
214
+ });
215
+
216
+ test("an empty stored name overrides the deployment name", async () => {
217
+ process.env.SWARM_ORG_NAME = "Deployment name";
218
+ try {
219
+ await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_NAME", value: "" });
220
+ const result = await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
221
+ expect(result.statusCode).toBe(400);
222
+ expect(result.body).toContain("Set the organization name and website first");
223
+ expect(proxied).toEqual([]);
224
+ } finally {
225
+ delete process.env.SWARM_ORG_NAME;
226
+ }
227
+ });
228
+
229
+ test("a new grant clears omitted optional fields from the previous grant", async () => {
230
+ const result = await post(
231
+ {
232
+ granted: true,
233
+ contactEmail: "admin@acme.com",
234
+ contactFunction: "engineer",
235
+ quote: "x".repeat(280),
236
+ },
237
+ OPERATOR,
238
+ );
239
+ expect(result.statusCode).toBe(200);
240
+ expect(await configValue("SWARM_USED_BY_QUOTE")).toHaveLength(280);
241
+ expect((await post({ granted: true, contactEmail: "new@acme.com" }, OPERATOR)).statusCode).toBe(
242
+ 200,
243
+ );
244
+ expect(await configValue("SWARM_USED_BY_CONTACT_EMAIL")).toBe("new@acme.com");
245
+ expect(await configValue("SWARM_USED_BY_CONTACT_FUNCTION")).toBe("");
246
+ expect(await configValue("SWARM_USED_BY_QUOTE")).toBe("");
117
247
  });
118
248
 
119
249
  test("with telemetry off the install ID stays home", async () => {
@@ -124,9 +254,9 @@ describe("POST /api/used-by", () => {
124
254
  });
125
255
  const saved = process.env.ANONYMIZED_TELEMETRY;
126
256
  process.env.ANONYMIZED_TELEMETRY = "true";
127
- await post({ granted: true }, OPERATOR);
257
+ await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
128
258
  process.env.ANONYMIZED_TELEMETRY = "false";
129
- await post({ granted: true }, OPERATOR);
259
+ await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
130
260
  if (saved === undefined) delete process.env.ANONYMIZED_TELEMETRY;
131
261
  else process.env.ANONYMIZED_TELEMETRY = saved;
132
262
  expect(proxied[0]?.body.install_id).toBe("install_0123456789abcdef");
@@ -139,7 +269,7 @@ describe("POST /api/used-by", () => {
139
269
  key: "feedback_endpoint",
140
270
  value: "http://stub.test/v1/feedback",
141
271
  });
142
- await post({ granted: true }, OPERATOR);
272
+ await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
143
273
  expect(proxied[0]?.url).toBe("http://stub.test/v1/used-by");
144
274
  await upsertSwarmConfig({
145
275
  scope: "global",
@@ -151,13 +281,15 @@ describe("POST /api/used-by", () => {
151
281
  test("a proxy failure is a 502 and leaves the stored consent alone", async () => {
152
282
  await upsertSwarmConfig({ scope: "global", key: "SWARM_USED_BY_CONSENT", value: "false" });
153
283
  proxyStatus = 500;
154
- const result = await post({ granted: true }, OPERATOR);
284
+ const result = await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
155
285
  expect(result.statusCode).toBe(502);
156
286
  expect(await configValue("SWARM_USED_BY_CONSENT")).toBe("false");
157
287
  });
158
288
 
159
289
  test("a revoke after the website is cleared or changed uses the granted identity", async () => {
160
- expect((await post({ granted: true }, OPERATOR)).statusCode).toBe(200);
290
+ expect(
291
+ (await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR)).statusCode,
292
+ ).toBe(200);
161
293
  await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_WEBSITE", value: "" });
162
294
  const revoke = await post({ granted: false }, OPERATOR);
163
295
  expect(revoke.statusCode).toBe(200);
@@ -173,7 +305,9 @@ describe("POST /api/used-by", () => {
173
305
  expect(proxied[2]?.body).toMatchObject({ org_name: "Acme", org_website: "acme.com" });
174
306
 
175
307
  // A new grant sends, and from then on revokes with, the current identity.
176
- expect((await post({ granted: true }, OPERATOR)).statusCode).toBe(200);
308
+ expect(
309
+ (await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR)).statusCode,
310
+ ).toBe(200);
177
311
  expect(proxied[3]?.body).toMatchObject({ org_name: "Acme Labs", org_website: "acme.io" });
178
312
  await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_WEBSITE", value: "" });
179
313
  await post({ granted: false }, OPERATOR);
@@ -182,10 +316,20 @@ describe("POST /api/used-by", () => {
182
316
 
183
317
  test("without a website it is a 400", async () => {
184
318
  await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_WEBSITE", value: "" });
185
- const result = await post({ granted: true }, OPERATOR);
319
+ const result = await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
186
320
  expect(result.statusCode).toBe(400);
321
+ expect(JSON.parse(result.body)).toEqual({
322
+ error: "Set the organization name and website first",
323
+ });
187
324
  expect(proxied).toEqual([]);
188
325
  });
326
+
327
+ test("a placeholder name still grants: the ranking's placeholder rule does not apply", async () => {
328
+ await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_NAME", value: "Swarm" });
329
+ const result = await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
330
+ expect(result.statusCode).toBe(200);
331
+ expect(proxied[0]?.body).toMatchObject({ org_name: "Swarm", org_website: "acme.com" });
332
+ });
189
333
  });
190
334
 
191
335
  describe("fillOrgLogoFromWebsite", () => {
@@ -13,8 +13,14 @@ import {
13
13
  getWorkflowRun,
14
14
  getWorkflowRunStepsByRunId,
15
15
  initDb,
16
+ updateWorkflowRunStep,
16
17
  } from "../be/db";
17
- import type { AgentTask, WorkflowDefinition, WorkflowRunStep } from "../types";
18
+ import {
19
+ type AgentTask,
20
+ SUPERSEDED_WORKFLOW_TASK_REASON,
21
+ type WorkflowDefinition,
22
+ type WorkflowRunStep,
23
+ } from "../types";
18
24
  import { startWorkflowExecution } from "../workflows/engine";
19
25
  import { workflowEventBus } from "../workflows/event-bus";
20
26
  import { AgentTaskExecutor } from "../workflows/executors/agent-task";
@@ -27,6 +33,7 @@ import { ExecutorRegistry } from "../workflows/executors/registry";
27
33
  import { recoverIncompleteRuns } from "../workflows/recovery";
28
34
  import { setupWorkflowResumeListener } from "../workflows/resume";
29
35
  import { startRetryPoller, stopRetryPoller } from "../workflows/retry-poller";
36
+ import { SUPERSEDE_RETRY_POLICY } from "../workflows/task-step-routing";
30
37
  import { interpolate } from "../workflows/template";
31
38
 
32
39
  const TEST_DB_PATH = "./test-workflow-agent-task-retry.sqlite";
@@ -217,18 +224,18 @@ describe("agent-task node.retry on task failure", () => {
217
224
  expect((await getTaskByWorkflowRunStepId(step.id))!.id).toBe(second.id);
218
225
  });
219
226
 
220
- test("a node without retry fails the run exactly as before", async () => {
227
+ test("a node without retry fails the run exactly as before on a non-supersede failure", async () => {
221
228
  const runId = await startRun({});
222
229
  const step = await synthesizeStep(runId);
223
230
  const first = (await getTaskByWorkflowRunStepId(step.id))!;
224
231
 
225
- await failTask(first.id, "superseded_workflow_task");
232
+ await failTask(first.id, "agent crashed");
226
233
 
227
234
  const run = await waitFor(async () => {
228
235
  const r = await getWorkflowRun(runId);
229
236
  return r?.status === "failed" ? r : null;
230
237
  }, "run failed");
231
- expect(run.error).toContain("superseded_workflow_task");
238
+ expect(run.error).toContain("agent crashed");
232
239
  const failedStep = await synthesizeStep(runId);
233
240
  expect(failedStep.status).toBe("failed");
234
241
  expect(failedStep.retryCount).toBe(0);
@@ -281,6 +288,125 @@ describe("agent-task node.retry on task failure", () => {
281
288
  });
282
289
  });
283
290
 
291
+ /** Wait for the step to be queued for the poller, then make it due now. */
292
+ async function waitForQueuedRetry(runId: string): Promise<WorkflowRunStep> {
293
+ const queued = await waitFor(async () => {
294
+ const s = await synthesizeStep(runId);
295
+ return s.status === "failed" && s.nextRetryAt != null ? s : null;
296
+ }, "step queued for retry");
297
+ await updateWorkflowRunStep(queued.id, { nextRetryAt: new Date(Date.now() - 1).toISOString() });
298
+ return queued;
299
+ }
300
+
301
+ describe("implicit retry for a superseded task on a node without retry", () => {
302
+ test("a cut is redriven once after the static delay; a second cut follows onNodeFailure", async () => {
303
+ const runId = await startRun({});
304
+ const step = await synthesizeStep(runId);
305
+ const first = (await getTaskByWorkflowRunStepId(step.id))!;
306
+
307
+ const cutAt = Date.now();
308
+ await failTask(first.id, SUPERSEDED_WORKFLOW_TASK_REASON);
309
+
310
+ const queued = await waitFor(async () => {
311
+ const s = await synthesizeStep(runId);
312
+ return s.status === "failed" && s.nextRetryAt != null ? s : null;
313
+ }, "step queued for retry");
314
+ expect(queued.retryCount).toBe(1);
315
+ expect(queued.maxRetries).toBe(1);
316
+ expect(queued.error).toBe(SUPERSEDED_WORKFLOW_TASK_REASON);
317
+ const delayMs = new Date(queued.nextRetryAt!).getTime() - cutAt;
318
+ expect(delayMs).toBeGreaterThanOrEqual(SUPERSEDE_RETRY_POLICY.baseDelayMs - 1000);
319
+ expect(delayMs).toBeLessThanOrEqual(SUPERSEDE_RETRY_POLICY.baseDelayMs + 5000);
320
+ expect((await getWorkflowRun(runId))!.status).toBe("waiting");
321
+ expect((await getTaskById(first.id))!.workflowRunStepId).toBeFalsy();
322
+
323
+ await waitForQueuedRetry(runId);
324
+ startRetryPoller(registry, 10);
325
+ const second = await waitForRedrive(step.id, first);
326
+ await waitFor(async () => {
327
+ const s = await synthesizeStep(runId);
328
+ return s.status === "waiting" ? s : null;
329
+ }, "step waiting on redriven task");
330
+ stopRetryPoller();
331
+
332
+ await failTask(second.id, SUPERSEDED_WORKFLOW_TASK_REASON);
333
+ const run = await waitFor(async () => {
334
+ const r = await getWorkflowRun(runId);
335
+ return r?.status === "failed" ? r : null;
336
+ }, "run failed via onNodeFailure:fail");
337
+ expect(run.error).toContain(SUPERSEDED_WORKFLOW_TASK_REASON);
338
+ const finalStep = await synthesizeStep(runId);
339
+ expect(finalStep.status).toBe("failed");
340
+ expect(finalStep.retryCount).toBe(1);
341
+ expect(finalStep.nextRetryAt).toBeFalsy();
342
+ expect((await getTaskByWorkflowRunStepId(step.id))!.id).toBe(second.id);
343
+ });
344
+
345
+ test("the redriven task completing continues the run", async () => {
346
+ const runId = await startRun({ onNodeFailure: "continue" });
347
+ const step = await synthesizeStep(runId);
348
+ const first = (await getTaskByWorkflowRunStepId(step.id))!;
349
+
350
+ await failTask(first.id, SUPERSEDED_WORKFLOW_TASK_REASON);
351
+ await waitForQueuedRetry(runId);
352
+ startRetryPoller(registry, 10);
353
+ const second = await waitForRedrive(step.id, first);
354
+ await waitFor(async () => {
355
+ const s = await synthesizeStep(runId);
356
+ return s.status === "waiting" ? s : null;
357
+ }, "step waiting on redriven task");
358
+ stopRetryPoller();
359
+
360
+ await completeTask(second.id, "synthesis done");
361
+ const run = await waitFor(async () => {
362
+ const r = await getWorkflowRun(runId);
363
+ return r?.status === "completed" ? r : null;
364
+ }, "run completed");
365
+ expect(run.status).toBe("completed");
366
+ const finalStep = await synthesizeStep(runId);
367
+ expect(JSON.stringify(finalStep.output)).not.toContain("[FAILED:");
368
+ });
369
+
370
+ test("explicit retry {maxRetries:0} opts out: the cut fails the run", async () => {
371
+ const runId = await startRun({ retry: { ...RETRY_ONCE, maxRetries: 0 } });
372
+ const step = await synthesizeStep(runId);
373
+ const first = (await getTaskByWorkflowRunStepId(step.id))!;
374
+
375
+ await failTask(first.id, SUPERSEDED_WORKFLOW_TASK_REASON);
376
+
377
+ const run = await waitFor(async () => {
378
+ const r = await getWorkflowRun(runId);
379
+ return r?.status === "failed" ? r : null;
380
+ }, "run failed");
381
+ expect(run.error).toContain(SUPERSEDED_WORKFLOW_TASK_REASON);
382
+ const failedStep = await synthesizeStep(runId);
383
+ expect(failedStep.retryCount).toBe(0);
384
+ expect(failedStep.nextRetryAt).toBeFalsy();
385
+ expect((await getTaskByWorkflowRunStepId(step.id))!.id).toBe(first.id);
386
+ });
387
+
388
+ test("the recovery sweep reads the cut from the task row", async () => {
389
+ const runId = await startRun({});
390
+ const step = await synthesizeStep(runId);
391
+ const first = (await getTaskByWorkflowRunStepId(step.id))!;
392
+
393
+ teardownResumeListener?.();
394
+ try {
395
+ await failTask(first.id, SUPERSEDED_WORKFLOW_TASK_REASON);
396
+ await new Promise((r) => setTimeout(r, 20));
397
+ await recoverIncompleteRuns(registry);
398
+ } finally {
399
+ teardownResumeListener = setupWorkflowResumeListener(workflowEventBus, registry);
400
+ }
401
+
402
+ const queued = await synthesizeStep(runId);
403
+ expect(queued.status).toBe("failed");
404
+ expect(queued.nextRetryAt).toBeTruthy();
405
+ expect(queued.retryCount).toBe(1);
406
+ expect((await getWorkflowRun(runId))!.status).toBe("waiting");
407
+ });
408
+ });
409
+
284
410
  describe("recovery sweep holding a stale failed-task snapshot", () => {
285
411
  /**
286
412
  * Recovery snapshots task A as failed, then pauses. Meanwhile the live
@@ -1,4 +1,4 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1
+ import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import * as db from "../be/db";
4
4
  import {
@@ -13,6 +13,7 @@ import {
13
13
  getWorkflowRunStep,
14
14
  getWorkflowRunStepsByRunId,
15
15
  initDb,
16
+ resolveWaitState,
16
17
  updateWorkflowRun,
17
18
  updateWorkflowRunStep,
18
19
  } from "../be/db";
@@ -23,6 +24,10 @@ import { createExecutorRegistry } from "../workflows/executors/registry";
23
24
  import { recoverIncompleteRuns } from "../workflows/recovery";
24
25
 
25
26
  const TEST_DB_PATH = "./test-workflow-wait-recovery.sqlite";
27
+ const templateGlobals = globalThis as typeof globalThis & {
28
+ __testMigrationTemplate?: Uint8Array;
29
+ };
30
+ let savedTemplate: Uint8Array | undefined;
26
31
 
27
32
  const eventBus = new InProcessEventBus();
28
33
  const deps: ExecutorDependencies = {
@@ -42,7 +47,52 @@ async function makeWorkflow(name: string, def: WorkflowDefinition): Promise<Work
42
47
  return wf;
43
48
  }
44
49
 
50
+ async function makeResolvedWait(
51
+ mode: "time" | "event",
52
+ status: "fired" | "timeout",
53
+ nextPort: string,
54
+ ) {
55
+ const wf = await makeWorkflow("wait-recovery-resolved", {
56
+ nodes: [
57
+ {
58
+ id: "w1",
59
+ type: "wait",
60
+ config:
61
+ mode === "time"
62
+ ? { mode, durationMs: 60_000 }
63
+ : { mode, eventName: "saved.signal", timeoutMs: 60_000 },
64
+ next: { [nextPort]: "done" },
65
+ },
66
+ { id: "done", type: "notify", config: { channel: "swarm", template: "recovered" } },
67
+ ],
68
+ });
69
+ const run = await createWorkflowRun({ id: crypto.randomUUID(), workflowId: wf.id });
70
+ await updateWorkflowRun(run.id, { status: "waiting" });
71
+ const step = await createWorkflowRunStep({
72
+ id: crypto.randomUUID(),
73
+ runId: run.id,
74
+ nodeId: "w1",
75
+ nodeType: "wait",
76
+ });
77
+ await updateWorkflowRunStep(step.id, { status: "waiting" });
78
+ const future = new Date(Date.now() + 60_000).toISOString();
79
+ const wait = await createWaitState({
80
+ id: crypto.randomUUID(),
81
+ workflowRunId: run.id,
82
+ workflowRunStepId: step.id,
83
+ mode,
84
+ ...(mode === "time" ? { wakeUpAt: future } : { eventName: "saved.signal", expiresAt: future }),
85
+ });
86
+ const payload = mode === "event" && status === "fired" ? { signal: "saved", ok: true } : null;
87
+ const resolution = await resolveWaitState(wait.id, { status, firedPayload: payload });
88
+ expect(resolution.updated).toBe(true);
89
+ return { run, step, wait, payload, resolution };
90
+ }
91
+
45
92
  beforeAll(async () => {
93
+ // Use a file-backed DB so restart cases retain their committed wait resolutions.
94
+ savedTemplate = templateGlobals.__testMigrationTemplate;
95
+ templateGlobals.__testMigrationTemplate = undefined;
46
96
  try {
47
97
  await unlink(TEST_DB_PATH);
48
98
  } catch {
@@ -60,12 +110,76 @@ afterAll(async () => {
60
110
  }
61
111
  }
62
112
  closeDb();
113
+ templateGlobals.__testMigrationTemplate = savedTemplate;
63
114
  for (const suffix of ["", "-wal", "-shm"]) {
64
115
  await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => {});
65
116
  }
66
117
  });
67
118
 
68
119
  describe("WaitExecutor — recovery on startup", () => {
120
+ test.each([
121
+ ["time", "fired", "default"],
122
+ ["event", "fired", "event"],
123
+ ["event", "timeout", "timeout"],
124
+ ] as const)("recovers a persisted %s/%s resolution after restart via '%s' exactly once", async (mode, status, nextPort) => {
125
+ const registry = createExecutorRegistry(deps);
126
+ const { run, step, wait, payload, resolution } = await makeResolvedWait(mode, status, nextPort);
127
+
128
+ // Reopen after the resolution commits, before the waiting step is checkpointed.
129
+ closeDb();
130
+ initDb(TEST_DB_PATH);
131
+ expect((await getWorkflowRunStep(step.id))?.status).toBe("waiting");
132
+ expect((await getWaitStateByStepId(step.id))?.status).toBe(status);
133
+ await Promise.all([recoverIncompleteRuns(registry), recoverIncompleteRuns(registry)]);
134
+
135
+ const recoveredStep = await getWorkflowRunStep(step.id);
136
+ expect(recoveredStep?.status).toBe("completed");
137
+ expect(recoveredStep?.nextPort).toBe(nextPort);
138
+ expect(recoveredStep?.output).toEqual({
139
+ waitId: wait.id,
140
+ mode,
141
+ firedAt: resolution.row!.resolvedAt,
142
+ payload,
143
+ });
144
+ expect(await getWaitStateByStepId(step.id)).toEqual(resolution.row);
145
+ expect((await getWorkflowRun(run.id))?.status).toBe("completed");
146
+ const doneSteps = (await getWorkflowRunStepsByRunId(run.id)).filter((s) => s.nodeId === "done");
147
+ expect(doneSteps).toHaveLength(1);
148
+ expect(doneSteps[0]?.status).toBe("completed");
149
+
150
+ await recoverIncompleteRuns(registry);
151
+ expect((await getWorkflowRunStepsByRunId(run.id)).filter((s) => s.nodeId === "done")).toEqual(
152
+ doneSteps,
153
+ );
154
+ expect((await getWorkflowRun(run.id))?.status).toBe("completed");
155
+ });
156
+
157
+ test.each([
158
+ "completed",
159
+ "failed",
160
+ "cancelled",
161
+ ] as const)("does not reopen a %s run that becomes terminal during wait recovery", async (status) => {
162
+ const registry = createExecutorRegistry(deps);
163
+ const { run, step } = await makeResolvedWait("event", "fired", "event");
164
+ const getWorkflow = db.getWorkflow;
165
+ const snapshot = spyOn(db, "getWorkflow").mockImplementationOnce(async (id) => {
166
+ const workflow = await getWorkflow(id);
167
+ expect((await getWorkflowRun(run.id))?.status).toBe("waiting");
168
+ // A sibling can fail the run after the resume read, leaving this step waiting.
169
+ await updateWorkflowRun(run.id, { status });
170
+ return workflow;
171
+ });
172
+ try {
173
+ await recoverIncompleteRuns(registry);
174
+ expect(snapshot).toHaveBeenCalledTimes(1);
175
+ expect((await getWorkflowRun(run.id))?.status).toBe(status);
176
+ expect((await getWorkflowRunStep(step.id))?.status).toBe("waiting");
177
+ expect(await getWorkflowRunStepsByRunId(run.id)).toHaveLength(1);
178
+ } finally {
179
+ snapshot.mockRestore();
180
+ }
181
+ });
182
+
69
183
  test("server died while waiting: overdue time-mode wait recovers via 'default' port", async () => {
70
184
  const registry = createExecutorRegistry(deps);
71
185
 
@@ -170,9 +284,7 @@ describe("WaitExecutor — recovery on startup", () => {
170
284
  const doneCount1 = stepsAfter1.filter((s) => s.nodeId === "done").length;
171
285
  expect(doneCount1).toBe(1);
172
286
 
173
- // Second recovery pass — wait_state is now 'fired' (case a in the
174
- // recovery query). resumeWaitState's atomic update returns updated=false,
175
- // so the step shouldn't re-run.
287
+ // Completed runs and steps are no longer eligible for recovery.
176
288
  await recoverIncompleteRuns(registry);
177
289
  const stepsAfter2 = await getWorkflowRunStepsByRunId(run.id);
178
290
  expect(stepsAfter2.filter((s) => s.nodeId === "done").length).toBe(1);
@@ -0,0 +1,63 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { runAppAction } from "../apps/action-runner";
4
+ import { AppNameSchema } from "../apps/definition";
5
+ import { appDefinitionNeedsRepair, getApp } from "../apps/store";
6
+ import { getAgentById } from "../be/db";
7
+ import { can } from "../rbac";
8
+ import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "./utils";
9
+
10
+ export function registerAppActionRunTool(server: McpServer) {
11
+ createToolRegistrar(server)(
12
+ "app-action-run",
13
+ {
14
+ title: "Run an app action",
15
+ description:
16
+ "Run a declared app action as an agent. Scripts and extensions cannot invoke this tool.",
17
+ // An action runs a script, a sync, or a task: it can write rows and reach external sources.
18
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
19
+ rbac: { permission: "app.use" },
20
+ inputSchema: z.object({
21
+ appId: z.string().min(1),
22
+ action: AppNameSchema,
23
+ input: z.record(z.string(), z.unknown()).optional(),
24
+ }),
25
+ outputSchema: swarmToolOutputSchema({
26
+ ok: z.boolean().optional(),
27
+ result: z.unknown().optional(),
28
+ stdout: z.string().optional(),
29
+ error: z.string().optional(),
30
+ durationMs: z.number().optional(),
31
+ taskId: z.string().optional(),
32
+ status: z.string().optional(),
33
+ }),
34
+ },
35
+ async ({ appId, action, input }, info) => {
36
+ if (info.callOrigin === "script-sdk" || info.callOrigin === "extension")
37
+ return toolErr("Scripts and extensions cannot run app actions.");
38
+ if (!info.agentId) return toolErr('Agent ID not found. Set the "X-Agent-ID" header.');
39
+ const agent = await getAgentById(info.agentId);
40
+ const decision = can({
41
+ principal: { kind: "agent", agentId: info.agentId, isLead: agent?.isLead ?? false },
42
+ verb: "app.use",
43
+ resource: { kind: "app", appId },
44
+ source: "mcp",
45
+ });
46
+ if (!decision.allow) return toolErr(decision.reason);
47
+ const app = await getApp(appId);
48
+ if (!app) return toolErr("App not found.");
49
+ if (appDefinitionNeedsRepair(app))
50
+ return toolErr("App definition needs repair.", { data: { issues: app.definitionError } });
51
+ const output = await runAppAction({
52
+ app,
53
+ name: action,
54
+ input,
55
+ actor: `agent:${info.agentId}`,
56
+ caller: { kind: "agent", id: info.agentId, ...(agent?.name ? { name: agent.name } : {}) },
57
+ });
58
+ if (output.status !== 200)
59
+ return toolErr(output.body.error ?? "Action failed.", { data: output.body });
60
+ return toolOk("Action invoked.", { data: output.body });
61
+ },
62
+ );
63
+ }