@desplega.ai/agent-swarm 2.7.4 → 2.7.5

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 (253) hide show
  1. package/README.md +1 -1
  2. package/dist/acp-adapter-g3ads55s.js +15 -0
  3. package/dist/{actions-gfrr9vb7.js → actions-zwfykqqk.js} +18 -18
  4. package/dist/{amp-adapter-0s58gm46.js → amp-adapter-z3s0ssdt.js} +3 -3
  5. package/dist/{app-stvq692g.js → app-vmp4fgep.js} +3 -3
  6. package/dist/{assistant-5s37fr9n.js → assistant-vwde653f.js} +28 -21
  7. package/dist/{boot-reembed-yzy1841n.js → boot-reembed-375t7tbk.js} +1 -1
  8. package/dist/{boot-reembed-cv82gz7y.js → boot-reembed-gq9rcpnj.js} +1 -1
  9. package/dist/{boot-scrub-sweep-8k4q95ap.js → boot-scrub-sweep-nd938kyf.js} +4 -4
  10. package/dist/{boot-seeds-ftt9htah.js → boot-seeds-aykx51v3.js} +4 -4
  11. package/dist/{boxd-515v1t8z.js → boxd-k1j26g3h.js} +1 -1
  12. package/dist/{claude-adapter-t00wrzeh.js → claude-adapter-8j4hm099.js} +7 -7
  13. package/dist/{claude-managed-adapter-g6vmjccb.js → claude-managed-adapter-dvs2xw5n.js} +2 -2
  14. package/dist/{claude-managed-setup-afpa82f7.js → claude-managed-setup-0wyynpn1.js} +3 -3
  15. package/dist/{claude-sdk-session-vj0c2azx.js → claude-sdk-session-q6wj5hmy.js} +9 -9
  16. package/dist/{cli-wndv9p7g.js → cli-0731hvbw.js} +6 -6
  17. package/dist/{cli-d2pzqej1.js → cli-0bxqnvq9.js} +719 -7
  18. package/dist/{cli-jkt52mgt.js → cli-0gey5fzy.js} +2 -2
  19. package/dist/{cli-1yekbyqm.js → cli-1338796d.js} +205 -48
  20. package/dist/{cli-1s1dqn1j.js → cli-1ayk1eas.js} +171 -123
  21. package/dist/{cli-a4btqqft.js → cli-1b7btd38.js} +1 -1
  22. package/dist/{cli-a2jw6ee5.js → cli-1p89xny0.js} +1 -1
  23. package/dist/{cli-v726afdm.js → cli-2pnswgh8.js} +1 -1
  24. package/dist/{cli-kvtc7aez.js → cli-3h4nhjn8.js} +42 -14
  25. package/dist/{cli-655xm9ms.js → cli-583frbyr.js} +1 -1
  26. package/dist/{cli-4r12t6w6.js → cli-5c4hjzne.js} +101 -4
  27. package/dist/{cli-az0bpf8p.js → cli-5pag6rmn.js} +3 -3
  28. package/dist/{cli-36g23z21.js → cli-6s7vdcta.js} +4 -4
  29. package/dist/{cli-rtk9g7av.js → cli-73jzwksr.js} +6 -2
  30. package/dist/{cli-jg0nfda6.js → cli-75gkpjvw.js} +52 -21
  31. package/dist/{cli-a81qevyg.js → cli-7a42nfy0.js} +20 -36
  32. package/dist/{cli-0sbkss7h.js → cli-7wcyk2s5.js} +2 -2
  33. package/dist/{cli-41z8xwj0.js → cli-7wspm5gm.js} +14 -4
  34. package/dist/{cli-byqfbnjw.js → cli-82ae98pz.js} +9 -9
  35. package/dist/{cli-bq2pcphn.js → cli-8hs41zy2.js} +1 -1
  36. package/dist/{cli-vqgnyh76.js → cli-98rw5dt5.js} +15 -3
  37. package/dist/{cli-9cctx1tn.js → cli-a22h291q.js} +1 -1
  38. package/dist/{cli-j4kt1mpb.js → cli-a47xy8eq.js} +3 -3
  39. package/dist/{cli-ycm6180s.js → cli-a7v854rf.js} +5 -3
  40. package/dist/{cli-98pke4vk.js → cli-ae7mrdf9.js} +2 -2
  41. package/dist/{cli-ahd90bwt.js → cli-afrrppsw.js} +6 -6
  42. package/dist/{cli-8yy525ad.js → cli-bhjt02p8.js} +2 -2
  43. package/dist/{cli-v1yw3wha.js → cli-bnz7a7qf.js} +4 -4
  44. package/dist/{cli-j4kw3rsx.js → cli-cm5yqn35.js} +18 -0
  45. package/dist/{cli-h55y2bjc.js → cli-dhar9qay.js} +2 -2
  46. package/dist/{cli-5gr1g711.js → cli-fqzx7gwa.js} +2 -2
  47. package/dist/{cli-tzrpv5sc.js → cli-ftyxyspd.js} +1 -1
  48. package/dist/cli-fw2w0kva.js +47 -0
  49. package/dist/{cli-2dfbv8qr.js → cli-fy6x31ps.js} +1 -0
  50. package/dist/{cli-mvckssyq.js → cli-g61qxmge.js} +5 -5
  51. package/dist/{cli-aq58fk5m.js → cli-g83s9ewh.js} +7 -1
  52. package/dist/{cli-224epwwf.js → cli-hn9hxmz9.js} +3 -3
  53. package/dist/{cli-5hjced2v.js → cli-j3jvnpe8.js} +6 -6
  54. package/dist/{cli-dae4sbgy.js → cli-jm2vfpfd.js} +2 -2
  55. package/dist/{cli-zn15c46z.js → cli-mjzynrse.js} +1 -1
  56. package/dist/{cli-a1fhj4n9.js → cli-mmwm7zbf.js} +2 -2
  57. package/dist/{cli-cq1pf336.js → cli-mry0qzym.js} +5 -5
  58. package/dist/{cli-bwty35gx.js → cli-mthwvjre.js} +1 -1
  59. package/dist/{cli-s0exq74r.js → cli-nmbmy64z.js} +3 -3
  60. package/dist/{cli-wcmnan2g.js → cli-nn4d8dne.js} +5 -5
  61. package/dist/{cli-jg4p5t36.js → cli-nzxr556s.js} +121 -51
  62. package/dist/{cli-bcz074zx.js → cli-p599f1qm.js} +1 -1
  63. package/dist/{cli-k76tgq03.js → cli-pywf3eh5.js} +2 -2
  64. package/dist/{cli-wk9md5px.js → cli-qxnf8z2z.js} +2 -2
  65. package/dist/{cli-je8yeyc5.js → cli-rhw40jgb.js} +5 -5
  66. package/dist/{cli-avgcz4ra.js → cli-rm6hqj3z.js} +406 -35
  67. package/dist/{cli-0fgr1w9b.js → cli-rpm2sgv0.js} +2 -2
  68. package/dist/{cli-t282jgab.js → cli-rqkxrmgw.js} +12 -12
  69. package/dist/{cli-2qpvmmkw.js → cli-s0qnd4wv.js} +1 -1
  70. package/dist/{cli-pwyevs29.js → cli-spknvxbq.js} +22 -21
  71. package/dist/{cli-9y354g3j.js → cli-srye3vdt.js} +1 -1
  72. package/dist/{cli-pe09p6dv.js → cli-tqgg40ek.js} +1 -1
  73. package/dist/{cli-f255yaqn.js → cli-vbyfwh92.js} +3 -3
  74. package/dist/{cli-gcbdjt47.js → cli-vfym7wmz.js} +6 -6
  75. package/dist/{cli-edmm9d9y.js → cli-vqypxzh4.js} +7 -7
  76. package/dist/{cli-1gm8jcyr.js → cli-wvaawacd.js} +13 -13
  77. package/dist/{cli-eny54typ.js → cli-wx3rhnm0.js} +1 -1
  78. package/dist/{cli-b1afgtj3.js → cli-xbkdm710.js} +3 -3
  79. package/dist/{cli-mc97xy2s.js → cli-xtjhfmpc.js} +16 -4
  80. package/dist/{cli-eqx6k993.js → cli-y24sc29k.js} +25 -13
  81. package/dist/{cli-ktmvb20s.js → cli-z6jkx23q.js} +12 -12
  82. package/dist/{cli-7n5sbw0z.js → cli-z8eh3ex8.js} +5 -5
  83. package/dist/cli-zh3fp8v2.js +151 -0
  84. package/dist/{cli-13jvmew3.js → cli-zppwg9p9.js} +7 -7
  85. package/dist/cli.js +18 -18
  86. package/dist/{codex-adapter-g0gctw29.js → codex-adapter-q6k3gnyg.js} +5 -5
  87. package/dist/{codex-hook-vrzz2xhr.js → codex-hook-9efm2x1m.js} +1 -1
  88. package/dist/{codex-login-0gg46ygn.js → codex-login-y0pmff1c.js} +1 -1
  89. package/dist/{codex-session-runner-dr2cxe1c.js → codex-session-runner-3mwmk3d9.js} +6 -6
  90. package/dist/{commands-1p07cvff.js → commands-evrenjn9.js} +2 -2
  91. package/dist/{controller-z0s7sv6r.js → controller-xjp8vswh.js} +4 -4
  92. package/dist/{controllers-connect-x9kvvhyk.js → controllers-connect-pp5jnxvc.js} +20 -20
  93. package/dist/{cursor-adapter-m5b98pvk.js → cursor-adapter-yf47w4t0.js} +2 -2
  94. package/dist/{db-rsh04kh3.js → db-rgfsh91f.js} +2 -2
  95. package/dist/{db-capk2raj.js → db-zxpw6aht.js} +3 -3
  96. package/dist/{devin-adapter-yxm87fe9.js → devin-adapter-r9phbnc9.js} +1 -1
  97. package/dist/{dispatcher-304q6fwa.js → dispatcher-smasgws7.js} +17 -17
  98. package/dist/{docker-7rxt0rry.js → docker-rrjhaqt9.js} +2 -2
  99. package/dist/{dsh-adapter-btewem0y.js → dsh-adapter-ftyy8dnp.js} +2 -2
  100. package/dist/{e2b-renc0k8q.js → e2b-qcaj675y.js} +2 -2
  101. package/dist/{e2b-7fc9b9bd.js → e2b-w1p0bnpm.js} +2 -2
  102. package/dist/{easter-egg-3d-fm1p3n4d.js → easter-egg-3d-yyts7p16.js} +1 -1
  103. package/dist/{embedding-space-s68v49ct.js → embedding-space-1z0mbg95.js} +6 -6
  104. package/dist/{embeddings-4ms3604w.js → embeddings-ygeg7tm7.js} +1 -1
  105. package/dist/{event-bus-dfjx2qdh.js → event-bus-g0ka3pbw.js} +1 -1
  106. package/dist/{execute-mcdgccjs.js → execute-14qhqp4d.js} +1 -1
  107. package/dist/{external-channel-9dxwfztn.js → external-channel-ttgrs3sx.js} +2 -2
  108. package/dist/fx-adapter-gt6pncj2.js +18 -0
  109. package/dist/{grok-adapter-05ecv4h0.js → grok-adapter-sm4m5k4g.js} +8 -8
  110. package/dist/handlers-vwmba1m2.js +31 -0
  111. package/dist/{heartbeat-3n94heh3.js → heartbeat-qahz4p62.js} +20 -20
  112. package/dist/{hook-zxwje86c.js → hook-4fd5wq5w.js} +4 -4
  113. package/dist/{hook-aadrn69x.js → hook-kj3d398m.js} +6 -6
  114. package/dist/{http-xzxk9mst.js → http-1ted0j1k.js} +100 -64
  115. package/dist/index-5r71rf98.js +32 -0
  116. package/dist/{index-d34f6r87.js → index-6rq3r5zb.js} +1 -1
  117. package/dist/{index-9j4zwcht.js → index-7v51awbn.js} +158 -672
  118. package/dist/index-anmwxs87.js +29 -0
  119. package/dist/{index-qeb82z37.js → index-q4b917zs.js} +1 -1
  120. package/dist/index-spp0ra5c.js +36 -0
  121. package/dist/{keepalive-w4aakbyk.js → keepalive-6cf311t5.js} +4 -4
  122. package/dist/lead-fjy418cw.js +69 -0
  123. package/dist/{maintenance-hbptpk6p.js → maintenance-a4bm1bmt.js} +1 -1
  124. package/dist/{oauth-refresh-sweep-217semrv.js → oauth-refresh-sweep-3h0dfw1k.js} +3 -3
  125. package/dist/{onboard-vz2s5df3.js → onboard-myn5hraa.js} +3 -3
  126. package/dist/{opencode-adapter-gtfd7d7v.js → opencode-adapter-p2nkb5am.js} +5 -5
  127. package/dist/{otel-impl-aefave37.js → otel-impl-a1a0ef6b.js} +2 -2
  128. package/dist/{pi-mono-adapter-f8h5yseh.js → pi-mono-adapter-rzmpb72a.js} +117 -23
  129. package/dist/{pricing-refresh-m1yhh9ya.js → pricing-refresh-83drrnxj.js} +3 -3
  130. package/dist/{rbac-roles-svj72cdf.js → rbac-roles-0fb6e7pj.js} +2 -2
  131. package/dist/{rbac-roles-rrw8twdz.js → rbac-roles-s8y6ecb5.js} +1 -1
  132. package/dist/{render-v2-amwbnfxh.js → render-v2-twmbgrz4.js} +3 -3
  133. package/dist/{resume-9cd6wf6z.js → resume-zfqc36xj.js} +7 -7
  134. package/dist/{runtime-fp4vrx7m.js → runtime-qwggdpqr.js} +1 -1
  135. package/dist/{session-access-21t6yjyg.js → session-access-sc9pn9da.js} +4 -4
  136. package/dist/{session-source-backfill-pxbzcsc6.js → session-source-backfill-qh07xr8g.js} +3 -3
  137. package/dist/{setup-4592tqz4.js → setup-4fr0m7a1.js} +1 -1
  138. package/dist/{virtual-modules-gzfnpmnz.js → virtual-modules-fh4ha15b.js} +2 -2
  139. package/dist/{work-objects-t9tcf174.js → work-objects-0wr8zxct.js} +20 -20
  140. package/dist/worker-0c5tyzqe.js +69 -0
  141. package/dist/{worker-follow-up-q32jh4yk.js → worker-follow-up-ktyyazyp.js} +17 -17
  142. package/dist/{x-b6wxntdt.js → x-vmmye1kb.js} +2 -2
  143. package/openapi.json +97 -32
  144. package/package.json +1 -1
  145. package/src/be/db/session-summaries.ts +8 -2
  146. package/src/be/db/session-tree.ts +174 -61
  147. package/src/be/db/session-visibility.ts +5 -4
  148. package/src/be/db.ts +8 -4
  149. package/src/be/migrations/259_session_summaries_open_tasks.sql +111 -0
  150. package/src/be/model-validation.ts +5 -1
  151. package/src/be/pricing-normalize.ts +7 -2
  152. package/src/be/scripts/typecheck.ts +1 -0
  153. package/src/be/seed/schedules-seeder.ts +135 -9
  154. package/src/be/seed/workflows-seeder.ts +65 -1
  155. package/src/be/seed-pricing.ts +23 -0
  156. package/src/be/steering.ts +50 -3
  157. package/src/be/swarm-config-guard.ts +1 -0
  158. package/src/commands/provider-credentials.ts +29 -2
  159. package/src/commands/runner.ts +86 -5
  160. package/src/extensions/contract-types.generated.ts +1 -1
  161. package/src/extensions/contract.ts +27 -1
  162. package/src/extensions/dispatcher.ts +4 -0
  163. package/src/http/agents.ts +1 -0
  164. package/src/http/mcp-bridge.ts +4 -0
  165. package/src/integrations/chat-sdk/inbound.ts +15 -5
  166. package/src/integrations/chat-sdk/steering.ts +3 -1
  167. package/src/kv-reserved-namespaces.ts +2 -0
  168. package/src/prompts/base-prompt.ts +4 -1
  169. package/src/prompts/session-templates.ts +26 -0
  170. package/src/providers/acp-adapter.ts +190 -26
  171. package/src/providers/acp-targets.ts +306 -6
  172. package/src/providers/amp-adapter.ts +1 -1
  173. package/src/providers/codex-adapter.ts +9 -44
  174. package/src/providers/codex-oauth/flow.ts +24 -3
  175. package/src/providers/codex-oauth/storage.ts +56 -17
  176. package/src/providers/codex-oauth/subscription.ts +67 -0
  177. package/src/providers/fx-adapter.ts +211 -0
  178. package/src/providers/index.ts +5 -1
  179. package/src/providers/mcp-loopback-proxy.ts +94 -0
  180. package/src/providers/pi-mono-adapter.ts +98 -13
  181. package/src/providers/pricing-sources.md +8 -0
  182. package/src/providers/reasoning-effort.ts +17 -1
  183. package/src/providers/types.ts +2 -1
  184. package/src/scheduler/scheduler.ts +36 -3
  185. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  186. package/src/scripts-runtime/types/stdlib.d.ts +5 -0
  187. package/src/scripts-runtime/types/swarm-extension.d.ts +33 -0
  188. package/src/scripts-runtime/types/swarm-sdk.d.ts +5 -0
  189. package/src/server.ts +7 -0
  190. package/src/slack/assistant.ts +17 -1
  191. package/src/slack/handlers.ts +18 -5
  192. package/src/slack/left-threads.ts +65 -0
  193. package/src/slack/steering.ts +3 -0
  194. package/src/slack/thread-buffer.ts +40 -16
  195. package/src/tests/acp-adapter.test.ts +79 -0
  196. package/src/tests/base-prompt.test.ts +12 -0
  197. package/src/tests/chat-sdk-inbound.test.ts +30 -0
  198. package/src/tests/chatgpt-subscription-model-groups.test.ts +62 -0
  199. package/src/tests/chatgpt-subscription-pi.test.ts +170 -0
  200. package/src/tests/chatgpt-subscription-session-bench.test.ts +198 -0
  201. package/src/tests/codex-oauth-credential-info.test.ts +40 -0
  202. package/src/tests/codex-oauth-refresh-egress.test.ts +194 -0
  203. package/src/tests/context-window.test.ts +7 -0
  204. package/src/tests/explicit-model-validation.test.ts +99 -0
  205. package/src/tests/extensions-run-context.test.ts +11 -0
  206. package/src/tests/extensions-steering.test.ts +353 -0
  207. package/src/tests/extensions-validate.test.ts +5 -0
  208. package/src/tests/fixtures/extensions/steering-policy/hooks.ts +39 -0
  209. package/src/tests/fixtures/extensions/steering-policy/manifest.json +7 -0
  210. package/src/tests/fixtures/fake-fx.ts +167 -0
  211. package/src/tests/fixtures/fx/acp-responses-recorded.json +157 -0
  212. package/src/tests/fixtures/fx/acp-updates-recorded.jsonl +15 -0
  213. package/src/tests/fx-adapter.test.ts +771 -0
  214. package/src/tests/grok-adapter.test.ts +36 -0
  215. package/src/tests/mcp-loopback-proxy.test.ts +193 -0
  216. package/src/tests/model-tier-resolution.test.ts +3 -3
  217. package/src/tests/pricing-refresh.test.ts +5 -5
  218. package/src/tests/prompt-template-session.test.ts +5 -3
  219. package/src/tests/provider-registration.test.ts +2 -1
  220. package/src/tests/query-audit-plans.test.ts +3 -2
  221. package/src/tests/script-connections.test.ts +2 -0
  222. package/src/tests/seed-model-profiles.test.ts +2 -2
  223. package/src/tests/seed-schedules.test.ts +138 -0
  224. package/src/tests/session-cost-recompute-unit.test.ts +39 -0
  225. package/src/tests/session-costs-golden.test.ts +26 -0
  226. package/src/tests/session-summaries-plans.test.ts +120 -0
  227. package/src/tests/session-summaries.test.ts +72 -12
  228. package/src/tests/sessions-tree.test.ts +11 -3
  229. package/src/tests/slack-leave-thread.test.ts +431 -0
  230. package/src/tests/tool-annotations.test.ts +2 -1
  231. package/src/tests/workflow-engine-v2.test.ts +42 -0
  232. package/src/tests/workflow-script-principal.test.ts +207 -7
  233. package/src/tools/slack-leave-thread.ts +104 -0
  234. package/src/tools/steer-task.ts +3 -2
  235. package/src/tools/tool-config.ts +2 -1
  236. package/src/types.ts +19 -0
  237. package/src/utils/codex-auth-failure.ts +30 -0
  238. package/src/utils/context-window.ts +4 -1
  239. package/src/utils/credentials.ts +7 -0
  240. package/src/utils/fx-models.ts +43 -0
  241. package/src/workflows/cooldown.ts +3 -2
  242. package/src/workflows/engine.ts +24 -9
  243. package/templates/schedules/dreaming-daily/config.json +1 -1
  244. package/templates/schedules/dreaming-daily/content.md +6 -14
  245. package/templates/skills/swarm-docs/content.md +1 -0
  246. package/templates/workflows/dreaming/content.md +5 -1
  247. package/dist/acp-adapter-96dc64m2.js +0 -15
  248. package/dist/handlers-jg7v74ty.js +0 -31
  249. package/dist/index-8c1zbz59.js +0 -29
  250. package/dist/index-8k8tmfxd.js +0 -32
  251. package/dist/index-hq617xdj.js +0 -36
  252. package/dist/lead-567kw7x3.js +0 -68
  253. package/dist/worker-fjdqra6q.js +0 -68
