@desplega.ai/agent-swarm 2.4.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 (222) hide show
  1. package/README.md +1 -1
  2. package/dist/{acp-adapter-kedstxx6.js → acp-adapter-zvzr538d.js} +9 -9
  3. package/dist/{actions-vget6r1k.js → actions-jhtqdw69.js} +21 -21
  4. package/dist/{amp-adapter-xeb5mktv.js → amp-adapter-bd84rhs2.js} +4 -4
  5. package/dist/{app-mqap8t8m.js → app-kreq1gwg.js} +3 -3
  6. package/dist/{assistant-h0gv97mv.js → assistant-3k22sbsq.js} +22 -22
  7. package/dist/{boot-reembed-qr8a894z.js → boot-reembed-e53x6b9v.js} +1 -1
  8. package/dist/{boot-reembed-rj85jjhw.js → boot-reembed-f6z923ny.js} +1 -1
  9. package/dist/{boot-scrub-sweep-eyfvn6v9.js → boot-scrub-sweep-0538vsb7.js} +4 -4
  10. package/dist/{boot-seeds-xs9a8dx1.js → boot-seeds-6wxqkfk2.js} +4 -4
  11. package/dist/{claude-adapter-bm23cye7.js → claude-adapter-gfts5044.js} +9 -9
  12. package/dist/{claude-managed-adapter-1mwrsm9d.js → claude-managed-adapter-h2y889ge.js} +2 -2
  13. package/dist/{claude-managed-setup-gkfgks4j.js → claude-managed-setup-h00hsgtz.js} +2 -2
  14. package/dist/{claude-sdk-session-apb4ypz3.js → claude-sdk-session-56besmfz.js} +13 -13
  15. package/dist/{cli-nrrg0fvy.js → cli-0bh477zn.js} +18 -5
  16. package/dist/{cli-7gh7020n.js → cli-1dmcq2eh.js} +1 -1
  17. package/dist/{cli-7ayprxp7.js → cli-2dfbv8qr.js} +8 -0
  18. package/dist/{cli-1cevz46c.js → cli-3kh4kpqy.js} +6 -6
  19. package/dist/{cli-tg9ksbn8.js → cli-3p8fqcnh.js} +2 -2
  20. package/dist/{cli-x1p5dhq2.js → cli-3r448dqn.js} +3 -3
  21. package/dist/{cli-ecshf504.js → cli-3tnpj2bh.js} +7 -7
  22. package/dist/{cli-ffbrkh1r.js → cli-471qk6s2.js} +46 -38
  23. package/dist/{cli-a49vpbf3.js → cli-5kqnemen.js} +1 -1
  24. package/dist/{cli-zvze9rbm.js → cli-6ncjvx55.js} +3 -3
  25. package/dist/{cli-fb6t92v8.js → cli-6x9ma5w5.js} +1 -1
  26. package/dist/{cli-c8fwpgqb.js → cli-7225gwj7.js} +2 -2
  27. package/dist/{cli-bn26xbcs.js → cli-8nyhf7za.js} +1 -1
  28. package/dist/{cli-y83zk5sb.js → cli-a0kcty51.js} +4 -4
  29. package/dist/{cli-8b9wvjps.js → cli-a4gvwttf.js} +4 -4
  30. package/dist/{cli-jhp7w1fy.js → cli-a5ebc95n.js} +2 -2
  31. package/dist/{cli-mz1ezzzh.js → cli-ash6xxk7.js} +2 -2
  32. package/dist/{cli-rsd7ga7v.js → cli-axh29tyr.js} +11 -11
  33. package/dist/{cli-p22zdvnt.js → cli-bn0rewsy.js} +3 -3
  34. package/dist/{cli-6ftrrwm1.js → cli-bn5kevnf.js} +5 -5
  35. package/dist/{cli-p1m2b6gc.js → cli-d2mb5s8a.js} +7 -7
  36. package/dist/{cli-c5sbmw1v.js → cli-d82btycs.js} +94 -15
  37. package/dist/{cli-vjfpshn3.js → cli-ejs3m1sx.js} +1 -1
  38. package/dist/{cli-a1esmmee.js → cli-fpmsqq59.js} +7 -2
  39. package/dist/{cli-8cxkw1f5.js → cli-fz9kewbh.js} +2 -2
  40. package/dist/{cli-8rwvksbc.js → cli-g0hgtz4p.js} +5 -5
  41. package/dist/{cli-kz116kvw.js → cli-gf0kfg81.js} +7 -7
  42. package/dist/{cli-eqgfs202.js → cli-hqhzg1qq.js} +2 -2
  43. package/dist/{cli-3wjs22x3.js → cli-j4rqdf2n.js} +2 -2
  44. package/dist/{cli-ywyrffff.js → cli-jh0qcrga.js} +1 -1
  45. package/dist/{cli-ttaz6c7m.js → cli-k9z4zd4p.js} +6 -5
  46. package/dist/{cli-nme7wery.js → cli-kmqgmcnb.js} +46 -13
  47. package/dist/{cli-x719j3vs.js → cli-kr2pmfqx.js} +392 -168
  48. package/dist/{cli-wrv09v7q.js → cli-m2qgh5sv.js} +1 -1
  49. package/dist/{cli-xfe63zx3.js → cli-mfe7f7b5.js} +2 -2
  50. package/dist/{cli-x3en6ct3.js → cli-nwyw3zhg.js} +1 -1
  51. package/dist/{cli-5s5hx57y.js → cli-nx462e4v.js} +1 -1
  52. package/dist/{cli-bfc3k1x5.js → cli-p0rakbrk.js} +9 -9
  53. package/dist/{cli-avmqhatn.js → cli-p6argxh9.js} +8 -8
  54. package/dist/{cli-3avfh5az.js → cli-pemywhfq.js} +2 -2
  55. package/dist/{cli-sppc0cmw.js → cli-pnp2j5zg.js} +2 -2
  56. package/dist/{cli-wk8ccrz6.js → cli-pw7v69vp.js} +2 -2
  57. package/dist/{cli-bhwa8rzc.js → cli-rdq7tzwm.js} +13 -12
  58. package/dist/{cli-rg7ntypm.js → cli-rqqsfz30.js} +379 -87
  59. package/dist/{cli-kk2r80n5.js → cli-s48yvzb8.js} +4 -4
  60. package/dist/{cli-zv4vy2b0.js → cli-s95dknxh.js} +1 -1
  61. package/dist/{cli-b80a1100.js → cli-sjyetj21.js} +2 -2
  62. package/dist/{cli-rdr166ht.js → cli-smvcw9z5.js} +1 -1
  63. package/dist/{cli-2rwet3r7.js → cli-sv5gb0yb.js} +1 -1
  64. package/dist/{cli-5ff2sjyt.js → cli-syhq378v.js} +2 -2
  65. package/dist/{cli-tj4j2ts4.js → cli-tbhwrxpt.js} +8 -8
  66. package/dist/{cli-4fhyq711.js → cli-vy0fay6k.js} +13 -13
  67. package/dist/{cli-ptkpaqfy.js → cli-wt645v7q.js} +1 -1
  68. package/dist/{cli-f3nx1h8z.js → cli-wxbn3yfx.js} +2 -2
  69. package/dist/{cli-wyrr38xm.js → cli-x5mq1fyq.js} +1 -1
  70. package/dist/{cli-ccn0eybf.js → cli-xbzvet96.js} +6 -6
  71. package/dist/{cli-4mkvwgzr.js → cli-xf234ws8.js} +1 -1
  72. package/dist/{cli-tgvpmnwz.js → cli-xftrvxag.js} +5 -5
  73. package/dist/{cli-02vhhssk.js → cli-z071fb95.js} +5 -5
  74. package/dist/{cli-mvg2hf8p.js → cli-z1ew8vbq.js} +14 -7
  75. package/dist/{cli-dsgj3e7a.js → cli-zfg9enh9.js} +2 -2
  76. package/dist/{cli-yxqag76b.js → cli-zw88zjba.js} +1 -1
  77. package/dist/cli.js +19 -19
  78. package/dist/{codex-adapter-pp2gq8gr.js → codex-adapter-p6rmm7vv.js} +6 -6
  79. package/dist/{codex-hook-qwzccd64.js → codex-hook-x7nejyrr.js} +7 -4
  80. package/dist/{codex-login-35p4sk85.js → codex-login-m819c41r.js} +1 -1
  81. package/dist/{codex-session-runner-0vzn09t8.js → codex-session-runner-8vd5t9t0.js} +7 -7
  82. package/dist/{commands-psqhkttb.js → commands-7ems2p13.js} +2 -2
  83. package/dist/{controller-t20vz2k8.js → controller-m263fg43.js} +4 -4
  84. package/dist/{controllers-connect-gkxmqw9d.js → controllers-connect-bg33ae53.js} +23 -23
  85. package/dist/{cursor-adapter-3v542qe5.js → cursor-adapter-cz6nm6mj.js} +3 -3
  86. package/dist/{db-2xbyenjr.js → db-w505dwa8.js} +2 -2
  87. package/dist/{db-nc1rd869.js → db-ys2rbdft.js} +3 -3
  88. package/dist/{devin-adapter-7egzmt0k.js → devin-adapter-zgqadvnm.js} +1 -1
  89. package/dist/{dispatcher-ewceb8ts.js → dispatcher-0s0mckqr.js} +20 -20
  90. package/dist/{docker-njavd3fb.js → docker-fkgtr75t.js} +2 -2
  91. package/dist/{dsh-adapter-75xwh78t.js → dsh-adapter-3n3net33.js} +3 -3
  92. package/dist/{e2b-5r6a5jeb.js → e2b-7am6tmq6.js} +2 -2
  93. package/dist/{e2b-pw1nvtmh.js → e2b-thdcxtjd.js} +2 -2
  94. package/dist/{easter-egg-3d-xsc47jwf.js → easter-egg-3d-5cgynaja.js} +1 -1
  95. package/dist/{event-bus-101fenkh.js → event-bus-9vpk8gmq.js} +1 -1
  96. package/dist/{execute-gzmw1mnw.js → execute-t4e6m3rw.js} +1 -1
  97. package/dist/{external-channel-a5dsmxn4.js → external-channel-5b23fv8z.js} +2 -2
  98. package/dist/{grok-adapter-vj88xr23.js → grok-adapter-q9fghe5q.js} +10 -10
  99. package/dist/handlers-mqrym347.js +39 -0
  100. package/dist/{heartbeat-t2m9r3wa.js → heartbeat-tww6yx1w.js} +23 -23
  101. package/dist/{hook-xa677f8r.js → hook-dx0pkvar.js} +16 -9
  102. package/dist/{hook-8femx1bg.js → hook-pvmh9b6m.js} +6 -6
  103. package/dist/{http-xpmmq028.js → http-k2ejbt05.js} +199 -131
  104. package/dist/{index-s7ckdajq.js → index-2z710v4x.js} +10 -10
  105. package/dist/{index-dg2tmj99.js → index-dgz61nff.js} +20 -20
  106. package/dist/{index-syztqdt6.js → index-g6tbb6ve.js} +1 -1
  107. package/dist/index-h2ek08d6.js +40 -0
  108. package/dist/index-pqfwh9nn.js +41 -0
  109. package/dist/{index-cdzyfrsp.js → index-x5aktmp8.js} +1 -1
  110. package/dist/{keepalive-3nr4st3r.js → keepalive-nhvpw10z.js} +4 -4
  111. package/dist/lead-jb6sf69z.js +78 -0
  112. package/dist/{maintenance-verwpe2c.js → maintenance-agakc519.js} +1 -1
  113. package/dist/{oauth-refresh-sweep-9q2t0y3q.js → oauth-refresh-sweep-4dcbhtn0.js} +3 -3
  114. package/dist/{onboard-aytqedmq.js → onboard-4n2kwa4v.js} +3 -3
  115. package/dist/{opencode-adapter-f4hryjny.js → opencode-adapter-3s7ax02e.js} +7 -7
  116. package/dist/{otel-impl-hckw4wt1.js → otel-impl-tfa12080.js} +2 -2
  117. package/dist/{pi-mono-adapter-hxby0ce1.js → pi-mono-adapter-ytp7gz8b.js} +22 -11
  118. package/dist/{pricing-refresh-5nvme3sm.js → pricing-refresh-3cqx3f3x.js} +3 -3
  119. package/dist/{rbac-roles-pd8c26m1.js → rbac-roles-9fae5rnv.js} +1 -1
  120. package/dist/{rbac-roles-vbpy21mg.js → rbac-roles-y6zbfzve.js} +2 -2
  121. package/dist/{render-v2-7nqgvk2q.js → render-v2-emawjvc4.js} +3 -3
  122. package/dist/{resume-b6djcj3c.js → resume-zhgts7h7.js} +10 -8
  123. package/dist/{runtime-txrxc9za.js → runtime-xe3aqy4x.js} +1 -1
  124. package/dist/{session-access-fc3f9y12.js → session-access-cm823e06.js} +4 -4
  125. package/dist/{setup-n39szm6b.js → setup-tkp99w1e.js} +1 -1
  126. package/dist/{virtual-modules-wyp2k6z3.js → virtual-modules-4r3zkc20.js} +2 -2
  127. package/dist/{work-objects-jem2fre0.js → work-objects-f722h263.js} +23 -23
  128. package/dist/worker-p7f336rq.js +78 -0
  129. package/dist/{x-8ewt8gxn.js → x-y45mthwn.js} +2 -2
  130. package/openapi.json +99 -52
  131. package/package.json +2 -2
  132. package/src/apps/action-runner.ts +146 -0
  133. package/src/apps/row-store.ts +4 -2
  134. package/src/apps/script-types.ts +1 -1
  135. package/src/be/db.ts +14 -4
  136. package/src/be/fleet-benchmark-stats.ts +10 -5
  137. package/src/be/models-catalog.ts +4 -0
  138. package/src/be/onboarding.ts +6 -0
  139. package/src/be/scripts/typecheck.ts +13 -0
  140. package/src/be/swarm-config-guard.ts +10 -1
  141. package/src/be/telemetry-enable.ts +1 -1
  142. package/src/commands/provider-credentials.ts +12 -2
  143. package/src/commands/runner.ts +3 -0
  144. package/src/heartbeat/heartbeat.ts +2 -2
  145. package/src/hooks/codex-hook.ts +6 -2
  146. package/src/hooks/hook.ts +7 -0
  147. package/src/hooks/pr-body-guard.ts +38 -11
  148. package/src/hooks/pr-room-guard.ts +88 -0
  149. package/src/http/agents.ts +1 -0
  150. package/src/http/apps.ts +84 -181
  151. package/src/http/fleet-benchmark.ts +79 -30
  152. package/src/http/global-config.ts +7 -0
  153. package/src/http/ranking.ts +24 -40
  154. package/src/http/tasks.ts +2 -1
  155. package/src/http/telemetry-ui.ts +1 -1
  156. package/src/http/used-by.ts +55 -19
  157. package/src/http/users.ts +2 -2
  158. package/src/providers/amp-adapter.ts +1 -1
  159. package/src/providers/pi-mono-adapter.ts +15 -0
  160. package/src/providers/reasoning-effort.ts +2 -1
  161. package/src/providers/types.ts +2 -0
  162. package/src/ranking/telemetry-ranking.ts +5 -33
  163. package/src/scripts-runtime/sdk-allowlist.ts +7 -0
  164. package/src/scripts-runtime/types/stdlib.d.ts +31 -0
  165. package/src/scripts-runtime/types/swarm-sdk.d.ts +31 -0
  166. package/src/server.ts +4 -0
  167. package/src/telemetry-context.ts +19 -1
  168. package/src/telemetry-contract/EVENTS.md +42 -8
  169. package/src/telemetry-contract/SOURCE.json +6 -5
  170. package/src/telemetry-contract/catalog.json +225 -5
  171. package/src/telemetry-contract/ranking/fixtures/valid/no-claim.json +10 -0
  172. package/src/telemetry-contract/ranking/listing.v1.schema.json +2 -2
  173. package/src/telemetry-contract/types.gen.ts +225 -27
  174. package/src/telemetry-identity.ts +47 -9
  175. package/src/telemetry-snapshot.ts +9 -1
  176. package/src/telemetry-ui-events.ts +9 -2
  177. package/src/telemetry.ts +14 -3
  178. package/src/tests/app-action-run-tool.test.ts +178 -0
  179. package/src/tests/app-row-tools.test.ts +360 -0
  180. package/src/tests/check-pr-body.test.ts +0 -23
  181. package/src/tests/credential-check.test.ts +38 -0
  182. package/src/tests/fleet-benchmark.test.ts +122 -2
  183. package/src/tests/http-users.test.ts +62 -1
  184. package/src/tests/model-catalog-table.test.ts +63 -0
  185. package/src/tests/model-profiles-api.test.ts +19 -7
  186. package/src/tests/models-catalog.test.ts +50 -0
  187. package/src/tests/onboarding.test.ts +21 -0
  188. package/src/tests/org-website.test.ts +62 -0
  189. package/src/tests/pi-mono-adapter.test.ts +23 -0
  190. package/src/tests/pr-body-leaks.test.ts +4 -0
  191. package/src/tests/pr-room-guard.test.ts +242 -0
  192. package/src/tests/ranking-listing.test.ts +8 -23
  193. package/src/tests/ranking-route.test.ts +49 -47
  194. package/src/tests/reasoning-effort.test.ts +39 -3
  195. package/src/tests/runner-harness-telemetry-env.test.ts +56 -1
  196. package/src/tests/swarm-tool-result-gate.test.ts +2 -4
  197. package/src/tests/telemetry-context.test.ts +128 -2
  198. package/src/tests/telemetry-ui-route.test.ts +123 -0
  199. package/src/tests/tool-annotations.test.ts +3 -2
  200. package/src/tests/used-by-route.test.ts +144 -10
  201. package/src/tests/workflow-agent-task-retry.test.ts +130 -4
  202. package/src/tests/workflow-wait-recovery.test.ts +116 -4
  203. package/src/tools/app-action-run.ts +63 -0
  204. package/src/tools/app-rows.ts +234 -0
  205. package/src/tools/model-catalog/index.ts +1 -1
  206. package/src/tools/tool-config.ts +8 -0
  207. package/src/tools/utils.ts +1 -1
  208. package/src/tools/workflows/retry-workflow-run.ts +1 -1
  209. package/src/types.ts +17 -0
  210. package/src/utils/company-profile.ts +24 -0
  211. package/src/utils/org-website.ts +58 -21
  212. package/src/workflows/recovery.ts +10 -22
  213. package/src/workflows/resume.ts +16 -0
  214. package/src/workflows/task-step-routing.ts +47 -7
  215. package/templates/skills/apps/SKILL.md +41 -2
  216. package/templates/skills/apps/content.md +41 -2
  217. package/dist/handlers-zbs90bny.js +0 -39
  218. package/dist/index-8pwc1jc8.js +0 -40
  219. package/dist/index-nxa6me0q.js +0 -41
  220. package/dist/lead-td8h2sfn.js +0 -78
  221. package/dist/worker-wr6xgfr2.js +0 -78
  222. package/src/tests/check-ranking-entry.test.ts +0 -146
