@desplega.ai/agent-swarm 1.157.0 → 1.158.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/dist/{acp-adapter-qhqrk4q0.js → acp-adapter-xaq4n6pc.js} +1 -1
  2. package/dist/{actions-9rrg0zxm.js → actions-p9hz5yat.js} +15 -15
  3. package/dist/{app-enysy1zd.js → app-g26wdcz6.js} +4 -4
  4. package/dist/{artifact-td4q4dch.js → artifact-3v5t9jtg.js} +1 -1
  5. package/dist/{assistant-zegamy48.js → assistant-wydfqkb3.js} +16 -16
  6. package/dist/{boot-reembed-r6shx5qx.js → boot-reembed-g5ckg9yb.js} +6 -6
  7. package/dist/{boot-reembed-zfxb937k.js → boot-reembed-se8zmdcp.js} +5 -5
  8. package/dist/{boot-scrub-logs-k5y0se6r.js → boot-scrub-logs-wfxqan61.js} +3 -3
  9. package/dist/{seed-pricing-vqxa06qf.js → boot-seeds-s3q01xj3.js} +10 -10
  10. package/dist/{claude-adapter-5yc7hxwv.js → claude-adapter-tv2x4f36.js} +7 -7
  11. package/dist/{claude-managed-adapter-ea2aga6w.js → claude-managed-adapter-41b2s7fd.js} +2 -2
  12. package/dist/{claude-sdk-session-j4ywn3xf.js → claude-sdk-session-t442qz6f.js} +8 -8
  13. package/dist/{cli-e781c6f9.js → cli-0xjkj1j7.js} +1 -0
  14. package/dist/{cli-58rgvkt4.js → cli-1hkrch3k.js} +2 -2
  15. package/dist/{cli-yscm6cv8.js → cli-20cj37vt.js} +1866 -9
  16. package/dist/{cli-h0wdadsx.js → cli-4198qc9v.js} +7 -3
  17. package/dist/{cli-awwphhcs.js → cli-47m6dkd3.js} +362 -126
  18. package/dist/{cli-wb2zvbfg.js → cli-53sz8gkz.js} +1 -1
  19. package/dist/{cli-005pze6s.js → cli-5d10vd2m.js} +1 -1
  20. package/dist/{cli-v53rbqp3.js → cli-65hjmev5.js} +57 -17
  21. package/dist/{cli-scwq51gn.js → cli-68w09sjw.js} +7 -1
  22. package/dist/{cli-s98skd5r.js → cli-7gtvvpbh.js} +4 -4
  23. package/dist/{cli-y3nxwx0t.js → cli-7s3syk22.js} +18 -11
  24. package/dist/{cli-74h6f2bp.js → cli-8qmme6vd.js} +2 -2
  25. package/dist/{cli-wsed24mk.js → cli-987hg0aa.js} +2 -2
  26. package/dist/{cli-9n76jszt.js → cli-9httdpyq.js} +1 -1
  27. package/dist/{cli-f3va018d.js → cli-aky8tctm.js} +26 -93
  28. package/dist/{cli-sgyqyyxe.js → cli-c07r9a2m.js} +2 -1
  29. package/dist/{cli-c41wjx0r.js → cli-fgqxwrpv.js} +291 -12
  30. package/dist/{cli-01vfmq8c.js → cli-ftfvggzx.js} +607 -164
  31. package/dist/{cli-ytxs7mn1.js → cli-g8djp6qe.js} +17 -0
  32. package/dist/{cli-avkd6mqv.js → cli-h967nb6r.js} +551 -0
  33. package/dist/{cli-r9ee9c4w.js → cli-j55n64b6.js} +23 -22
  34. package/dist/{cli-9p33cz9h.js → cli-j57qkrpq.js} +86 -28
  35. package/dist/{cli-tjmghmm5.js → cli-q67y4wzm.js} +6 -3
  36. package/dist/{cli-n6zq3kbd.js → cli-rj19hc34.js} +1 -1
  37. package/dist/{cli-jytrw2na.js → cli-rjehvrts.js} +4 -4
  38. package/dist/{cli-tpv97m71.js → cli-rqz8qbw7.js} +245 -75
  39. package/dist/{cli-p37sg0a9.js → cli-s8eet254.js} +1 -1
  40. package/dist/cli-t1ze64h3.js +811 -0
  41. package/dist/{cli-6jjzxv3b.js → cli-ws6rqycm.js} +3 -2
  42. package/dist/{cli-xf5b7y1f.js → cli-wscdvcqz.js} +13 -5
  43. package/dist/{cli-5j7bm4tb.js → cli-x24pshtq.js} +5 -5
  44. package/dist/{cli-nbp6m6gp.js → cli-xberxa1r.js} +51 -26
  45. package/dist/{cli-vx9p3qdt.js → cli-xhaeg2nc.js} +1 -1
  46. package/dist/cli.js +29 -15
  47. package/dist/{codex-adapter-gns3s6c3.js → codex-adapter-cnm8wk11.js} +5 -5
  48. package/dist/{codex-session-runner-gqnxys5w.js → codex-session-runner-gfg547zj.js} +5 -5
  49. package/dist/{commands-hxy2y2sp.js → commands-e3bn0p75.js} +3 -3
  50. package/dist/{db-w8ghhsgf.js → db-03v9kzr2.js} +17 -3
  51. package/dist/{dispatcher-cp1rknjz.js → dispatcher-nrmg8yht.js} +14 -14
  52. package/dist/{dsh-adapter-1dwg97zv.js → dsh-adapter-k03nfqa6.js} +2 -2
  53. package/dist/{handlers-egfhr351.js → handlers-aqcedbyb.js} +17 -17
  54. package/dist/{hook-x4kftwzt.js → hook-7vkskkre.js} +19 -11
  55. package/dist/{hook-fbxjqfjz.js → hook-ybvmrmhq.js} +4 -4
  56. package/dist/{http-c7jfx990.js → http-2eqa8t24.js} +432 -195
  57. package/dist/{index-4k9hct21.js → index-1yks3qcr.js} +14 -14
  58. package/dist/{index-jtvpgb8f.js → index-a3vn96n8.js} +30 -27
  59. package/dist/{index-dc2ek6bw.js → index-c6zsv1bd.js} +5 -5
  60. package/dist/{index-yy07ef2s.js → index-chgdkq36.js} +14 -14
  61. package/dist/{index-r6bty6tb.js → index-x71bvq0p.js} +15 -15
  62. package/dist/{keepalive-f7ekefyc.js → keepalive-vtkf5n99.js} +5 -5
  63. package/dist/{lead-h053syts.js → lead-h3sp357x.js} +26 -26
  64. package/dist/{maintenance-a3w6zs54.js → maintenance-jzeqtk3q.js} +6 -6
  65. package/dist/{oauth-refresh-sweep-vra1dhkd.js → oauth-refresh-sweep-qp3dzmdp.js} +4 -4
  66. package/dist/{onboard-mfgzeqxs.js → onboard-tmsp0qbz.js} +278 -81
  67. package/dist/{opencode-adapter-z64xjzec.js → opencode-adapter-x6x2da1z.js} +3 -3
  68. package/dist/{otel-impl-2ez6xk57.js → otel-impl-9mz73q65.js} +416 -301
  69. package/dist/{pi-mono-adapter-2b7mw16r.js → pi-mono-adapter-m6dvv0dz.js} +2 -2
  70. package/dist/{pricing-refresh-6sw65tck.js → pricing-refresh-v1cbr9te.js} +5 -5
  71. package/dist/{rbac-roles-nm6924sy.js → rbac-roles-c9wqz83x.js} +4 -4
  72. package/dist/{render-v2-f026j6c4.js → render-v2-gryrh9h8.js} +4 -4
  73. package/dist/{setup-w606fr1q.js → setup-q9720bt6.js} +2 -2
  74. package/dist/{virtual-modules-8qheft18.js → virtual-modules-3htpgn8x.js} +1 -1
  75. package/dist/{work-objects-ezmggdne.js → work-objects-b76x7deb.js} +17 -17
  76. package/dist/{worker-1mbyv29k.js → worker-6vrxbv41.js} +26 -26
  77. package/openapi.json +1626 -629
  78. package/package.json +2 -1
  79. package/src/apps/catalog.generated.json +229 -0
  80. package/src/apps/page-validator.ts +71 -16
  81. package/src/apps/row-store.ts +66 -11
  82. package/src/apps/sync.ts +59 -17
  83. package/src/artifact-sdk/browser-sdk.ts +436 -2
  84. package/src/be/approval-sweeps.ts +109 -0
  85. package/src/be/boot-seeds.ts +40 -0
  86. package/src/be/chunking.ts +1 -1
  87. package/src/be/db/agents.ts +20 -0
  88. package/src/be/db/approvals.ts +78 -0
  89. package/src/be/db/tasks/read.ts +197 -39
  90. package/src/be/db/tasks/write.ts +41 -9
  91. package/src/be/db-client.ts +107 -48
  92. package/src/be/db.ts +219 -76
  93. package/src/be/events.ts +33 -1
  94. package/src/be/memory/graph-expansion.ts +10 -3
  95. package/src/be/memory/providers/sqlite-store.ts +55 -13
  96. package/src/be/memory/raters/retrieval.ts +5 -2
  97. package/src/be/memory/reranker.ts +26 -2
  98. package/src/be/memory/types.ts +2 -0
  99. package/src/be/migrations/166_approval_request_auto_cancellation.sql +9 -0
  100. package/src/be/migrations/171_memory_retrieval_relevance.sql +8 -0
  101. package/src/be/migrations/175_workflow_runs_list_index.sql +11 -0
  102. package/src/be/migrations/177_events_event_agent_created_index.sql +11 -0
  103. package/src/be/models-catalog.ts +2 -0
  104. package/src/be/modelsdev-cache.json +1857 -0
  105. package/src/be/modelsdev-cache.ts +14 -1
  106. package/src/be/pricing-normalize.ts +2 -0
  107. package/src/be/scripts/typecheck.ts +1 -0
  108. package/src/be/seed-pricing.ts +3 -1
  109. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  110. package/src/be/steering.ts +53 -3
  111. package/src/be/swarm-config-guard.ts +3 -0
  112. package/src/be/task-citations.ts +29 -0
  113. package/src/cli.tsx +14 -1
  114. package/src/commands/onboard/compose-generator.ts +8 -3
  115. package/src/commands/onboard/container-engine.ts +220 -0
  116. package/src/commands/onboard/manifest.ts +1 -0
  117. package/src/commands/onboard/steps/health-check.tsx +4 -1
  118. package/src/commands/onboard/steps/prereq-check.tsx +50 -54
  119. package/src/commands/onboard/steps/start.tsx +31 -16
  120. package/src/commands/onboard/types.ts +7 -2
  121. package/src/commands/onboard.tsx +39 -4
  122. package/src/commands/profile-sync.ts +79 -26
  123. package/src/commands/runner.ts +8 -1
  124. package/src/extensions/catalog.generated.json +17 -0
  125. package/src/extensions/contract-types.generated.ts +1 -1
  126. package/src/heartbeat/heartbeat.ts +263 -103
  127. package/src/hooks/hook.ts +21 -12
  128. package/src/http/agents.ts +35 -0
  129. package/src/http/approval-requests.ts +136 -64
  130. package/src/http/apps.ts +14 -3
  131. package/src/http/config.ts +15 -1
  132. package/src/http/events.ts +62 -2
  133. package/src/http/index.ts +6 -4
  134. package/src/http/memory.ts +21 -7
  135. package/src/http/schedules.ts +1 -1
  136. package/src/http/session-data.ts +9 -4
  137. package/src/http/sessions.ts +25 -18
  138. package/src/http/stats.ts +19 -7
  139. package/src/http/status.ts +11 -1
  140. package/src/http/tasks.ts +40 -8
  141. package/src/http/webhooks.ts +67 -46
  142. package/src/http/workflows.ts +15 -20
  143. package/src/linear/outbound.ts +37 -15
  144. package/src/otel-impl.ts +151 -1
  145. package/src/otel.ts +14 -3
  146. package/src/prompts/memories.ts +19 -2
  147. package/src/providers/claude-managed-models.ts +8 -0
  148. package/src/providers/modelsdev-reasoning.json +551 -0
  149. package/src/rbac/legacy-policy.ts +16 -0
  150. package/src/rbac/permissions.ts +4 -0
  151. package/src/scheduler/schedule-task.ts +3 -0
  152. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  153. package/src/scripts-runtime/swarm-sdk.ts +9 -0
  154. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  155. package/src/scripts-runtime/types/swarm-extension.d.ts +1 -0
  156. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  157. package/src/server.ts +12 -7
  158. package/src/slack/app.ts +35 -4
  159. package/src/slack/connection-state.ts +17 -0
  160. package/src/tasks/context-key.ts +1 -0
  161. package/src/tasks/worker-follow-up.ts +6 -2
  162. package/src/tests/agent-task-activity.test.ts +74 -0
  163. package/src/tests/agents-list-model-display.test.ts +6 -1
  164. package/src/tests/approval-auto-cancellation-migration.test.ts +104 -0
  165. package/src/tests/approval-requests.test.ts +395 -0
  166. package/src/tests/approval-sweeps-config.test.ts +47 -0
  167. package/src/tests/approval-sweeps.test.ts +508 -0
  168. package/src/tests/apps-kibo-blocks.test.ts +77 -0
  169. package/src/tests/apps-spike.test.ts +56 -1
  170. package/src/tests/apps-sync-engine.test.ts +20 -9
  171. package/src/tests/boot-seeds.test.ts +170 -0
  172. package/src/tests/cancel-approval-request-tool.test.ts +115 -0
  173. package/src/tests/claude-managed-adapter.test.ts +6 -0
  174. package/src/tests/config-key-filter.test.ts +80 -0
  175. package/src/tests/context-window.test.ts +1 -0
  176. package/src/tests/db-client-busy-retry.test.ts +96 -0
  177. package/src/tests/db-client.test.ts +60 -1
  178. package/src/tests/db-task-read-contract.test.ts +10 -0
  179. package/src/tests/db-task-write-contract.test.ts +9 -0
  180. package/src/tests/docs-index-footprint.test.js +3 -3
  181. package/src/tests/events-http.test.ts +103 -0
  182. package/src/tests/extensions-catalog.test.ts +22 -17
  183. package/src/tests/extensions-pre-task.test.ts +107 -0
  184. package/src/tests/fixtures/kibo-blocks-definition.json.txt +105 -0
  185. package/src/tests/follow-up-config-inheritance.test.ts +260 -0
  186. package/src/tests/heartbeat-orphan-resume.test.ts +84 -0
  187. package/src/tests/heartbeat-reboot-live.test.ts +95 -0
  188. package/src/tests/heartbeat-stall-cas.test.ts +80 -0
  189. package/src/tests/heartbeat.test.ts +99 -3
  190. package/src/tests/hook-mcp-config.test.ts +29 -0
  191. package/src/tests/http-api-integration.test.ts +16 -2
  192. package/src/tests/kv-storage.test.ts +62 -0
  193. package/src/tests/linear-outbound-sync.test.ts +120 -5
  194. package/src/tests/list-endpoint-slimming.test.ts +91 -0
  195. package/src/tests/memory-graph-organic-route.test.ts +155 -0
  196. package/src/tests/memory-http-recall-gating.test.ts +14 -4
  197. package/src/tests/memory-hybrid.test.ts +161 -1
  198. package/src/tests/memory-prompt-recall-crowdout.test.ts +297 -0
  199. package/src/tests/memory.test.ts +22 -0
  200. package/src/tests/onboard-container-engine.test.ts +240 -0
  201. package/src/tests/otel-event-loop-and-logs.test.ts +99 -0
  202. package/src/tests/pricing-routes.test.ts +14 -0
  203. package/src/tests/profile-sync-warning.test.ts +8 -4
  204. package/src/tests/promote-draft-task-route.test.ts +19 -0
  205. package/src/tests/provider-registration.test.ts +296 -42
  206. package/src/tests/rbac-engine.test.ts +30 -0
  207. package/src/tests/reasoning-effort.test.ts +8 -0
  208. package/src/tests/script-connections.test.ts +7 -0
  209. package/src/tests/sdk-allowlist.test.ts +39 -0
  210. package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
  211. package/src/tests/sessions-detail-batch.test.ts +250 -0
  212. package/src/tests/sessions.test.ts +118 -0
  213. package/src/tests/slack-config.test.ts +22 -0
  214. package/src/tests/sonnet-5-5-registry.test.ts +44 -0
  215. package/src/tests/status.test.ts +36 -1
  216. package/src/tests/swarm-diff.test.ts +316 -10
  217. package/src/tests/task-completion-idempotency.test.ts +2 -1
  218. package/src/tests/tasks-requested-by-filter.test.ts +3 -1
  219. package/src/tests/tool-annotations.test.ts +1 -1
  220. package/src/tests/ui-list-slim-projections.test.ts +158 -0
  221. package/src/tests/webhooks-task-blocked.test.ts +127 -0
  222. package/src/tests/workflow-approval-recovery.test.ts +203 -0
  223. package/src/tests/workflow-hitl-routing.test.ts +22 -1
  224. package/src/tests/workflow-hitl-slack-card.test.ts +52 -0
  225. package/src/tests/workflow-http-v2.test.ts +99 -8
  226. package/src/tests/workflow-retry-validation.test.ts +56 -0
  227. package/src/tests/workflow-tla-races.test.ts +389 -0
  228. package/src/tests/workflow-validation-port-routing.test.ts +113 -1
  229. package/src/tools/cancel-approval-request.ts +57 -0
  230. package/src/tools/memory-search.ts +1 -0
  231. package/src/tools/request-human-input.ts +4 -2
  232. package/src/tools/send-task.ts +4 -0
  233. package/src/tools/templates.ts +19 -0
  234. package/src/tools/tool-config.ts +2 -1
  235. package/src/tools/workflows/list-workflow-runs.ts +1 -0
  236. package/src/types.ts +38 -0
  237. package/src/utils/context-window.ts +1 -0
  238. package/src/workflows/approval-cancel.ts +152 -0
  239. package/src/workflows/approval-notifications.ts +84 -0
  240. package/src/workflows/checkpoint.ts +10 -5
  241. package/src/workflows/definition.ts +21 -0
  242. package/src/workflows/engine.ts +152 -21
  243. package/src/workflows/executors/human-in-the-loop.ts +1 -1
  244. package/src/workflows/recovery.ts +100 -45
  245. package/src/workflows/resume.ts +49 -18
  246. package/src/workflows/retry-poller.ts +71 -21
  247. package/templates/extensions/README.md +1 -0
  248. package/templates/extensions/github-sender-allowlist/README.md +18 -0
  249. package/templates/extensions/github-sender-allowlist/hooks.ts +55 -0
  250. package/templates/extensions/github-sender-allowlist/manifest.json +8 -0
  251. package/templates/skills/apps/content.md +20 -1
  252. package/templates/skills/code-quality/content.md +26 -1
  253. package/templates/skills/pages/content.md +16 -4
  254. package/templates/skills/pages/files/examples/multiplayer-board.html +3 -12
  255. package/dist/cli-2daksqps.js +0 -378
  256. package/dist/rbac-roles-3gxmnb20.js +0 -37