@@ -13,14 +13,19 @@ import {
13
13
  createAgent,
14
14
  createScheduledTask,
15
15
  createWorkflow,
16
+ createWorkflowRun,
16
17
  getDbClient,
18
+ getScheduledTaskByName,
19
+ getWorkflowRun,
17
20
  initDb,
18
21
  listWorkflows,
22
+ updateWorkflow,
19
23
  } from "../be/db";
20
24
  import { upsertScriptByName } from "../be/scripts/db";
21
25
  import { setScriptEmbeddingProviderForTests } from "../be/scripts/embeddings";
22
26
  import * as runCredential from "../be/scripts/run-credential";
23
27
  import { runSeeder } from "../be/seed";
28
+ import { createSchedulesSeeder } from "../be/seed/schedules-seeder";
24
29
  import { createWorkflowsSeeder } from "../be/seed/workflows-seeder";
25
30
  import { getPathSegments, parseQueryParams } from "../http/utils";
26
31
  import { handleWorkflows } from "../http/workflows";
@@ -34,6 +39,7 @@ import {
34
39
  workflowEventBus,
35
40
  } from "../workflows";
36
41
  import { handleEventTrigger } from "../workflows/triggers";
42
+ import { snapshotAndUpdateWorkflow } from "../workflows/version";
37
43
  import { listenOnFreePort } from "./test-net";