@@ -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
+ }
@@ -0,0 +1,234 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { AppNameSchema, type ModelDef, SYSTEM_COLUMN_KINDS } from "../apps/definition";
4
+ import {
5
+ type AppRow,
6
+ AppRowAppNotFoundError,
7
+ AppRowValidationError,
8
+ createAppRow,
9
+ createAppRows,
10
+ deleteAppRow,
11
+ getAppRow,
12
+ patchAppRow,
13
+ validValue,
14
+ } from "../apps/row-store";
15
+ import { appDefinitionNeedsRepair, getApp } from "../apps/store";
16
+ import { getAgentById } from "../be/db";
17
+ import {
18
+ MAX_APP_BULK_ROWS_BODY_BYTES,
19
+ MAX_APP_ROW_BODY_BYTES,
20
+ rowMatchesFilters,
21
+ selectAppRows,
22
+ } from "../http/apps";
23
+ import { can } from "../rbac";
24
+ import {
25
+ createToolRegistrar,
26
+ type SwarmToolResult,
27
+ swarmToolOutputSchema,
28
+ toolErr,
29
+ toolOk,
30
+ } from "./utils";
31
+
32
+ const base = { appId: z.string().min(1), model: AppNameSchema };
33
+ const rowId = z.string().min(1);
34
+ const values = z.record(z.string(), z.unknown());
35
+ const equals = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
36
+ const limit = z.number().int().min(1).max(1000);
37
+ const rowOutput = { row: z.looseObject({}).optional() };
38
+ const rowsOutput = { rows: z.array(z.looseObject({})).optional() };
39
+
40
+ function checkSize(value: unknown, max: number): void {
41
+ if (Buffer.byteLength(JSON.stringify(value), "utf8") > max) {
42
+ throw new AppRowValidationError([
43
+ { path: "values", message: `Row input exceeds ${max} bytes.` },
44
+ ]);
45
+ }
46
+ }
47
+
48
+ function rowFilters(model: ModelDef, equals: Record<string, string | number | boolean> = {}) {
49
+ return Object.entries(equals).map(([column, value]) => {
50
+ const declared = Object.hasOwn(model.columns, column) ? model.columns[column] : undefined;
51
+ if ((!declared && !Object.hasOwn(SYSTEM_COLUMN_KINDS, column)) || declared?.hidden) {
52
+ throw new AppRowValidationError([
53
+ { path: `equals.${column}`, message: "Unknown or hidden column." },
54
+ ]);
55
+ }
56
+ if (!validValue(declared ?? { kind: SYSTEM_COLUMN_KINDS[column]! }, value)) {
57
+ throw new AppRowValidationError([
58
+ { path: `equals.${column}`, message: "Filter value must match the column kind." },
59
+ ]);
60
+ }
61
+ return { column, value };
62
+ });
63
+ }
64
+
65
+ export function registerAppRowTools(server: McpServer) {
66
+ function register<Shape extends z.ZodRawShape>(
67
+ name: string,
68
+ description: string,
69
+ shape: Shape,
70
+ output: z.ZodRawShape,
71
+ operation: (
72
+ args: z.infer<z.ZodObject<typeof base & Shape>>,
73
+ model: ModelDef,
74
+ actor: string,
75
+ ) => Promise<SwarmToolResult>,
76
+ readOnly = false,
77
+ ) {
78
+ createToolRegistrar(server)(
79
+ name,
80
+ {
81
+ title: description,
82
+ description,
83
+ annotations: { readOnlyHint: readOnly },
84
+ rbac: { permission: "app.use" },
85
+ inputSchema: z.object({ ...base, ...shape }),
86
+ outputSchema: swarmToolOutputSchema(output),
87
+ },
88
+ async (args, info) => {
89
+ if (!info.agentId) return toolErr('Agent ID not found. Set the "X-Agent-ID" header.');
90
+ const { appId, model: modelName } = z.object(base).parse(args);
91
+ const agent = await getAgentById(info.agentId);
92
+ const decision = can({
93
+ principal: { kind: "agent", agentId: info.agentId, isLead: agent?.isLead ?? false },
94
+ verb: "app.use",
95
+ resource: { kind: "app", appId },
96
+ source: "mcp",
97
+ });
98
+ if (!decision.allow) return toolErr(decision.reason);
99
+ const app = await getApp(appId);
100
+ if (!app) return toolErr("App not found.");
101
+ if (appDefinitionNeedsRepair(app))
102
+ return toolErr("App definition needs repair.", { data: { issues: app.definitionError } });
103
+ if (!Object.hasOwn(app.definition.models, modelName)) return toolErr("Model not found.");
104
+ try {
105
+ return await operation(args, app.definition.models[modelName]!, `agent:${info.agentId}`);
106
+ } catch (error) {
107
+ if (error instanceof AppRowValidationError)
108
+ return toolErr(error.message, { data: { issues: error.issues } });
109
+ if (error instanceof AppRowAppNotFoundError) return toolErr(error.message);
110
+ throw error;
111
+ }
112
+ },
113
+ );
114
+ }
115
+
116
+ register(
117
+ "app-rows-list",
118
+ "List app rows with equality filters, sorting, and a limit.",
119
+ {
120
+ equals: equals.optional(),
121
+ sort: z.string().optional(),
122
+ limit: limit.optional(),
123
+ },
124
+ { ...rowsOutput, total: z.number().optional() },
125
+ async (args, model) => {
126
+ const data = await selectAppRows(
127
+ args.appId,
128
+ args.model,
129
+ model,
130
+ rowFilters(model, args.equals),
131
+ args.sort,
132
+ args.limit,
133
+ true,
134
+ );
135
+ return toolOk(`Found ${data.total} row(s).`, { data });
136
+ },
137
+ true,
138
+ );
139
+
140
+ register(
141
+ "app-row-get",
142
+ "Get an app row.",
143
+ { rowId },
144
+ rowOutput,
145
+ async (args) => {
146
+ const row = await getAppRow(args.appId, args.model, args.rowId);
147
+ return row ? toolOk("Row retrieved.", { data: { row } }) : toolErr("Row not found.");
148
+ },
149
+ true,
150
+ );
151
+
152
+ register(
153
+ "app-row-create",
154
+ "Create an app row.",
155
+ { values },
156
+ rowOutput,
157
+ async (args, model, actor) => {
158
+ checkSize({ values: args.values }, MAX_APP_ROW_BODY_BYTES);
159
+ const row = await createAppRow(args.appId, args.model, model, args.values, { actor });
160
+ return toolOk("Row created.", { data: { row } });
161
+ },
162
+ );
163
+
164
+ register(
165
+ "app-rows-create",
166
+ "Create up to 500 app rows within 10 MiB.",
167
+ {
168
+ rows: z.array(values).min(1).max(500),
169
+ },
170
+ rowsOutput,
171
+ async (args, model, actor) => {
172
+ checkSize({ rows: args.rows }, MAX_APP_BULK_ROWS_BODY_BYTES);
173
+ for (const values of args.rows) checkSize({ values }, MAX_APP_ROW_BODY_BYTES);
174
+ const rows = await createAppRows(args.appId, args.model, model, args.rows, { actor });
175
+ return toolOk(`Created ${rows.length} row(s).`, { data: { rows } });
176
+ },
177
+ );
178
+
179
+ register(
180
+ "app-row-patch",
181
+ "Patch an app row.",
182
+ { rowId, values },
183
+ rowOutput,
184
+ async (args, model, actor) => {
185
+ checkSize({ values: args.values }, MAX_APP_ROW_BODY_BYTES);
186
+ const row = await patchAppRow(args.appId, args.model, model, args.rowId, args.values, {
187
+ actor,
188
+ });
189
+ return row ? toolOk("Row updated.", { data: { row } }) : toolErr("Row not found.");
190
+ },
191
+ );
192
+
193
+ register(
194
+ "app-row-delete",
195
+ "Delete an app row.",
196
+ { rowId },
197
+ { ok: z.boolean().optional() },
198
+ async (args, model) => {
199
+ const deleted = await deleteAppRow(args.appId, args.model, model, args.rowId);
200
+ return deleted ? toolOk("Row deleted.", { data: { ok: true } }) : toolErr("Row not found.");
201
+ },
202
+ );
203
+
204
+ register(
205
+ "app-rows-delete",
206
+ "Delete matching app rows. Supply at least one equality filter.",
207
+ {
208
+ equals: equals.refine(
209
+ (value) => Object.keys(value).length > 0,
210
+ "Supply at least one equality filter.",
211
+ ),
212
+ limit: limit.optional(),
213
+ },
214
+ { deleted: z.number().optional() },
215
+ async (args, model) => {
216
+ const filters = rowFilters(model, args.equals);
217
+ const { rows } = await selectAppRows(
218
+ args.appId,
219
+ args.model,
220
+ model,
221
+ filters,
222
+ undefined,
223
+ args.limit ?? 1000,
224
+ true,
225
+ );
226
+ // Re-check under the row lock: a row patched out of the filter since the select is kept.
227
+ const matches = (row: AppRow) => rowMatchesFilters(row, filters);
228
+ let deleted = 0;
229
+ for (const row of rows)
230
+ if (await deleteAppRow(args.appId, args.model, model, row.id, matches)) deleted++;
231
+ return toolOk(`Deleted ${deleted} row(s).`, { data: { deleted } });
232
+ },
233
+ );
234
+ }
@@ -87,7 +87,7 @@ export const registerModelCatalogOverlayUpsertTool = (server: McpServer) => {
87
87
  .string()
88
88
  .min(1)
89
89
  .describe(
90
- `models.dev provider id: anthropic, openai, openrouter, amazon-bedrock, opencode, ${openAICompatibleProviderIds().join(", ")}.`,
90
+ `models.dev provider id: anthropic, openai, openrouter, amazon-bedrock, opencode, opencode-go, ${openAICompatibleProviderIds().join(", ")}.`,
91
91
  ),
92
92
  modelId: z
93
93
  .string()
@@ -189,6 +189,14 @@ export const DEFERRED_TOOLS = new Set([
189
189
  "app-list",
190
190
  "app-patch",
191
191
  "app-query",
192
+ "app-rows-list",
193
+ "app-row-get",
194
+ "app-row-create",
195
+ "app-rows-create",
196
+ "app-row-patch",
197
+ "app-row-delete",
198
+ "app-rows-delete",
199
+ "app-action-run",
192
200
  "app-sync",
193
201
  "app-upsert",
194
202
  "app-history",
@@ -347,7 +347,7 @@ export const NUDGES: Record<string, (result: SwarmToolResult) => string | undefi
347
347
  const hasQueries = Object.keys(definition?.queries ?? {}).length > 0;
348
348
  const hasActions = Object.keys(definition?.actions ?? {}).length > 0;
349
349
  return hasQueries || hasActions
350
- ? "This app's callable surface is `definition.queries` (run with `app-query`, passing any `$param` values) and `definition.actions` (invoke with `POST /api/apps/<id>/actions/<name>`; `sync` actions refresh sources)."
350
+ ? "Run `definition.queries` with `app-query` and supply each `$param` value. Invoke `definition.actions` with `app-action-run` through MCP or `POST /api/apps/<id>/actions/<name>` through REST. Sync actions refresh sources."
351
351
  : undefined;
352
352
  },
353
353
  // kv-get is spill-exempt (retrieval path), so oversized values reach the
@@ -10,7 +10,7 @@ export const registerRetryWorkflowRunTool = (server: McpServer) => {
10
10
  title: "Retry Workflow Run",
11
11
  annotations: { destructiveHint: false },
12
12
  description:
13
- "Retry a failed workflow run from the beginning. The run must be in 'failed' status.",
13
+ "Resume the same failed workflow run from its failed step(s); completed steps are not re-run. Retrying re-creates the failed node's side effects (e.g. an agent task), so check the target state first if that work was already done manually.",
14
14
  inputSchema: z.object({
15
15
  runId: z.string().uuid().describe("Workflow run ID to retry"),
16
16
  }),