@@ -0,0 +1,127 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import { TaskCreationBlockedError } from "../tasks/errors";
4
+
5
+ // A pre.task.create extension block must ack the delivery (200, skipped) and
6
+ // still emit the delivery's workflow events; any other error stays a 500.
7
+
8
+ type Outcome = "blocked" | "boom";
9
+ let outcome: Outcome = "blocked";
10
+
11
+ function failingHandler(): Promise<never> {
12
+ if (outcome === "blocked") {
13
+ return Promise.reject(
14
+ new TaskCreationBlockedError("sender not allowed", { id: "ext-1", name: "gate" }),
15
+ );
16
+ }
17
+ return Promise.reject(new Error("db exploded"));
18
+ }
19
+
20
+ const realGithub = await import("../github");
21
+ const realGitlab = await import("../gitlab");
22
+ mock.module("../github", () => ({
23
+ ...realGithub,
24
+ isGitHubEnabled: () => true,
25
+ verifyWebhookSignature: async () => true,
26
+ handleIssue: failingHandler,
27
+ }));
28
+ mock.module("../gitlab", () => ({
29
+ ...realGitlab,
30
+ isGitLabEnabled: () => true,
31
+ verifyGitLabWebhook: () => true,
32
+ handleIssue: failingHandler,
33
+ }));
34
+
35
+ const { handleWebhooks } = await import("../http/webhooks");
36
+ const { workflowEventBus } = await import("../workflows/event-bus");
37
+
38
+ function fakeReqRes(rawBody: string, headers: Record<string, string>) {
39
+ const req = {
40
+ method: "POST",
41
+ headers,
42
+ async *[Symbol.asyncIterator]() {
43
+ yield Buffer.from(rawBody);
44
+ },
45
+ } as unknown as IncomingMessage;
46
+ const captured = { status: 0, body: "" };
47
+ const res = {
48
+ writeHead(status: number) {
49
+ captured.status = status;
50
+ return this;
51
+ },
52
+ end(chunk?: string) {
53
+ if (chunk) captured.body = chunk;
54
+ return this;
55
+ },
56
+ } as unknown as ServerResponse;
57
+ return { req, res, captured };
58
+ }
59
+
60
+ const githubIssue = JSON.stringify({
61
+ action: "opened",
62
+ issue: { number: 7, title: "hi" },
63
+ repository: { full_name: "acme/repo" },
64
+ });
65
+ const gitlabIssue = JSON.stringify({
66
+ object_kind: "issue",
67
+ object_attributes: { action: "open", iid: 3, title: "hi" },
68
+ project: { path_with_namespace: "acme/repo" },
69
+ });
70
+
71
+ const cases = [
72
+ {
73
+ name: "GitHub",
74
+ path: ["api", "github", "webhook"],
75
+ body: githubIssue,
76
+ headers: { "x-github-event": "issues", "x-hub-signature-256": "sha256=x" },
77
+ event: "github.issue.opened",
78
+ },
79
+ {
80
+ name: "GitLab",
81
+ path: ["api", "gitlab", "webhook"],
82
+ body: gitlabIssue,
83
+ headers: { "x-gitlab-token": "t" },
84
+ event: "gitlab.issue.open",
85
+ },
86
+ ];
87
+
88
+ let emitted: string[] = [];
89
+ let originalEmit: typeof workflowEventBus.emit;
90
+
91
+ beforeEach(() => {
92
+ emitted = [];
93
+ originalEmit = workflowEventBus.emit.bind(workflowEventBus);
94
+ workflowEventBus.emit = ((name: string) => {
95
+ emitted.push(name);
96
+ }) as typeof workflowEventBus.emit;
97
+ });
98
+
99
+ afterEach(() => {
100
+ workflowEventBus.emit = originalEmit;
101
+ });
102
+
103
+ describe("webhook task creation blocked by extension", () => {
104
+ for (const c of cases) {
105
+ test(`${c.name}: blocked create returns 200 skipped and still emits the workflow event`, async () => {
106
+ outcome = "blocked";
107
+ const { req, res, captured } = fakeReqRes(c.body, c.headers);
108
+ expect(await handleWebhooks(req, res, c.path)).toBe(true);
109
+ expect(captured.status).toBe(200);
110
+ expect(JSON.parse(captured.body)).toEqual({
111
+ created: false,
112
+ skipped: true,
113
+ reason: "sender not allowed",
114
+ extension: { id: "ext-1", name: "gate" },
115
+ });
116
+ expect(emitted).toEqual([c.event]);
117
+ });
118
+
119
+ test(`${c.name}: a non-blocked handler error still returns 500`, async () => {
120
+ outcome = "boom";
121
+ const { req, res, captured } = fakeReqRes(c.body, c.headers);
122
+ await handleWebhooks(req, res, c.path);
123
+ expect(captured.status).toBe(500);
124
+ expect(emitted).toEqual([]);
125
+ });
126
+ }
127
+ });
@@ -0,0 +1,203 @@
1
+ import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { z } from "zod";
4
+ import * as db from "../be/db";
5
+ import {
6
+ closeDb,
7
+ createWorkflow,
8
+ getApprovalRequestById,
9
+ getDbClient,
10
+ getWorkflowRun,
11
+ getWorkflowRunStepsByRunId,
12
+ initDb,
13
+ } from "../be/db";
14
+ import type { ExecutorMeta, WorkflowDefinition } from "../types";
15
+ import { startWorkflowExecution } from "../workflows/engine";
16
+ import {
17
+ BaseExecutor,
18
+ type ExecutorDependencies,
19
+ type ExecutorResult,
20
+ } from "../workflows/executors/base";
21
+ import { ExecutorRegistry } from "../workflows/executors/registry";
22
+ import { recoverIncompleteRuns } from "../workflows/recovery";
23
+
24
+ const TEST_DB_PATH = "./test-workflow-approval-recovery.sqlite";
25
+
26
+ class MockHITLExecutor extends BaseExecutor<
27
+ typeof MockHITLExecutor.schema,
28
+ typeof MockHITLExecutor.outSchema
29
+ > {
30
+ static readonly schema = z.object({ title: z.string() });
31
+ static readonly outSchema = z.object({
32
+ requestId: z.string(),
33
+ status: z.string(),
34
+ responses: z.record(z.string(), z.unknown()).nullable(),
35
+ });
36
+
37
+ readonly type = "mock-hitl";
38
+ readonly mode = "async" as const;
39
+ readonly configSchema = MockHITLExecutor.schema;
40
+ readonly outputSchema = MockHITLExecutor.outSchema;
41
+ lastRequestId: string | null = null;
42
+
43
+ protected async execute(
44
+ config: z.infer<typeof MockHITLExecutor.schema>,
45
+ _context: Readonly<Record<string, unknown>>,
46
+ meta: ExecutorMeta,
47
+ ): Promise<ExecutorResult<z.infer<typeof MockHITLExecutor.outSchema>>> {
48
+ const requestId = crypto.randomUUID();
49
+ this.lastRequestId = requestId;
50
+ await this.deps.db.createApprovalRequest({
51
+ id: requestId,
52
+ title: config.title,
53
+ questions: [{ id: "q1", type: "approval", label: "Approve?", required: true }],
54
+ approvers: { policy: "any" as const },
55
+ workflowRunId: meta.runId,
56
+ workflowRunStepId: meta.stepId,
57
+ });
58
+ return {
59
+ status: "success",
60
+ async: true,
61
+ waitFor: "approval.resolved",
62
+ correlationId: requestId,
63
+ } as unknown as ExecutorResult<z.infer<typeof MockHITLExecutor.outSchema>>;
64
+ }
65
+ }
66
+
67
+ class EchoExecutor extends BaseExecutor<typeof EchoExecutor.schema, typeof EchoExecutor.outSchema> {
68
+ static readonly schema = z.object({ message: z.string() });
69
+ static readonly outSchema = z.object({ echo: z.string() });
70
+
71
+ readonly type = "echo";
72
+ readonly mode = "instant" as const;
73
+ readonly configSchema = EchoExecutor.schema;
74
+ readonly outputSchema = EchoExecutor.outSchema;
75
+
76
+ protected async execute(
77
+ config: z.infer<typeof EchoExecutor.schema>,
78
+ ): Promise<ExecutorResult<z.infer<typeof EchoExecutor.outSchema>>> {
79
+ return { status: "success", output: { echo: config.message } };
80
+ }
81
+ }
82
+
83
+ const deps: ExecutorDependencies = {
84
+ db: db as typeof import("../be/db"),
85
+ eventBus: { emit: () => {}, on: () => {}, off: () => {} },
86
+ interpolate: (t: string) => t,
87
+ };
88
+
89
+ const definition: WorkflowDefinition = {
90
+ nodes: [
91
+ {
92
+ id: "review",
93
+ type: "mock-hitl",
94
+ config: { title: "Review" },
95
+ next: { approved: "deploy", rejected: "reject", timeout: "notify-timeout" },
96
+ },
97
+ { id: "deploy", type: "echo", config: { message: "deploying" } },
98
+ { id: "reject", type: "echo", config: { message: "rejected" } },
99
+ { id: "notify-timeout", type: "echo", config: { message: "timed out" } },
100
+ ],
101
+ };
102
+
103
+ let counter = 0;
104
+
105
+ async function startWaitingRun(registry: ExecutorRegistry) {
106
+ counter++;
107
+ const workflow = await createWorkflow({
108
+ name: `approval-recovery-${counter}-${Date.now()}`,
109
+ definition,
110
+ });
111
+ const runId = await startWorkflowExecution(workflow, {}, registry);
112
+ const requestId = (registry.get("mock-hitl") as MockHITLExecutor).lastRequestId!;
113
+ expect((await getWorkflowRun(runId))!.status).toBe("waiting");
114
+ return { runId, requestId };
115
+ }
116
+
117
+ function makeRegistry(): ExecutorRegistry {
118
+ const registry = new ExecutorRegistry();
119
+ registry.register(new MockHITLExecutor(deps));
120
+ registry.register(new EchoExecutor(deps));
121
+ return registry;
122
+ }
123
+
124
+ describe("recoverApprovalWaitingRuns", () => {
125
+ beforeAll(async () => {
126
+ try {
127
+ await unlink(TEST_DB_PATH);
128
+ } catch {}
129
+ initDb(TEST_DB_PATH);
130
+ });
131
+
132
+ afterAll(async () => {
133
+ closeDb();
134
+ for (const suffix of ["", "-wal", "-shm"]) {
135
+ try {
136
+ await unlink(`${TEST_DB_PATH}${suffix}`);
137
+ } catch {}
138
+ }
139
+ });
140
+
141
+ test("a cancelled request never routes its waiting step", async () => {
142
+ const registry = makeRegistry();
143
+ const { runId, requestId } = await startWaitingRun(registry);
144
+ await getDbClient().run(
145
+ "UPDATE approval_requests SET status = 'cancelled', resolutionReason = 'x' WHERE id = ?",
146
+ [requestId],
147
+ );
148
+
149
+ await recoverIncompleteRuns(registry);
150
+
151
+ expect((await getWorkflowRun(runId))!.status).toBe("waiting");
152
+ const steps = await getWorkflowRunStepsByRunId(runId);
153
+ expect(steps.map((s) => s.nodeId)).toEqual(["review"]);
154
+ expect(steps[0]!.status).toBe("waiting");
155
+ });
156
+
157
+ test("an expired pending request becomes timeout and routes on the timeout port", async () => {
158
+ const registry = makeRegistry();
159
+ const { runId, requestId } = await startWaitingRun(registry);
160
+ const expiresAt = new Date(Date.now() - 60_000).toISOString();
161
+ await getDbClient().run("UPDATE approval_requests SET expiresAt = ? WHERE id = ?", [
162
+ expiresAt,
163
+ requestId,
164
+ ]);
165
+
166
+ await recoverIncompleteRuns(registry);
167
+
168
+ const request = await getApprovalRequestById(requestId);
169
+ expect(request!.status).toBe("timeout");
170
+ expect(request!.resolutionReason).toStartWith("Timed out: no answer before");
171
+ const nodeIds = (await getWorkflowRunStepsByRunId(runId)).map((s) => s.nodeId);
172
+ expect(nodeIds).toContain("notify-timeout");
173
+ expect(nodeIds).not.toContain("deploy");
174
+ });
175
+
176
+ test("a request cancelled after the snapshot never routes its waiting step", async () => {
177
+ const registry = makeRegistry();
178
+ const { runId, requestId } = await startWaitingRun(registry);
179
+ await getDbClient().run("UPDATE approval_requests SET expiresAt = ? WHERE id = ?", [
180
+ new Date(Date.now() - 60_000).toISOString(),
181
+ requestId,
182
+ ]);
183
+
184
+ // The snapshot still reads pending past expiresAt; a cancel then lands
185
+ // before the recovery pass writes timeout.
186
+ const original = db.getStuckApprovalRuns;
187
+ const spy = spyOn(db, "getStuckApprovalRuns").mockImplementation(async () => {
188
+ const rows = await original();
189
+ await db.cancelApprovalRequestById(requestId, { reason: "cancelled", resolvedBy: null });
190
+ return rows;
191
+ });
192
+ try {
193
+ await recoverIncompleteRuns(registry);
194
+ } finally {
195
+ spy.mockRestore();
196
+ }
197
+
198
+ expect((await getApprovalRequestById(requestId))!.status).toBe("cancelled");
199
+ const steps = await getWorkflowRunStepsByRunId(runId);
200
+ expect(steps.map((s) => s.nodeId)).toEqual(["review"]);
201
+ expect(steps[0]!.status).toBe("waiting");
202
+ });
203
+ });
@@ -147,7 +147,7 @@ async function makeWorkflow(def: WorkflowDefinition): Promise<Workflow> {
147
147
  * Returns the run ID and step details for verification.
148
148
  */
149
149
  async function runHITLWorkflow(
150
- approvalStatus: "approved" | "rejected" | "timeout",
150
+ approvalStatus: "approved" | "rejected" | "timeout" | "cancelled",
151
151
  eventBus: InProcessEventBus,
152
152
  registry: ExecutorRegistry,
153
153
  ) {
@@ -287,6 +287,27 @@ describe("HITL port-based routing", () => {
287
287
  expect(stepNodeIds).not.toContain("notify-timeout");
288
288
  });
289
289
 
290
+ test("cancelled → routes to no port; the step stays waiting", async () => {
291
+ const eventBus = new InProcessEventBus();
292
+ const registry = new ExecutorRegistry();
293
+ registry.register(new MockHITLExecutor({ ...mockDeps, eventBus }));
294
+ registry.register(new EchoExecutor({ ...mockDeps, eventBus }));
295
+ setupWorkflowResumeListener(eventBus, registry);
296
+
297
+ const { runId } = await runHITLWorkflow("cancelled", eventBus, registry);
298
+
299
+ const run = await getWorkflowRun(runId);
300
+ expect(run!.status).toBe("waiting");
301
+
302
+ const steps = await getWorkflowRunStepsByRunId(runId);
303
+ expect(steps.find((s) => s.nodeId === "review")!.status).toBe("waiting");
304
+ expect(steps.map((s) => s.nodeId).sort()).toEqual(["review", "start"]);
305
+ const tasks = await db
306
+ .getDbClient()
307
+ .query<{ id: string }>("SELECT id FROM agent_tasks WHERE workflowRunId = ?", [runId]);
308
+ expect(tasks).toHaveLength(0);
309
+ });
310
+
290
311
  test("timeout → routes to 'timeout' port successor (notify-timeout)", async () => {
291
312
  const eventBus = new InProcessEventBus();
292
313
  const registry = new ExecutorRegistry();
@@ -0,0 +1,52 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ const postMessage = mock(async (_args: Record<string, unknown>) => ({ ts: "1.0" }));
4
+ mock.module("../slack/app", () => ({
5
+ getSlackApp: () => ({ client: { chat: { postMessage } } }),
6
+ }));
7
+
8
+ import type { ExecutorDependencies } from "../workflows/executors/base";
9
+ import { HumanInTheLoopExecutor } from "../workflows/executors/human-in-the-loop";
10
+
11
+ describe("HITL Slack card timeout caption", () => {
12
+ test("says the request times out, never that it auto-rejects", async () => {
13
+ const deps: ExecutorDependencies = {
14
+ db: {
15
+ getApprovalRequestByStepId: async () => null,
16
+ createApprovalRequest: async () => ({ status: "pending" }),
17
+ updateApprovalRequestNotifications: async () => {},
18
+ getApprovalRequestById: async () => ({ status: "pending" }),
19
+ } as unknown as typeof import("../be/db"),
20
+ eventBus: { emit: () => {}, on: () => {}, off: () => {} },
21
+ interpolate: (template: string) => template,
22
+ };
23
+ const executor = new HumanInTheLoopExecutor(deps);
24
+
25
+ await executor.run({
26
+ config: {
27
+ title: "Deploy",
28
+ questions: [{ id: "q1", type: "approval", label: "Approve?", required: true }],
29
+ approvers: { policy: "any" },
30
+ timeout: { seconds: 3600, action: "reject" },
31
+ notifications: [{ channel: "slack", target: "C1" }],
32
+ },
33
+ context: {},
34
+ meta: {
35
+ runId: crypto.randomUUID(),
36
+ stepId: crypto.randomUUID(),
37
+ nodeId: "review",
38
+ workflowId: crypto.randomUUID(),
39
+ dryRun: false,
40
+ },
41
+ });
42
+ // Notifications are fire-and-forget; wait for the dispatch to post.
43
+ for (let i = 0; i < 50 && postMessage.mock.calls.length === 0; i++) {
44
+ await Bun.sleep(10);
45
+ }
46
+
47
+ expect(postMessage).toHaveBeenCalledTimes(1);
48
+ const blocks = JSON.stringify(postMessage.mock.calls[0]![0].blocks);
49
+ expect(blocks).toContain("times out if not responded");
50
+ expect(blocks).not.toContain("auto-rejects");
51
+ });
52
+ });
@@ -753,7 +753,7 @@ describe("Workflow HTTP API v2", () => {
753
753
  // List all runs
754
754
  const res1 = await fetch(`${baseUrl}/api/workflows/${workflow.id}/runs`, { headers });
755
755
  expect(res1.status).toBe(200);
756
- const allRuns = (await res1.json()) as WorkflowRun[];
756
+ const allRuns = ((await res1.json()) as { runs: WorkflowRun[] }).runs;
757
757
  expect(allRuns.length).toBeGreaterThanOrEqual(1);
758
758
 
759
759
  // Filter by status — use a status that likely doesn't match
@@ -761,14 +761,14 @@ describe("Workflow HTTP API v2", () => {
761
761
  headers,
762
762
  });
763
763
  expect(res2.status).toBe(200);
764
- const filteredRuns = (await res2.json()) as WorkflowRun[];
764
+ const filteredRuns = ((await res2.json()) as { runs: WorkflowRun[] }).runs;
765
765
  // All returned runs should have the requested status
766
766
  for (const run of filteredRuns) {
767
767
  expect(run.status).toBe("waiting");
768
768
  }
769
769
  });
770
770
 
771
- test("supports deterministic limit/offset pages while preserving the omitted legacy shape", async () => {
771
+ test("supports deterministic limit/offset pages", async () => {
772
772
  const workflow = await createTestWorkflow();
773
773
  const runIds = Array.from({ length: 5 }, () => crypto.randomUUID());
774
774
  const statuses = ["running", "failed", "running", "failed", "running"] as const;
@@ -782,11 +782,11 @@ describe("Workflow HTTP API v2", () => {
782
782
  ]);
783
783
  }
784
784
 
785
- const legacyRes = await fetch(`${baseUrl}/api/workflows/${workflow.id}/runs`, { headers });
786
- expect(legacyRes.status).toBe(200);
787
- const legacyBody = (await legacyRes.json()) as WorkflowRun[];
788
- expect(Array.isArray(legacyBody)).toBe(true);
789
- expect(legacyBody.map((run) => run.id)).toEqual([...runIds].reverse());
785
+ const defaultRes = await fetch(`${baseUrl}/api/workflows/${workflow.id}/runs`, { headers });
786
+ expect(defaultRes.status).toBe(200);
787
+ const defaultBody = (await defaultRes.json()) as { runs: WorkflowRun[]; page: unknown };
788
+ expect(defaultBody.runs.map((run) => run.id)).toEqual([...runIds].reverse());
789
+ expect(defaultBody.page).toEqual({ limit: 50, offset: 0, total: 5, hasMore: false });
790
790
 
791
791
  const pageRes = await fetch(`${baseUrl}/api/workflows/${workflow.id}/runs?limit=2&offset=1`, {
792
792
  headers,
@@ -829,6 +829,97 @@ describe("Workflow HTTP API v2", () => {
829
829
  expect(invalidRes.status).toBe(400);
830
830
  });
831
831
 
832
+ test("caps an unpaged request at 50 runs and pages through the rest", async () => {
833
+ const workflow = await createTestWorkflow();
834
+ const total = 53;
835
+ for (let index = 0; index < total; index++) {
836
+ const id = crypto.randomUUID();
837
+ await createWorkflowRun({ id, workflowId: workflow.id });
838
+ await getDbClient().run("UPDATE workflow_runs SET startedAt = ? WHERE id = ?", [
839
+ new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(),
840
+ id,
841
+ ]);
842
+ }
843
+
844
+ const first = (await (
845
+ await fetch(`${baseUrl}/api/workflows/${workflow.id}/runs`, { headers })
846
+ ).json()) as {
847
+ runs: WorkflowRun[];
848
+ page: {
849
+ limit: number;
850
+ offset: number;
851
+ total: number;
852
+ hasMore: boolean;
853
+ nextOffset?: number;
854
+ };
855
+ };
856
+ expect(first.runs).toHaveLength(50);
857
+ expect(first.page).toEqual({ limit: 50, offset: 0, total, hasMore: true, nextOffset: 50 });
858
+
859
+ const second = (await (
860
+ await fetch(
861
+ `${baseUrl}/api/workflows/${workflow.id}/runs?offset=${first.page.nextOffset}`,
862
+ {
863
+ headers,
864
+ },
865
+ )
866
+ ).json()) as { runs: WorkflowRun[]; page: { hasMore: boolean } };
867
+ expect(second.runs).toHaveLength(3);
868
+ expect(second.page.hasMore).toBe(false);
869
+ expect(new Set([...first.runs, ...second.runs].map((run) => run.id)).size).toBe(total);
870
+ });
871
+
872
+ test("list rows carry triggerData but not context; the detail route serves context", async () => {
873
+ const workflow = await createTestWorkflow();
874
+ const run = await createWorkflowRun({
875
+ id: crypto.randomUUID(),
876
+ workflowId: workflow.id,
877
+ triggerData: { topic: "runs" },
878
+ });
879
+ await updateWorkflowRun(run.id, { context: { nodeOutput: "x".repeat(50_000) } });
880
+
881
+ const body = (await (
882
+ await fetch(`${baseUrl}/api/workflows/${workflow.id}/runs`, { headers })
883
+ ).json()) as { runs: Array<Record<string, unknown>> };
884
+ expect(body.runs).toHaveLength(1);
885
+ expect(body.runs[0]!.id).toBe(run.id);
886
+ expect(body.runs[0]!.triggerData).toEqual({ topic: "runs" });
887
+ expect("context" in body.runs[0]!).toBe(false);
888
+
889
+ const detail = (await (
890
+ await fetch(`${baseUrl}/api/workflow-runs/${run.id}`, { headers })
891
+ ).json()) as { run: WorkflowRun };
892
+ expect(detail.run.context).toEqual({ nodeOutput: "x".repeat(50_000) });
893
+ });
894
+
895
+ test("a run page and its count read from the workflow indexes without a sort or cross-workflow scan", async () => {
896
+ const explain = async (sql: string, params: string[]) =>
897
+ (await getDbClient().query<{ detail: string }>(`EXPLAIN QUERY PLAN ${sql}`, params))
898
+ .map((row) => row.detail)
899
+ .join("\n");
900
+ const workflowId = crypto.randomUUID();
901
+
902
+ const page = await explain(
903
+ "SELECT id FROM workflow_runs WHERE workflowId = ? ORDER BY startedAt DESC, id DESC LIMIT 50",
904
+ [workflowId],
905
+ );
906
+ expect(page).toContain("idx_workflow_runs_workflow_started");
907
+ expect(page).not.toContain("USE TEMP B-TREE");
908
+
909
+ const filtered = await explain(
910
+ "SELECT id FROM workflow_runs WHERE workflowId = ? AND status = ? ORDER BY startedAt DESC, id DESC LIMIT 50",
911
+ [workflowId, "failed"],
912
+ );
913
+ expect(filtered).toContain("idx_workflow_runs_workflow_status_started");
914
+ expect(filtered).not.toContain("USE TEMP B-TREE");
915
+
916
+ const count = await explain(
917
+ "SELECT COUNT(*) FROM workflow_runs WHERE workflowId = ? AND status = ?",
918
+ [workflowId, "failed"],
919
+ );
920
+ expect(count).toContain("COVERING INDEX idx_workflow_runs_workflow_status_started");
921
+ });
922
+
832
923
  test("MCP run listing defaults to bounded slim rows and preserves an explicit full-row opt-in", async () => {
833
924
  const workflow = await createTestWorkflow();
834
925
  const largeValue = "x".repeat(100_000);
@@ -47,6 +47,19 @@ class NumberExecutor extends BaseExecutor<
47
47
  }
48
48
  }
49
49
 
50
+ /**
51
+ * NumberExecutor that also emits `nextPort: "success"`, like `script` / `swarm-script`.
52
+ */
53
+ class SuccessPortNumberExecutor extends NumberExecutor {
54
+ override readonly type = "success-port-number";
55
+
56
+ protected override async execute(
57
+ config: z.infer<typeof NumberExecutor.schema>,
58
+ ): Promise<ExecutorResult<z.infer<typeof NumberExecutor.outSchema>>> {
59
+ return { status: "success", output: { result: config.value }, nextPort: "success" };
60
+ }
61
+ }
62
+
50
63
  /**
51
64
  * Validate executor stub that checks if the target node's result is > 50.
52
65
  * Returns { pass: true } or { pass: false, reason: string }.
@@ -112,6 +125,7 @@ function createTestRegistry(): ExecutorRegistry {
112
125
  const registry = new ExecutorRegistry();
113
126
  registry.register(new NumberExecutor(mockDeps));
114
127
  registry.register(new ValidateStubExecutor(mockDeps));
128
+ registry.register(new SuccessPortNumberExecutor(mockDeps));
115
129
  return registry;
116
130
  }
117
131
 
@@ -249,6 +263,48 @@ describe("Retry Poller — Validation on Retry", () => {
249
263
  expect(validateCallCount).toBeGreaterThanOrEqual(1);
250
264
  });
251
265
 
266
+ test('retried script-like node routes to "pass" once validation passes', async () => {
267
+ validateCallCount = 0;
268
+ validateAlwaysPass = false;
269
+
270
+ const workflow = await makeWorkflow({
271
+ nodes: [
272
+ {
273
+ id: "n1",
274
+ type: "success-port-number",
275
+ config: { value: 10 },
276
+ next: { pass: "on-pass", fail: "on-fail" },
277
+ validation: {
278
+ executor: "validate",
279
+ config: { prompt: "Is result > 50?" },
280
+ mustPass: true,
281
+ retry: { strategy: "static", maxRetries: 2, baseDelayMs: 10, maxDelayMs: 10 },
282
+ },
283
+ },
284
+ { id: "on-pass", type: "number-gen", config: { value: 1 } },
285
+ { id: "on-fail", type: "number-gen", config: { value: 2 } },
286
+ ],
287
+ });
288
+
289
+ const runId = await startWorkflowExecution(workflow, {}, registry);
290
+ const initial = (await getWorkflowRunStepsByRunId(runId)).find((s) => s.nodeId === "n1")!;
291
+ expect(initial.status).toBe("failed");
292
+ expect(initial.nextRetryAt).toBeTruthy();
293
+
294
+ validateAlwaysPass = true;
295
+ await new Promise((resolve) => setTimeout(resolve, 10));
296
+ startRetryPoller(registry, 10);
297
+ await new Promise((resolve) => setTimeout(resolve, 150));
298
+ stopRetryPoller();
299
+ validateAlwaysPass = false;
300
+
301
+ const steps = await getWorkflowRunStepsByRunId(runId);
302
+ const nodeIds = steps.map((s) => s.nodeId);
303
+ expect(steps.find((s) => s.nodeId === "n1")!.nextPort).toBe("pass");
304
+ expect(nodeIds).toContain("on-pass");
305
+ expect(nodeIds).not.toContain("on-fail");
306
+ });
307
+
252
308
  test("validation halt on retry → step and run marked failed", async () => {
253
309
  validateCallCount = 0;
254
310
  validateAlwaysPass = false;