38
44
 
39
45
  // A creator-less workflow (the seeded `dreaming`) runs its `swarm-script` nodes as
@@ -160,9 +166,31 @@ afterAll(async () => {
160
166
  await removeDbFiles();
161
167
  });
162
168
 
163
- beforeEach(() => {
164
- // Dreaming has no cooldown, so earlier runs (parked on an agent-task) do not
165
- // block the next trigger.
169
+ /** Put the seeded dreaming row back after a test edits it. */
170
+ async function restoreDreaming(): Promise<void> {
171
+ await updateWorkflow(dreaming.id, {
172
+ description: dreaming.description,
173
+ definition: dreaming.definition,
174
+ input: dreaming.input,
175
+ cooldown: dreaming.cooldown,
176
+ enabled: dreaming.enabled,
177
+ });
178
+ }
179
+
180
+ /** Move every dreaming run start out of its 20-hour cooldown window. */
181
+ async function ageDreamingRuns(): Promise<void> {
182
+ await getDbClient().run(
183
+ `UPDATE workflow_runs SET startedAt = '2000-01-01T00:00:00.000Z',
184
+ finishedAt = CASE WHEN finishedAt IS NULL THEN NULL ELSE '2000-01-01T00:00:00.000Z' END
185
+ WHERE workflowId = ?`,
186
+ [dreaming.id],
187
+ );
188
+ }
189
+
190
+ beforeEach(async () => {
191
+ // Dreaming has a 20-hour cooldown. Age earlier runs (parked on an agent-task)
192
+ // so they do not block the next trigger.
193
+ await ageDreamingRuns();
166
194
  calls = [];
167
195
  scriptResult = QUIET_GATHER;
168
196
  });
@@ -241,6 +269,179 @@ describe("swarm-script principal for a creator-less workflow", () => {
241
269
  expect(gatherPrincipals()).toEqual([leadId]);
242
270
  });
243
271
 
272
+ test("a creator-less schedule runs as the lead, resolved at fire time", async () => {
273
+ const schedule = await createScheduledTask({
274
+ name: `principal-seeded-${crypto.randomUUID().slice(0, 8)}`,
275
+ cronExpression: "0 0 1 1 *",
276
+ targetType: "workflow",
277
+ workflowId: dreaming.id,
278
+ targetAgentId: workerId,
279
+ });
280
+ expect(schedule.createdByAgentId).toBeUndefined();
281
+ await dispatchScheduleTarget(schedule);
282
+ expect(gatherPrincipals()).toEqual([leadId]);
283
+ });
284
+
285
+ test("a creator-less schedule fails its fire when the swarm has no lead", async () => {
286
+ const schedule = await createScheduledTask({
287
+ name: `principal-leadless-${crypto.randomUUID().slice(0, 8)}`,
288
+ cronExpression: "0 0 1 1 *",
289
+ targetType: "workflow",
290
+ workflowId: dreaming.id,
291
+ });
292
+ await getDbClient().run("UPDATE agents SET isLead = 0 WHERE id = ?", [leadId]);
293
+ try {
294
+ await expect(dispatchScheduleTarget(schedule)).rejects.toThrow(
295
+ "has no creator and the swarm has no lead",
296
+ );
297
+ } finally {
298
+ await getDbClient().run("UPDATE agents SET isLead = 1 WHERE id = ?", [leadId]);
299
+ }
300
+ expect(calls).toEqual([]);
301
+ const runs = await getDbClient().query<{ id: string }>(
302
+ "SELECT id FROM workflow_runs WHERE workflowId = ? AND startedAt > '2001-01-01'",
303
+ [dreaming.id],
304
+ );
305
+ expect(runs).toEqual([]);
306
+ });
307
+
308
+ test("a creator-less schedule keeps the lead after the documented input setup and enable", async () => {
309
+ // The guide's setup: update-workflow with every input key, then turn it on.
310
+ // Both snapshot the seeded state first, as the tool and REST paths do.
311
+ await snapshotAndUpdateWorkflow(
312
+ dreaming.id,
313
+ { input: { ...dreaming.input, leadAgentId: leadId, slackChannel: "C123" } },
314
+ { changedByAgentId: workerId },
315
+ );
316
+ await snapshotAndUpdateWorkflow(dreaming.id, { enabled: false });
317
+ await snapshotAndUpdateWorkflow(dreaming.id, {
318
+ enabled: true,
319
+ description: "tuned by the operator",
320
+ cooldown: { hours: 22, from: "start" },
321
+ });
322
+ const schedule = await createScheduledTask({
323
+ name: `principal-tuned-${crypto.randomUUID().slice(0, 8)}`,
324
+ cronExpression: "0 0 1 1 *",
325
+ targetType: "workflow",
326
+ workflowId: dreaming.id,
327
+ });
328
+ try {
329
+ await dispatchScheduleTarget(schedule);
330
+ expect(gatherPrincipals()).toEqual([leadId]);
331
+ expect(calls[0]?.args.leadAgentId).toBe(leadId);
332
+ } finally {
333
+ await restoreDreaming();
334
+ }
335
+ });
336
+
337
+ test("a creator-less schedule refuses the lead once a worker edits the seeded nodes", async () => {
338
+ const schedule = await createScheduledTask({
339
+ name: `principal-edited-${crypto.randomUUID().slice(0, 8)}`,
340
+ cronExpression: "0 0 1 1 *",
341
+ targetType: "workflow",
342
+ workflowId: dreaming.id,
343
+ });
344
+ const [gather, ...rest] = dreaming.definition.nodes;
345
+ await snapshotAndUpdateWorkflow(
346
+ dreaming.id,
347
+ {
348
+ input: { ...dreaming.input, leadAgentId: leadId },
349
+ definition: {
350
+ ...dreaming.definition,
351
+ nodes: [{ ...gather!, config: { ...gather!.config, scriptName: "evil" } }, ...rest],
352
+ },
353
+ },
354
+ { changedByAgentId: workerId },
355
+ );
356
+ try {
357
+ await expect(dispatchScheduleTarget(schedule)).rejects.toThrow(
358
+ 'the nodes or params of seeded workflow "dreaming" were edited',
359
+ );
360
+ } finally {
361
+ await restoreDreaming();
362
+ }
363
+ expect(calls).toEqual([]);
364
+ });
365
+
366
+ test("a creator-less schedule refuses the lead for an edit with no seeded snapshot", async () => {
367
+ const schedule = await createScheduledTask({
368
+ name: `principal-unsnapshotted-${crypto.randomUUID().slice(0, 8)}`,
369
+ cronExpression: "0 0 1 1 *",
370
+ targetType: "workflow",
371
+ workflowId: dreaming.id,
372
+ });
373
+ // Delete the history so no snapshot proves the seeded state.
374
+ await getDbClient().run("DELETE FROM workflow_versions WHERE workflowId = ?", [dreaming.id]);
375
+ await updateWorkflow(dreaming.id, { description: "edited without a snapshot" });
376
+ try {
377
+ await expect(dispatchScheduleTarget(schedule)).rejects.toThrow("were edited");
378
+ } finally {
379
+ await restoreDreaming();
380
+ }
381
+ expect(calls).toEqual([]);
382
+ });
383
+
384
+ test("a long successful night does not make the next nightly fire skip", async () => {
385
+ const schedule = await createScheduledTask({
386
+ name: `principal-long-${crypto.randomUUID().slice(0, 8)}`,
387
+ cronExpression: "0 0 1 1 *",
388
+ targetType: "workflow",
389
+ workflowId: dreaming.id,
390
+ });
391
+ // Last night started 24h ago and finished 1h ago.
392
+ const runId = crypto.randomUUID();
393
+ await createWorkflowRun({ id: runId, workflowId: dreaming.id });
394
+ await getDbClient().run(
395
+ "UPDATE workflow_runs SET status = 'completed', startedAt = ?, finishedAt = ? WHERE id = ?",
396
+ [
397
+ new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
398
+ new Date(Date.now() - 60 * 60 * 1000).toISOString(),
399
+ runId,
400
+ ],
401
+ );
402
+ const fired = await dispatchScheduleTarget(schedule);
403
+ const run = await getWorkflowRun(fired.workflowRunIds![0]!);
404
+ expect(run?.status).not.toBe("skipped");
405
+ expect(gatherPrincipals()).toEqual([leadId]);
406
+ });
407
+
408
+ test("control: a creator-less workflow the seeder never wrote keeps running agentless", async () => {
409
+ const copy = await createWorkflow({
410
+ name: `principal-unseeded-${crypto.randomUUID()}`,
411
+ definition: dreaming.definition,
412
+ });
413
+ const schedule = await createScheduledTask({
414
+ name: `principal-unseeded-${crypto.randomUUID().slice(0, 8)}`,
415
+ cronExpression: "0 0 1 1 *",
416
+ targetType: "workflow",
417
+ workflowId: copy.id,
418
+ });
419
+ await dispatchScheduleTarget(schedule);
420
+ expect(gatherPrincipals()).toEqual(["workflow"]);
421
+ });
422
+
423
+ test("end to end: the seeded dreaming-daily schedule fires dreaming as the lead, once a day", async () => {
424
+ const seeded = await runSeeder(createSchedulesSeeder(), { quiet: true });
425
+ expect(seeded.failed).toEqual([]);
426
+ const schedule = await getScheduledTaskByName("dreaming-daily");
427
+ expect(schedule).toMatchObject({ targetType: "workflow", workflowId: dreaming.id });
428
+ expect(schedule?.taskTemplate).toBeUndefined();
429
+ expect(schedule?.createdByAgentId).toBeUndefined();
430
+
431
+ const first = await dispatchScheduleTarget(schedule!);
432
+ expect(first.task).toBeUndefined();
433
+ expect(first.workflowRunIds).toHaveLength(1);
434
+ expect(gatherPrincipals()).toEqual([leadId]);
435
+ const run = await getWorkflowRun(first.workflowRunIds![0]!);
436
+ expect(run?.triggerData).toMatchObject({ scheduleName: "dreaming-daily" });
437
+
438
+ // The cooldown replaces the old task prompt's 20-hour double-run guard.
439
+ const second = await dispatchScheduleTarget(schedule!);
440
+ const skipped = await getWorkflowRun(second.workflowRunIds![0]!);
441
+ expect(skipped).toMatchObject({ status: "skipped", error: "cooldown" });
442
+ expect(gatherPrincipals()).toEqual([leadId]);
443
+ });
444
+
244
445
  test("control: a workflow with a creator still runs as its creator", async () => {
245
446
  const owned = await createWorkflow({
246
447
  name: `principal-owned-${crypto.randomUUID()}`,
@@ -251,10 +452,9 @@ describe("swarm-script principal for a creator-less workflow", () => {
251
452
  expect(gatherPrincipals()).toEqual([leadId]);
252
453
  });
253
454
 
254
- test("control: the seeded schedule's fire (a worker triggers with no data) runs the *-lead nodes on the lead", async () => {
255
- // dreaming-daily makes a pool task; the worker that claims it calls
256
- // trigger-workflow with no triggerData. Scripts run as that worker, while
257
- // synthesize and apply-lead target gather's leadAgentId.
455
+ test("control: a worker's manual trigger with no data runs the *-lead nodes on the lead", async () => {
456
+ // A worker that calls trigger-workflow with no triggerData. Scripts run as
457
+ // that worker, while synthesize and apply-lead target gather's leadAgentId.
258
458
  scriptResult = {
259
459
  ok: true,
260
460
  agents: [{ id: workerId, name: "principal-worker" }],
@@ -0,0 +1,104 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { getAgentById, getTaskById } from "@/be/db";
4
+ import { can } from "@/rbac";
5
+ import {
6
+ isSlackLeaveThreadDisabled,
7
+ leaveSlackThread,
8
+ SLACK_LEAVE_THREAD_DISABLE,
9
+ } from "@/slack/left-threads";
10
+ import { parseSlackTs } from "@/slack/message-text";
11
+ import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "@/tools/utils";
12
+
13
+ export const registerSlackLeaveThreadTool = (server: McpServer) => {
14
+ createToolRegistrar(server)(
15
+ "slack-leave-thread",
16
+ {
17
+ title: "Leave a Slack thread",
18
+ description:
19
+ "Stop following a Slack thread until someone @mentions the bot there again. Messages in the thread that do not @mention the bot are then ignored, with no task and no reply. Posts nothing to Slack. Pass taskId for the thread of your Slack-originated task, or (lead only) channelId and threadTs.",
20
+ annotations: { openWorldHint: true, idempotentHint: true },
21
+
22
+ inputSchema: z.object({
23
+ taskId: z.uuid().optional().describe("A task whose Slack thread to leave."),
24
+ channelId: z.string().min(1).optional().describe("The Slack channel ID of the thread."),
25
+ threadTs: z
26
+ .string()
27
+ .min(1)
28
+ .optional()
29
+ .describe("Timestamp of the thread root (dotted, 'p' deep-link, or full permalink URL)."),
30
+ }),
31
+ outputSchema: swarmToolOutputSchema({
32
+ channelId: z.string().optional(),
33
+ threadTs: z.string().optional(),
34
+ }),
35
+ },
36
+ async ({ taskId, channelId, threadTs }, requestInfo, _meta) => {
37
+ // Sessions created before the flag was set still list the tool.
38
+ if (isSlackLeaveThreadDisabled()) {
39
+ return toolErr(`slack-leave-thread is disabled by ${SLACK_LEAVE_THREAD_DISABLE}.`);
40
+ }
41
+
42
+ if (!requestInfo.agentId) {
43
+ return toolErr("Agent ID not found.");
44
+ }
45
+
46
+ const agent = await getAgentById(requestInfo.agentId);
47
+ if (!agent) {
48
+ return toolErr("Agent not found.");
49
+ }
50
+
51
+ // Any thread is open to leads, like slack-post. Task Slack context and
52
+ // source can be set by any agent (send-task, POST /api/tasks), so a non-lead
53
+ // may only leave the thread of a task assigned to it that Slack ingress
54
+ // created: only the Slack handlers write slackTriggerMessageTs.
55
+ const mayLeaveAnyThread = can({
56
+ principal: { kind: "agent", agentId: agent.id, isLead: agent.isLead },
57
+ verb: "integration.slack.post",
58
+ resource: { kind: "none" },
59
+ source: "mcp",
60
+ }).allow;
61
+
62
+ let slackChannelId: string | undefined;
63
+ let slackThreadTs: string | undefined;
64
+
65
+ if (taskId) {
66
+ if (channelId || threadTs) {
67
+ return toolErr("Provide either taskId or channelId and threadTs, not both.");
68
+ }
69
+ const task = await getTaskById(taskId);
70
+ if (!task) {
71
+ return toolErr("Task not found.");
72
+ }
73
+ if (
74
+ !mayLeaveAnyThread &&
75
+ (task.agentId !== agent.id || task.source !== "slack" || !task.slackTriggerMessageTs)
76
+ ) {
77
+ return toolErr(
78
+ "Only a lead or the agent assigned a Slack-originated task can leave its thread.",
79
+ );
80
+ }
81
+ slackChannelId = task.slackChannelId;
82
+ slackThreadTs = task.slackThreadTs;
83
+ } else if (channelId && threadTs) {
84
+ if (!mayLeaveAnyThread) {
85
+ return toolErr("Leaving a thread by channelId and threadTs requires lead privileges.");
86
+ }
87
+ slackChannelId = channelId;
88
+ slackThreadTs = parseSlackTs(threadTs);
89
+ } else {
90
+ return toolErr("Must provide taskId, or both channelId and threadTs.");
91
+ }
92
+
93
+ if (!slackChannelId || !slackThreadTs) {
94
+ return toolErr("No Slack thread context available.");
95
+ }
96
+
97
+ await leaveSlackThread(slackChannelId, slackThreadTs, agent.id);
98
+ return toolOk(
99
+ `Left Slack thread ${slackChannelId}/${slackThreadTs}. Messages there are ignored until someone @mentions the bot.`,
100
+ { data: { channelId: slackChannelId, threadTs: slackThreadTs } },
101
+ );
102
+ },
103
+ );
104
+ };
@@ -99,7 +99,8 @@ export async function steerTaskHandler(
99
99
  message,
100
100
  mode,
101
101
  onUnsupported,
102
- source: "mcp",
102
+ source:
103
+ ctx.kind === "owner" && ctx.requestInfo?.callOrigin === "script-sdk" ? "script" : "mcp",
103
104
  createdByKind: ctx.kind === "owner" ? "agent" : "user",
104
105
  createdByAgentId: ctx.kind === "owner" ? ctx.agentId : undefined,
105
106
  createdByUserId: ctx.kind === "user" ? ctx.userId : undefined,
@@ -126,7 +127,7 @@ export const registerSteerTaskTool = (server: McpServer) => {
126
127
  {
127
128
  title: "Steer Task",
128
129
  description:
129
- 'Send a message to a task that is already running. `mode:"steer"` is honored on pi, claude-managed, codex and cursor; claude, devin, opencode and amp support queue only (amp delivery lands at the next tool result). Pass `onUnsupported:"fail"` to get an error instead of a downgrade.',
130
+ 'Send a message to a task that is already running. `mode:"steer"` is honored on pi, claude-managed, codex, cursor and fx (fx supports steer only); claude, devin, opencode and amp support queue only (amp delivery lands at the next tool result). Pass `onUnsupported:"fail"` to get an error instead of a downgrade.',
130
131
  annotations: { destructiveHint: true },
131
132
  inputSchema: steerTaskInputSchema,
132
133
  outputSchema: steerTaskOutputSchema,
@@ -96,7 +96,7 @@ export const DEFERRED_TOOLS = new Set([
96
96
  "read-messages",
97
97
  "post-message",
98
98
 
99
- // Slack (12): task-context gated; keep out of CORE_TOOLS
99
+ // Slack (13): task-context gated; keep out of CORE_TOOLS
100
100
  "slack-reply",
101
101
  "slack-read",
102
102
  "slack-upload-file",
@@ -109,6 +109,7 @@ export const DEFERRED_TOOLS = new Set([
109
109
  "slack-archive-channel",
110
110
  "slack-delete",
111
111
  "slack-update",
112
+ "slack-leave-thread",
112
113
 
113
114
  // Channel management (2)
114
115
  "create-channel",
package/src/types.ts CHANGED
@@ -185,6 +185,14 @@ export const DEFAULT_MODEL_TIER_MAP: Record<ProviderName, Record<ModelTier, stri
185
185
  smart: "grok-4.6",
186
186
  ultra: "grok-4.7",
187
187
  },
188
+ // fx runs these on its `openrouter` custom connection (OPENROUTER_API_KEY),
189
+ // pi's tested OpenRouter set; each ran under fx 0.0.15 (2026-10-29 probe).
190
+ fx: {
191
+ smol: "openrouter/deepseek/deepseek-v4.1-flash",
192
+ regular: "openrouter/deepseek/deepseek-v4.1-flash",
193
+ smart: "openrouter/deepseek/deepseek-v4-pro-0813",
194
+ ultra: "openrouter/anthropic/claude-opus-5.5",
195
+ },
188
196
  // ACP has no portable tier-to-model mapping. Operators may set an explicit
189
197
  // MODEL_OVERRIDE, which the adapter applies through an advertised `model`
190
198
  // config option with a target-specific startup fallback.
@@ -436,6 +444,7 @@ export const ProviderNameSchema = z.enum([
436
444
  "amp",
437
445
  "cursor",
438
446
  "grok",
447
+ "fx",
439
448
  ]);
440
449
  export type ProviderName = z.infer<typeof ProviderNameSchema>;
441
450
 
@@ -596,6 +605,10 @@ export const PROVIDER_STEER_CAPABILITIES: Record<ProviderName, SteerMode[]> = {
596
605
  // Grok runs on the ACP client: one `session/prompt` turn, interrupted only
597
606
  // by `session/cancel`. No Grok surface (ACP or headless) exposes a steer.
598
607
  grok: [],
608
+ // `fx acp` steers natively: a `session/prompt` with `_meta.fx.steer` during a
609
+ // running turn lands at the next tool boundary without cancelling it (fx
610
+ // 0.0.15 probe, 2026-10-29). Queue is not measured, so it is not advertised.
611
+ fx: ["steer"],
599
612
  };
600
613
 
601
614
  export type DevinProviderMeta = {
@@ -619,6 +632,7 @@ export type ProviderMetaMap = {
619
632
  amp: NoProviderMeta;
620
633
  cursor: NoProviderMeta;
621
634
  grok: NoProviderMeta;
635
+ fx: NoProviderMeta;
622
636
  };
623
637
 
624
638
  export const FollowUpConfigSchema = z
@@ -2606,6 +2620,10 @@ export const CooldownConfigSchema = z
2606
2620
  hours: z.number().min(0).optional(),
2607
2621
  minutes: z.number().min(0).optional(),
2608
2622
  seconds: z.number().min(0).optional(),
2623
+ // "finish" (default): also wait out the window after the last successful
2624
+ // run finished. "start": count only from run starts, so a long run never
2625
+ // pushes the next fixed-time trigger out.
2626
+ from: z.enum(["finish", "start"]).optional(),
2609
2627
  })
2610
2628
  .refine((v) => v.hours !== undefined || v.minutes !== undefined || v.seconds !== undefined, {
2611
2629
  message: "At least one of hours, minutes, or seconds is required",
@@ -3838,6 +3856,7 @@ export const PricingProviderSchema = z.enum([
3838
3856
  "amp",
3839
3857
  "cursor",
3840
3858
  "grok",
3859
+ "fx",
3841
3860
  ]);
3842
3861
  export type PricingProvider = z.infer<typeof PricingProviderSchema>;
3843
3862
 
@@ -32,3 +32,33 @@ export function isCodexAuthFailureReason(failureReason: string | undefined | nul
32
32
  const lower = failureReason.toLowerCase();
33
33
  return !TRANSIENT_AUTH_ERROR_MARKERS.some((m) => lower.includes(m));
34
34
  }
35
+
36
+ /**
37
+ * Turn-level texts of a rejected ChatGPT/Codex credential: a bad API key, or
38
+ * a pool login revoked while its access token is unexpired. That death mode
39
+ * never enters `getValidCodexOAuth` (the token looks fresh), so this error is
40
+ * its only artifact. The adapters tag a match `[auth-error]`.
41
+ */
42
+ const REJECTED_ACCESS_PATTERNS = [
43
+ "unauthorized",
44
+ "http 401",
45
+ " 401 ",
46
+ "invalid api key",
47
+ "invalid_api_key",
48
+ "missing api key",
49
+ "no api key",
50
+ "authentication failed",
51
+ "failed to refresh token",
52
+ "invalid 'refresh_token'",
53
+ // ChatGPT backend 401 bodies (pi on the subscription surfaces them as-is).
54
+ "token_invalidated",
55
+ "authentication token has been invalidated",
56
+ "authentication token is expired",
57
+ "refresh token was revoked",
58
+ "could not parse your authentication token",
59
+ ];
60
+
61
+ export function isCodexRejectedAccessError(text: string): boolean {
62
+ const lower = text.toLowerCase();
63
+ return REJECTED_ACCESS_PATTERNS.some((p) => lower.includes(p));
64
+ }
@@ -20,7 +20,7 @@
20
20
  * deprecated; new code should use `computeContextUsedUnified`.
21
21
  */
22
22
 
23
- import { buildClaudeShortnameMap } from "@desplega/model-catalog";
23
+ import { buildClaudeShortnameMap, chatgptSubscriptionModelId } from "@desplega/model-catalog";
24
24
  import { runtimeCatalogModel, runtimeCatalogSection } from "./runtime-model-catalog";
25
25
 
26
26
  /**
@@ -73,6 +73,9 @@ export function getContextWindowSize(model: string): number {
73
73
  /** {@link getContextWindowSize} without the fallback: undefined when the catalog has no window. */
74
74
  export function getKnownContextWindowSize(model: string | undefined): number | undefined {
75
75
  if (!model) return undefined;
76
+ // `openai-codex/<id>` (pi on the ChatGPT subscription) is the OpenAI `<id>`.
77
+ const subscriptionId = chatgptSubscriptionModelId(model);
78
+ if (subscriptionId) return catalogContext("openai", subscriptionId);
76
79
  const slash = model.indexOf("/");
77
80
  if (slash > 0) {
78
81
  const scoped = catalogContext(model.slice(0, slash), model.slice(slash + 1));
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import {
3
+ fxModelRoute,
3
4
  openAICompatibleProviders,
4
5
  providerForKeyVar,
5
6
  providerForModel,
@@ -61,6 +62,7 @@ export const PROVIDER_CREDENTIAL_VARS: Record<string, readonly string[]> = {
61
62
  amp: ["AMP_API_KEY"],
62
63
  cursor: ["CURSOR_API_KEY"],
63
64
  grok: ["XAI_API_KEY", "OPENROUTER_API_KEY"],
65
+ fx: ["OPENROUTER_API_KEY", "OPENAI_API_KEY", "AI_GATEWAY_API_KEY"],
64
66
  opencode: ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
65
67
  };
66
68
 
@@ -100,6 +102,11 @@ export function getModelAwareCredentialVars(provider: string, model?: string): r
100
102
  if (provider === "grok" && model) {
101
103
  return model.startsWith("openrouter/") ? ["OPENROUTER_API_KEY"] : ["XAI_API_KEY"];
102
104
  }
105
+ // fx routes by namespace (`src/utils/fx-models.ts`); each route reads one key.
106
+ if (provider === "fx" && model) {
107
+ const route = fxModelRoute(model);
108
+ return "error" in route ? base : [route.keyVar];
109
+ }
103
110
  if (!SLASH_MODEL_PROVIDERS.has(provider) || !model) return base;
104
111
  // OpenAI-compatible registry providers own their key: `nan/<id>` and
105
112
  // `helmcode/<id>` never pool an OpenRouter or Anthropic key.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Model routing for the `fx` harness. Pure, so the API server can validate a
3
+ * model and the worker can route it without importing the adapter.
4
+ *
5
+ * | Swarm model string | fx provider (`FX_PROVIDER`) | `fx acp --model` | Key |
6
+ * |------------------------------|-----------------------------------|------------------|----------------------|
7
+ * | `openrouter/<vendor>/<id>` | `openrouter` (custom connection) | `<vendor>/<id>` | `OPENROUTER_API_KEY` |
8
+ * | `vercel/<vendor>/<id>` | `gateway` (built in) | `<vendor>/<id>` | `AI_GATEWAY_API_KEY` |
9
+ * | bare `<id>` (no slash) | `openai` (custom connection) | `<id>` | `OPENAI_API_KEY` |
10
+ *
11
+ * Anything else (`nan/…`, `helmcode/…`, `anthropic/…`) is rejected with a reason.
12
+ */
13
+ import { type FxRoute, fxModelRoute } from "@desplega/model-catalog";
14
+ import { DEFAULT_MODEL_TIER_MAP } from "../types";
15
+
16
+ export type { FxRoute };
17
+
18
+ /** An fx route plus the swarm model string it came from. */
19
+ export type ResolvedFxRoute = FxRoute & { model: string };
20
+
21
+ /** Every key an fx route can read, in route-table order. */
22
+ export const FX_ROUTE_KEY_VARS = [
23
+ "OPENROUTER_API_KEY",
24
+ "OPENAI_API_KEY",
25
+ "AI_GATEWAY_API_KEY",
26
+ ] as const;
27
+
28
+ /**
29
+ * The fx route for a swarm model string, or the reason it has none. An empty
30
+ * model means the regular tier default.
31
+ */
32
+ export function resolveFxRoute(model: string | undefined): ResolvedFxRoute | { error: string } {
33
+ const value = model?.trim() || DEFAULT_MODEL_TIER_MAP.fx.regular;
34
+ const route = fxModelRoute(value);
35
+ return "error" in route ? route : { ...route, model: value };
36
+ }
37
+
38
+ /** {@link resolveFxRoute}, throwing the rejection reason. */
39
+ export function requireFxRoute(model: string | undefined): ResolvedFxRoute {
40
+ const route = resolveFxRoute(model);
41
+ if ("error" in route) throw new Error(route.error);
42
+ return route;
43
+ }
@@ -15,7 +15,8 @@ function cooldownToMs(cooldown: CooldownConfig): number {
15
15
  /**
16
16
  * Check if a workflow should be skipped due to cooldown.
17
17
  * Returns true if:
18
- * - the last successful run finished within the cooldown window, OR
18
+ * - the last successful run finished within the cooldown window (skipped for
19
+ * `from: "start"`), OR
19
20
  * - any run (including failed ones) started within the cooldown window.
20
21
  *
21
22
  * The second condition prevents runaway re-triggering when all runs fail
@@ -30,7 +31,7 @@ export async function shouldSkipCooldown(
30
31
  const now = Date.now();
31
32
 
32
33
  // Skip if last successful run finished within the cooldown window
33
- const lastSuccess = await getLastSuccessfulRun(workflowId);
34
+ const lastSuccess = cooldown.from === "start" ? null : await getLastSuccessfulRun(workflowId);
34
35
  if (lastSuccess?.finishedAt) {
35
36
  const lastFinished = new Date(lastSuccess.finishedAt).getTime();
36
37
  if (now - lastFinished < cooldownMs) return true;
@@ -192,7 +192,7 @@ export async function startWorkflowExecution(
192
192
  const newRunId = crypto.randomUUID();
193
193
  const release = holdWorkflowRun(newRunId);
194
194
  try {
195
- const { runId, created, preflightAlert } = await createRunWithInitialState(
195
+ const { runId, created, preflightAlert, status } = await createRunWithInitialState(
196
196
  workflow,
197
197
  triggerData,
198
198
  options,
@@ -204,7 +204,7 @@ export async function startWorkflowExecution(
204
204
  if (preflightAlert) await notifyAutomationPreflightFailure(preflight);
205
205
  return runId;
206
206
  }
207
- if (initial.status === "skipped") return runId;
207
+ if (status === "skipped") return runId;
208
208
  telemetry.workflow("started", {
209
209
  workflowId: workflow.id,
210
210
  nodeCount: workflow.definition.nodes.length,
@@ -317,7 +317,12 @@ async function createRunWithInitialState(
317
317
  options: WorkflowExecutionOptions,
318
318
  initial: InitialRunState,
319
319
  runId: string,
320
- ): Promise<{ runId: string; created: boolean; preflightAlert?: boolean }> {
320
+ ): Promise<{
321
+ runId: string;
322
+ created: boolean;
323
+ preflightAlert?: boolean;
324
+ status?: InitialRunState["status"];
325
+ }> {
321
326
  return getDbClient().transaction(async () => {
322
327
  const { parentStepId } = options;
323
328
  const attempt = options.parentStepAttempt ?? 0;
@@ -328,6 +333,16 @@ async function createRunWithInitialState(
328
333
  const existingId = await getChildWorkflowRunId(parentStepId, attempt);
329
334
  if (existingId) return { runId: existingId, created: false };
330
335
  }
336
+ // The cooldown check before this transaction is only a fast path: two
337
+ // triggers can both pass it before either run exists. The write lock is
338
+ // held from BEGIN, across processes too, so this recheck sees every run
339
+ // committed before it and the losing trigger records a skipped run.
340
+ const state: InitialRunState =
341
+ !initial.status &&
342
+ workflow.cooldown &&
343
+ (await shouldSkipCooldown(workflow.id, workflow.cooldown))
344
+ ? { status: "skipped", error: "cooldown", finishedAt: new Date().toISOString() }
345
+ : initial;
331
346
  await createWorkflowRun({
332
347
  id: runId,
333
348
  workflowId: workflow.id,
@@ -340,16 +355,16 @@ async function createRunWithInitialState(
340
355
  });
341
356
  await updateWorkflowRun(
342
357
  runId,
343
- initial.status
344
- ? { status: initial.status, error: initial.error, finishedAt: initial.finishedAt }
345
- : { context: initial.context },
358
+ state.status
359
+ ? { status: state.status, error: state.error, finishedAt: state.finishedAt }
360
+ : { context: state.context },
346
361
  );
347
362
  const preflightAlert =
348
- initial.status === "failed" && initial.preflight
363
+ state.status === "failed" && state.preflight
349
364
  ? await isFirstWorkflowPreflightFailureToday(workflow.id, runId)
350
365
  : undefined;
351
- await options.accept?.({ runId, skipped: initial.status === "skipped" });
352
- return { runId, created: true, preflightAlert };
366
+ await options.accept?.({ runId, skipped: state.status === "skipped" });
367
+ return { runId, created: true, preflightAlert, status: state.status };
353
368
  });
354
369
  }
355
370