@desplega.ai/agent-swarm 2.4.5 → 2.5.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.
- package/README.md +4 -2
- package/dist/{acp-adapter-2wdkktv6.js → acp-adapter-11gp67nr.js} +8 -6
- package/dist/{actions-c40ebcm4.js → actions-8p2kze8j.js} +23 -21
- package/dist/{amp-adapter-wb67m95z.js → amp-adapter-z4qvhacj.js} +2 -2
- package/dist/{anthropic-messages-g6pqmqk4.js → anthropic-messages-txs190dd.js} +17 -20
- package/dist/{app-353vre8g.js → app-0qp0a2gk.js} +3 -3
- package/dist/{assistant-sj1njh23.js → assistant-jedd3ktm.js} +24 -22
- package/dist/{azure-openai-responses-9dbmj346.js → azure-openai-responses-9kfwhmn7.js} +18 -13
- package/dist/{boot-reembed-qdkvxeem.js → boot-reembed-4xwq5tg0.js} +1 -1
- package/dist/{boot-reembed-b9c3az3c.js → boot-reembed-n4wq5dd2.js} +1 -1
- package/dist/{boot-scrub-sweep-m3wh8eks.js → boot-scrub-sweep-69h90xcq.js} +4 -4
- package/dist/{boot-seeds-j2273s54.js → boot-seeds-3vn0m9sg.js} +4 -4
- package/dist/{claude-adapter-m7q0a085.js → claude-adapter-221mvad5.js} +8 -6
- package/dist/{claude-managed-adapter-5ptb6tzs.js → claude-managed-adapter-gt6pa35d.js} +2 -2
- package/dist/{claude-managed-setup-smrqk5j5.js → claude-managed-setup-c3hp36ph.js} +4 -4
- package/dist/{claude-sdk-session-38xnz21e.js → claude-sdk-session-8thjsk5e.js} +9012 -8928
- package/dist/{cli-b9fcxxev.js → cli-007cnhm1.js} +50 -4
- package/dist/{cli-j4rqdf2n.js → cli-03vpq45j.js} +2 -2
- package/dist/{cli-dk44dxec.js → cli-0y6f0ckq.js} +1 -1
- package/dist/{cli-kpw04va0.js → cli-0z3vjrpm.js} +1 -1
- package/dist/{cli-94gwyg7m.js → cli-179xcyky.js} +9 -9
- package/dist/{cli-3r8samv3.js → cli-1f0gp9vz.js} +3 -2
- package/dist/{cli-4zfqgjc5.js → cli-1pr672hz.js} +2 -2
- package/dist/{cli-0bqteszy.js → cli-2nf3b08t.js} +328 -25
- package/dist/{cli-43x64hv3.js → cli-36j4jnk3.js} +4 -4
- package/dist/{cli-k8g16gz3.js → cli-3qv8naq8.js} +115 -119
- package/dist/cli-3zwye9sn.js +1193 -0
- package/dist/{cli-60cgh1rv.js → cli-459fk97x.js} +7 -7
- package/dist/{cli-v8fjakwf.js → cli-4w24f7z3.js} +1 -1
- package/dist/{cli-n72rg53p.js → cli-5kzk3472.js} +812 -418
- package/dist/{cli-h7gc0b4r.js → cli-5m84zd2k.js} +55 -18
- package/dist/{cli-j99azg3g.js → cli-5rvg1ddk.js} +5 -5
- package/dist/{cli-dvkmp9bt.js → cli-6ctwrf1z.js} +20 -10
- package/dist/{cli-848hxdwj.js → cli-6rg2jn2y.js} +2 -1
- package/dist/{cli-fv5hepyy.js → cli-7bwtc6yx.js} +3 -3
- package/dist/{cli-ms85309d.js → cli-7g40g8hq.js} +11 -11
- package/dist/{cli-xpa64rek.js → cli-92mqenkc.js} +2362 -159
- package/dist/{cli-018r4jq6.js → cli-9ce2xw98.js} +8 -8
- package/dist/{cli-1syn2xy1.js → cli-bgdtv62r.js} +32 -18
- package/dist/{cli-wxbn3yfx.js → cli-cz5nmdxt.js} +2 -2
- package/dist/{cli-wt645v7q.js → cli-dabehxhp.js} +1 -1
- package/dist/{cli-vfha21c9.js → cli-e5bxnjps.js} +2 -2
- package/dist/{cli-wt0jf9hm.js → cli-fcc5qe3f.js} +553 -193
- package/dist/{cli-ftc1g5tj.js → cli-fes51ya2.js} +3 -3
- package/dist/{cli-3a45zcgm.js → cli-ftmq4nvk.js} +2 -2
- package/dist/{cli-37j04k05.js → cli-fy1jyyym.js} +13 -13
- package/dist/{cli-s95dknxh.js → cli-g1cw7xtt.js} +1 -1
- package/dist/{cli-74c1wpm0.js → cli-gpv60278.js} +1 -1
- package/dist/{cli-pemywhfq.js → cli-ha9rmwgq.js} +2 -1
- package/dist/{cli-g35m2xwm.js → cli-j1eghqfm.js} +2 -2
- package/dist/{cli-qfxc84gh.js → cli-jpxsrxcb.js} +1 -1
- package/dist/{cli-ejs3m1sx.js → cli-kxpfx2xr.js} +1 -1
- package/dist/{cli-p14m2cnp.js → cli-mbfj11fq.js} +1 -1
- package/dist/{cli-pnp2j5zg.js → cli-mmhj8vd7.js} +2 -2
- package/dist/{cli-xf234ws8.js → cli-n4c8e3s5.js} +2 -0
- package/dist/{cli-f01h25yt.js → cli-n6kmqngw.js} +2 -2
- package/dist/{cli-v0yrfmdh.js → cli-nnvjb17s.js} +3 -3
- package/dist/{cli-1dmcq2eh.js → cli-nzfnt2gs.js} +1 -1
- package/dist/{cli-625tb7aq.js → cli-p859hcc8.js} +2 -2
- package/dist/{cli-smvcw9z5.js → cli-pasyvjmm.js} +1 -1
- package/dist/cli-pkaqk57w.js +128 -0
- package/dist/{cli-8nyhf7za.js → cli-qqzmjxkf.js} +129 -105
- package/dist/{cli-9yqgm9nd.js → cli-r25qpaxt.js} +161 -63
- package/dist/{cli-eww120g8.js → cli-rh5smt8t.js} +18 -4
- package/dist/{cli-qwkcwjdn.js → cli-rycdaj0a.js} +1 -1
- package/dist/{cli-a92r1127.js → cli-s4sntwt4.js} +2 -2
- package/dist/{cli-be2161h1.js → cli-segs7hx9.js} +6 -6
- package/dist/{cli-36ckw0wx.js → cli-stxdrqs2.js} +2 -2
- package/dist/{cli-z7d29rw9.js → cli-sy8f9705.js} +4 -4
- package/dist/{cli-1m17dc9d.js → cli-ta8bd7th.js} +2 -2
- package/dist/{cli-st8aw1m6.js → cli-we0ra057.js} +4 -4
- package/dist/{cli-7c9qs530.js → cli-x21dqn18.js} +5 -5
- package/dist/{cli-1sp861s4.js → cli-x6vf4sad.js} +11 -11
- package/dist/{cli-d2mb5s8a.js → cli-x77gen2p.js} +680 -440
- package/dist/{cli-tw4p90jb.js → cli-xawhpgc9.js} +5 -5
- package/dist/{cli-f0g3pgvf.js → cli-xd4eqtr2.js} +2 -2
- package/dist/{cli-1f3qzw6y.js → cli-xt768r5m.js} +2 -2
- package/dist/{cli-cxxe61f6.js → cli-y4x8ww6s.js} +2 -2
- package/dist/{cli-bnes4z6b.js → cli-y9jvxfhk.js} +3 -3
- package/dist/{cli-qwhy2qma.js → cli-z63a269p.js} +2 -2
- package/dist/{cli-sm8rses9.js → cli-zfdp01hn.js} +8 -5
- package/dist/cli.js +29 -27
- package/dist/{cloudflare-workers-ai-system-one-ch2wxfcp.js → cloudflare-workers-ai-system-one-43v9qkjk.js} +7 -2
- package/dist/{codex-adapter-tn6k5mkg.js → codex-adapter-tcg1q3jk.js} +6 -4
- package/dist/{codex-login-m819c41r.js → codex-login-rn7c5gxt.js} +1 -1
- package/dist/{codex-session-runner-7vjt7jbw.js → codex-session-runner-xfx4wd9x.js} +7 -5
- package/dist/{commands-t7rz9146.js → commands-ew83nj70.js} +2 -2
- package/dist/{controller-m2jasma5.js → controller-gnq1k1mf.js} +4 -4
- package/dist/{controllers-connect-9ftpex99.js → controllers-connect-tw1dan9r.js} +25 -23
- package/dist/{cursor-adapter-mjv79e4z.js → cursor-adapter-yq5xgfcg.js} +1 -1
- package/dist/{db-6sfrn9g3.js → db-7gbc53fd.js} +14 -2
- package/dist/{db-rn673r06.js → db-t86xrnes.js} +3 -3
- package/dist/{devin-adapter-zgqadvnm.js → devin-adapter-4ycy4vrf.js} +1 -1
- package/dist/{dispatcher-c155xz0p.js → dispatcher-64bdyhps.js} +22 -20
- package/dist/{docker-fkgtr75t.js → docker-nfy2gc8t.js} +2 -2
- package/dist/{dsh-adapter-3mmszc1x.js → dsh-adapter-tvhc4k2d.js} +1 -1
- package/dist/{e2b-16d6ch39.js → e2b-9gkdhm3c.js} +2 -2
- package/dist/{e2b-zn0b4w5w.js → e2b-cjtf8k1z.js} +2 -2
- package/dist/{easter-egg-3d-5cgynaja.js → easter-egg-3d-e4qv8dxj.js} +1 -1
- package/dist/{event-bus-9vpk8gmq.js → event-bus-3z78ep5n.js} +1 -1
- package/dist/{execute-t4e6m3rw.js → execute-0xpz8950.js} +85 -23
- package/dist/{external-channel-35k6tkv8.js → external-channel-e9nw24zv.js} +2 -2
- package/dist/{google-generative-ai-bmq8gkf3.js → google-generative-ai-mk9vcpe1.js} +15 -15
- package/dist/{google-vertex-b1rvp52s.js → google-vertex-q580kz8k.js} +20 -20
- package/dist/{grok-adapter-tfrq7tg1.js → grok-adapter-v82srhrx.js} +9 -7
- package/dist/handlers-c9ajzhbn.js +41 -0
- package/dist/{heartbeat-dyg4npkp.js → heartbeat-2vqs1319.js} +25 -23
- package/dist/{hook-x26b31bg.js → hook-01nk13wx.js} +7 -5
- package/dist/{hook-drspbwpe.js → hook-jzpheegg.js} +9 -7
- package/dist/{http-pk972xyw.js → http-fc1k6ec8.js} +544 -76
- package/dist/index-07ef7nkm.js +42 -0
- package/dist/{index-g6tbb6ve.js → index-31ceyf42.js} +1 -1
- package/dist/index-7yts8n9s.js +43 -0
- package/dist/index-q3t40ryv.js +46 -0
- package/dist/{index-arytgj5q.js → index-sepja052.js} +1 -1
- package/dist/{index-jwxkkex2.js → index-zdycv3ja.js} +13 -11
- package/dist/{index-g216q6jr.js → index-zsyx51ap.js} +1 -4
- package/dist/{keepalive-7yrng7px.js → keepalive-5nh2rhmr.js} +4 -4
- package/dist/lead-aayq64jn.js +79 -0
- package/dist/{llama-cpp-classify-vv43gswy.js → llama-cpp-classify-ee7vmd7q.js} +4 -1
- package/dist/{maintenance-e6cemdqt.js → maintenance-hjhzdt3k.js} +1 -1
- package/dist/{mistral-conversations-1medfwv7.js → mistral-conversations-sd0xf1tn.js} +13 -13
- package/dist/{node-de6akczr.js → node-jzjj7r9x.js} +1 -1
- package/dist/{oauth-refresh-sweep-yw3xdbcj.js → oauth-refresh-sweep-y3n1knmd.js} +3 -3
- package/dist/{onboard-0a9y5yrc.js → onboard-1zq3m7db.js} +2 -2
- package/dist/{openai-codex-responses-de9pvs4d.js → openai-codex-responses-79s8xnbh.js} +29 -23
- package/dist/{openai-completions-mtqge3nf.js → openai-completions-tdbyts0x.js} +21 -20
- package/dist/{openai-decisions-ygk4kkxd.js → openai-decisions-bqhnjyfz.js} +2 -1
- package/dist/{openai-responses-dkyng71g.js → openai-responses-dm3t5vqp.js} +26 -17
- package/dist/{opencode-adapter-x4tqzhkh.js → opencode-adapter-e56086m1.js} +7 -5
- package/dist/{openrouter-images-wvv7camj.js → openrouter-images-ssttr1y4.js} +6 -3
- package/dist/{otel-impl-trjpkfb7.js → otel-impl-h9yyza47.js} +2 -2
- package/dist/{pi-messages-nhh1pkz2.js → pi-messages-3nf8bq1a.js} +6 -4
- package/dist/{pi-mono-adapter-mcfkn9vf.js → pi-mono-adapter-tzps8f38.js} +9 -37
- package/dist/{pricing-refresh-d2b5my0y.js → pricing-refresh-nevhat8h.js} +3 -3
- package/dist/{rbac-roles-nxh5ahwk.js → rbac-roles-4y6ngvzq.js} +1 -1
- package/dist/{rbac-roles-hey3yv9d.js → rbac-roles-exbg8xb6.js} +2 -2
- package/dist/{render-v2-rr3p2atg.js → render-v2-cfsh13z3.js} +3 -3
- package/dist/{resume-2efkf1s1.js → resume-5s2t0dbs.js} +10 -8
- package/dist/{runtime-xe3aqy4x.js → runtime-bt5pkpm1.js} +1 -1
- package/dist/{session-access-s2mttxpw.js → session-access-dnbyp3cn.js} +6 -4
- package/dist/{setup-vypma5yt.js → setup-1wj1y77j.js} +1 -1
- package/dist/{typesafe-system-one-2hgpayzy.js → typesafe-system-one-bre38hvs.js} +4 -2
- package/dist/{virtual-modules-4r3zkc20.js → virtual-modules-mnfbh5mr.js} +8 -2
- package/dist/{work-objects-rvhckdkp.js → work-objects-7mg6216g.js} +25 -23
- package/dist/worker-nwb789mg.js +79 -0
- package/dist/{x-y45mthwn.js → x-pbpezr0s.js} +2 -2
- package/openapi.json +785 -5
- package/package.json +9 -9
- package/src/assets/key.ts +17 -0
- package/src/azure-devops/handlers.ts +1 -1
- package/src/be/db/approvals.ts +18 -0
- package/src/be/db/session-moves.ts +164 -0
- package/src/be/db/session-tree.ts +384 -0
- package/src/be/db/tasks/read.ts +40 -4
- package/src/be/db/workflow-runs.ts +96 -0
- package/src/be/db.ts +80 -236
- package/src/be/onboarding.ts +3 -0
- package/src/be/skill-sync.ts +43 -2
- package/src/be/swarm-config-guard.ts +1 -0
- package/src/bitbucket/api.ts +81 -0
- package/src/bitbucket/auth.ts +64 -0
- package/src/bitbucket/handlers.ts +434 -0
- package/src/bitbucket/index.ts +28 -0
- package/src/bitbucket/templates.ts +134 -0
- package/src/bitbucket/types.ts +65 -0
- package/src/commands/runner.ts +55 -3
- package/src/e2b/env.ts +1 -0
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/extensions/contract.ts +2 -2
- package/src/extensions/post-bridge.ts +1 -0
- package/src/github/handlers.ts +6 -6
- package/src/gitlab/handlers.ts +5 -5
- package/src/http/assets.ts +14 -2
- package/src/http/core.ts +12 -0
- package/src/http/index.ts +4 -0
- package/src/http/onboarding.ts +1 -0
- package/src/http/sessions.ts +366 -12
- package/src/http/skills.ts +48 -1
- package/src/http/tasks.ts +1 -1
- package/src/http/users.ts +1 -0
- package/src/http/webhooks.ts +141 -0
- package/src/integrations/chat-sdk/adapters.ts +19 -3
- package/src/integrations/chat-sdk/facts.ts +30 -12
- package/src/integrations/chat-sdk/outbound.ts +38 -1
- package/src/providers/acp-targets.ts +1 -1
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/pi-mono-adapter.ts +5 -53
- package/src/scripts-runtime/types/swarm-extension.d.ts +6 -4
- package/src/slack/handlers.test.ts +158 -0
- package/src/slack/handlers.ts +19 -28
- package/src/tasks/context-key.ts +61 -1
- package/src/tests/bitbucket-auth.test.ts +156 -0
- package/src/tests/bitbucket-handlers.test.ts +425 -0
- package/src/tests/bitbucket-webhook.test.ts +220 -0
- package/src/tests/chat-sdk-inbound.test.ts +39 -0
- package/src/tests/chat-sdk-telegram-markdown.test.ts +92 -0
- package/src/tests/context-key.test.ts +50 -0
- package/src/tests/core-auth.test.ts +5 -0
- package/src/tests/db-task-read-contract.test.ts +7 -0
- package/src/tests/db-task-write-contract.test.ts +6 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/fixtures/bitbucket/pullrequest-comment-created-inline.json +174 -0
- package/src/tests/fixtures/bitbucket/pullrequest-comment-created.json +170 -0
- package/src/tests/fixtures/bitbucket/pullrequest-created-mention.json +132 -0
- package/src/tests/fixtures/bitbucket/pullrequest-created-reviewer.json +140 -0
- package/src/tests/fixtures/bitbucket/pullrequest-fulfilled.json +134 -0
- package/src/tests/fixtures/bitbucket/pullrequest-rejected.json +133 -0
- package/src/tests/generate-mcp-docs.test.ts +101 -0
- package/src/tests/gitlab-handlers.test.ts +3 -3
- package/src/tests/gitlab-vcs-db.test.ts +4 -4
- package/src/tests/integration-disable-flags.test.ts +36 -0
- package/src/tests/mcp-tools.test.ts +1 -1
- package/src/tests/onboarding.test.ts +12 -0
- package/src/tests/pi-codemode.test.ts +8 -2
- package/src/tests/preload-env.test.ts +57 -0
- package/src/tests/preload.ts +21 -0
- package/src/tests/prompt-template-remaining.test.ts +38 -0
- package/src/tests/run-root-coverage.test.ts +39 -0
- package/src/tests/runner-bitbucket-vcs.test.ts +191 -0
- package/src/tests/runner-skills-refresh.test.ts +58 -0
- package/src/tests/secret-scrubber.test.ts +15 -0
- package/src/tests/sessions-move.test.ts +576 -0
- package/src/tests/sessions-tree.test.ts +493 -0
- package/src/tests/skill-files-http.test.ts +50 -0
- package/src/tests/skills-bundle.test.ts +99 -0
- package/src/tests/swarm-config-export-to-env.test.ts +2 -1
- package/src/tests/vcs-bitbucket.test.ts +91 -0
- package/src/tests/vcs-provider-isolation.test.ts +123 -0
- package/src/tests/vcs-provider.test.ts +16 -1
- package/src/tests/vcs-tracking.test.ts +3 -3
- package/src/tests/workflow-foreach-bodies.test.ts +637 -0
- package/src/tests/workflow-foreach-concurrency.test.ts +342 -0
- package/src/tests/workflow-foreach-retry.test.ts +537 -0
- package/src/tests/workflow-foreach.test.ts +44 -23
- package/src/tests/workflow-step-claims.test.ts +6 -6
- package/src/tests/workflow-tla-races.test.ts +2 -2
- package/src/tools/script-delete.ts +1 -1
- package/src/tools/workflows/create-workflow.ts +5 -3
- package/src/types.ts +3 -2
- package/src/utils/config-env-export.ts +1 -0
- package/src/utils/secret-scrubber.ts +4 -0
- package/src/utils/skills-refresh.ts +83 -43
- package/src/vcs/bitbucket.ts +58 -0
- package/src/vcs/index.ts +2 -0
- package/src/vcs/types.ts +2 -2
- package/src/workflows/approval-resolution.ts +4 -1
- package/src/workflows/checkpoint.ts +9 -1
- package/src/workflows/constants.ts +3 -0
- package/src/workflows/definition.ts +48 -17
- package/src/workflows/engine.ts +25 -15
- package/src/workflows/executors/foreach.ts +45 -99
- package/src/workflows/foreach-body.ts +146 -0
- package/src/workflows/foreach-driver.ts +552 -0
- package/src/workflows/foreach-join.ts +206 -7
- package/src/workflows/node-timeout.ts +21 -0
- package/src/workflows/recovery.ts +68 -3
- package/src/workflows/resume.ts +221 -43
- package/src/workflows/retry-poller.ts +39 -24
- package/src/workflows/step-input.ts +59 -0
- package/src/workflows/task-step-routing.ts +114 -56
- package/templates/skills/slack-interaction/content.md +5 -0
- package/templates/skills/work-on-task/SKILL.md +1 -1
- package/templates/skills/work-on-task/content.md +1 -1
- package/dist/cli-9hstj09h.js +0 -2657
- package/dist/cli-yw91rnmw.js +0 -13
- package/dist/handlers-7gw64dxw.js +0 -39
- package/dist/index-bgdyvcnv.js +0 -40
- package/dist/index-gabbcdn2.js +0 -43
- package/dist/index-tfb26zhy.js +0 -41
- package/dist/lead-7e02z839.js +0 -77
- package/dist/worker-5snytpbd.js +0 -77
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, 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
|
+
completeTask,
|
|
8
|
+
createAgent,
|
|
9
|
+
createWorkflow,
|
|
10
|
+
getApprovalRequestByStepId,
|
|
11
|
+
getDbClient,
|
|
12
|
+
getTaskByWorkflowRunStepId,
|
|
13
|
+
getWaitStateByStepId,
|
|
14
|
+
getWorkflowRun,
|
|
15
|
+
getWorkflowRunStepsByRunId,
|
|
16
|
+
initDb,
|
|
17
|
+
resolveApprovalRequest,
|
|
18
|
+
} from "../be/db";
|
|
19
|
+
import type { Workflow, WorkflowDefinition, WorkflowRun, WorkflowRunStep } from "../types";
|
|
20
|
+
import { validateDefinition } from "../workflows/definition";
|
|
21
|
+
import { startWorkflowExecution } from "../workflows/engine";
|
|
22
|
+
import { workflowEventBus } from "../workflows/event-bus";
|
|
23
|
+
import {
|
|
24
|
+
BaseExecutor,
|
|
25
|
+
type ExecutorDependencies,
|
|
26
|
+
type ExecutorResult,
|
|
27
|
+
} from "../workflows/executors/base";
|
|
28
|
+
import { createExecutorRegistry } from "../workflows/executors/registry";
|
|
29
|
+
import {
|
|
30
|
+
FOREACH_BODY_ACCEPTED,
|
|
31
|
+
FOREACH_BODY_REJECTED,
|
|
32
|
+
isForeachBodyType,
|
|
33
|
+
} from "../workflows/foreach-body";
|
|
34
|
+
import { drainForeachDrives } from "../workflows/foreach-driver";
|
|
35
|
+
import { recoverIncompleteRuns } from "../workflows/recovery";
|
|
36
|
+
import {
|
|
37
|
+
cancelWorkflowRun,
|
|
38
|
+
resumeWaitState,
|
|
39
|
+
setupWorkflowResumeListener,
|
|
40
|
+
} from "../workflows/resume";
|
|
41
|
+
import { SKIP_SANDBOX_SPAWN_TESTS } from "./sandbox-spawn-test-helpers";
|
|
42
|
+
|
|
43
|
+
const TEST_DB_PATH = "./test-workflow-foreach-bodies.sqlite";
|
|
44
|
+
const spawnTest = test.skipIf(SKIP_SANDBOX_SPAWN_TESTS);
|
|
45
|
+
|
|
46
|
+
const deps: ExecutorDependencies = {
|
|
47
|
+
db,
|
|
48
|
+
eventBus: workflowEventBus,
|
|
49
|
+
interpolate: (t: string) => t,
|
|
50
|
+
};
|
|
51
|
+
const registry = createExecutorRegistry(deps);
|
|
52
|
+
const builtInTypes = registry.types();
|
|
53
|
+
|
|
54
|
+
/** An instant node that echoes its config. */
|
|
55
|
+
class EchoExecutor extends BaseExecutor<z.ZodObject, z.ZodObject> {
|
|
56
|
+
readonly type = "test-echo";
|
|
57
|
+
readonly mode = "instant" as const;
|
|
58
|
+
readonly configSchema = z.object({ value: z.string() });
|
|
59
|
+
readonly outputSchema = z.object({ value: z.string() });
|
|
60
|
+
|
|
61
|
+
protected async execute(config: { value: string }): Promise<ExecutorResult<{ value: string }>> {
|
|
62
|
+
return { status: "success", output: { value: config.value } };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
registry.register(new EchoExecutor(deps));
|
|
66
|
+
|
|
67
|
+
/** An instant node whose first call blocks until `release()`; records each call. */
|
|
68
|
+
class GatedExecutor extends BaseExecutor<z.ZodObject, z.ZodObject> {
|
|
69
|
+
readonly type = "test-gated";
|
|
70
|
+
readonly mode = "instant" as const;
|
|
71
|
+
readonly configSchema = z.object({ value: z.string() });
|
|
72
|
+
readonly outputSchema = z.object({ value: z.string() });
|
|
73
|
+
calls: Array<{ runId: string; stepId: string }> = [];
|
|
74
|
+
release: () => void = () => {};
|
|
75
|
+
private readonly gate = new Promise<void>((resolve) => {
|
|
76
|
+
this.release = resolve;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
protected async execute(
|
|
80
|
+
config: { value: string },
|
|
81
|
+
_context: Readonly<Record<string, unknown>>,
|
|
82
|
+
meta: { runId: string; stepId: string },
|
|
83
|
+
): Promise<ExecutorResult<{ value: string }>> {
|
|
84
|
+
this.calls.push({ runId: meta.runId, stepId: meta.stepId });
|
|
85
|
+
if (this.calls.length === 1) await this.gate;
|
|
86
|
+
return { status: "success", output: { value: config.value } };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const gated = new GatedExecutor(deps);
|
|
90
|
+
registry.register(gated);
|
|
91
|
+
|
|
92
|
+
/** Stands in for `swarm-script` (which needs the scripts runtime) and records each call. */
|
|
93
|
+
class RecordingSwarmScriptExecutor extends BaseExecutor<z.ZodObject, z.ZodObject> {
|
|
94
|
+
readonly type = "swarm-script";
|
|
95
|
+
readonly mode = "instant" as const;
|
|
96
|
+
readonly configSchema = z.object({ name: z.string(), args: z.record(z.string(), z.unknown()) });
|
|
97
|
+
readonly outputSchema = z.object({ result: z.unknown() });
|
|
98
|
+
calls: Array<{ stepId: string; args: Record<string, unknown> }> = [];
|
|
99
|
+
|
|
100
|
+
protected async execute(
|
|
101
|
+
config: { name: string; args: Record<string, unknown> },
|
|
102
|
+
_context: Readonly<Record<string, unknown>>,
|
|
103
|
+
meta: { stepId: string },
|
|
104
|
+
): Promise<ExecutorResult<{ result: unknown }>> {
|
|
105
|
+
this.calls.push({ stepId: meta.stepId, args: config.args });
|
|
106
|
+
return { status: "success", output: { result: config.args } };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const swarmScript = new RecordingSwarmScriptExecutor(deps);
|
|
110
|
+
|
|
111
|
+
let teardown: (() => void) | undefined;
|
|
112
|
+
let agentId: string;
|
|
113
|
+
|
|
114
|
+
beforeAll(async () => {
|
|
115
|
+
await removeDbFiles();
|
|
116
|
+
initDb(TEST_DB_PATH);
|
|
117
|
+
agentId = (await createAgent({ name: "Fanout", status: "idle" })).id;
|
|
118
|
+
teardown = setupWorkflowResumeListener(workflowEventBus, registry);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
afterAll(async () => {
|
|
122
|
+
teardown?.();
|
|
123
|
+
await drainForeachDrives();
|
|
124
|
+
closeDb();
|
|
125
|
+
await removeDbFiles();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("foreach body policy", () => {
|
|
129
|
+
test("every registered executor is accepted or rejected with a reason", () => {
|
|
130
|
+
for (const type of builtInTypes) {
|
|
131
|
+
const accepted = Object.hasOwn(FOREACH_BODY_ACCEPTED, type);
|
|
132
|
+
const rejected = Object.hasOwn(FOREACH_BODY_REJECTED, type);
|
|
133
|
+
// A new executor needs an explicit foreach-body decision.
|
|
134
|
+
expect({ type, decided: accepted !== rejected }).toEqual({ type, decided: true });
|
|
135
|
+
expect(isForeachBodyType(type, registry)).toBe(accepted);
|
|
136
|
+
}
|
|
137
|
+
expect(FOREACH_BODY_REJECTED.foreach).toContain("nested fan-out");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("validation rejects a nested foreach body and an unknown body type", () => {
|
|
141
|
+
const nested = validateDefinition(
|
|
142
|
+
foreachOver({ type: "foreach", config: { over: [], itemKey: "id", body: {} } }),
|
|
143
|
+
registry,
|
|
144
|
+
);
|
|
145
|
+
expect(nested.valid).toBe(false);
|
|
146
|
+
expect(
|
|
147
|
+
nested.errors.some(
|
|
148
|
+
(error) => error.includes('Node "fan"') && error.includes("nested fan-out"),
|
|
149
|
+
),
|
|
150
|
+
).toBe(true);
|
|
151
|
+
|
|
152
|
+
const unknown = validateDefinition(
|
|
153
|
+
foreachOver({ type: "no-such-executor", config: {} }),
|
|
154
|
+
registry,
|
|
155
|
+
);
|
|
156
|
+
expect(unknown.valid).toBe(false);
|
|
157
|
+
expect(unknown.errors.some((error) => error.includes("is not a registered executor"))).toBe(
|
|
158
|
+
true,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Without the registry only the rejection list applies.
|
|
162
|
+
expect(validateDefinition(foreachOver({ type: "no-such-executor", config: {} })).valid).toBe(
|
|
163
|
+
true,
|
|
164
|
+
);
|
|
165
|
+
expect(
|
|
166
|
+
validateDefinition(
|
|
167
|
+
foreachOver({ type: "test-echo", config: { value: "{{item.name}}" } }),
|
|
168
|
+
registry,
|
|
169
|
+
).valid,
|
|
170
|
+
).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("static body config is validated against the body executor's schema", () => {
|
|
174
|
+
const invalid = validateDefinition(
|
|
175
|
+
foreachOver({ type: "script", config: { runtime: "cobol", script: "x" } }),
|
|
176
|
+
registry,
|
|
177
|
+
);
|
|
178
|
+
expect(invalid.valid).toBe(false);
|
|
179
|
+
expect(invalid.errors.some((error) => error.includes("config.body.config.runtime"))).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("foreach with any executor body", () => {
|
|
184
|
+
test("a sub-workflow body runs one child workflow per item", async () => {
|
|
185
|
+
const child = await makeWorkflow({
|
|
186
|
+
nodes: [{ id: "echo", type: "test-echo", config: { value: "hi {{trigger.name}}" } }],
|
|
187
|
+
});
|
|
188
|
+
const parent = await makeWorkflow(
|
|
189
|
+
foreachOver({
|
|
190
|
+
type: "sub-workflow",
|
|
191
|
+
config: { workflowId: child.id, inputs: { name: "{{item.name}}" } },
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
const runId = await startWorkflowExecution(parent, { items: ITEMS }, registry);
|
|
195
|
+
const run = await waitForRun(runId, (r) => r.status === "completed");
|
|
196
|
+
|
|
197
|
+
const children = await childSteps(runId);
|
|
198
|
+
expect(children.map((step) => step.nodeType)).toEqual([
|
|
199
|
+
"sub-workflow",
|
|
200
|
+
"sub-workflow",
|
|
201
|
+
"sub-workflow",
|
|
202
|
+
]);
|
|
203
|
+
const childRuns = await getDbClient().query<{ parentStepId: string }>(
|
|
204
|
+
"SELECT parentStepId FROM workflow_runs WHERE workflowId = ?",
|
|
205
|
+
[child.id],
|
|
206
|
+
);
|
|
207
|
+
expect(childRuns.map((row) => row.parentStepId).sort()).toEqual(
|
|
208
|
+
children.map((step) => step.id).sort(),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const aggregate = (run.context as Record<string, unknown>).fan as {
|
|
212
|
+
results: Array<{ itemKey: string; status: string }>;
|
|
213
|
+
okCount: number;
|
|
214
|
+
failedCount: number;
|
|
215
|
+
};
|
|
216
|
+
expect(aggregate.okCount).toBe(3);
|
|
217
|
+
expect(aggregate.failedCount).toBe(0);
|
|
218
|
+
expect(aggregate.results.map((result) => result.itemKey)).toEqual(["a", "b", "c"]);
|
|
219
|
+
expect((await stepByNodeId(runId, "after"))?.status).toBe("completed");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
spawnTest("a script body runs once per item and a failing item fails alone", async () => {
|
|
223
|
+
const workflow = await makeWorkflow({
|
|
224
|
+
...foreachOver({
|
|
225
|
+
type: "script",
|
|
226
|
+
config: {
|
|
227
|
+
runtime: "bash",
|
|
228
|
+
// bash -c puts the first arg in $0.
|
|
229
|
+
script: 'if [ "$0" = "Bad" ]; then echo "boom" >&2; exit 3; fi; echo "item $0"',
|
|
230
|
+
args: ["{{item.name}}"],
|
|
231
|
+
timeout: 5_000,
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
onNodeFailure: "continue",
|
|
235
|
+
});
|
|
236
|
+
const runId = await startWorkflowExecution(
|
|
237
|
+
workflow,
|
|
238
|
+
{ items: [...ITEMS.slice(0, 2), { id: "x", name: "Bad" }] },
|
|
239
|
+
registry,
|
|
240
|
+
);
|
|
241
|
+
const run = await waitForRun(runId, (r) => r.status === "completed");
|
|
242
|
+
|
|
243
|
+
const children = await childSteps(runId);
|
|
244
|
+
expect(children.map((step) => [step.nodeType, step.status])).toEqual([
|
|
245
|
+
["script", "completed"],
|
|
246
|
+
["script", "completed"],
|
|
247
|
+
["script", "failed"],
|
|
248
|
+
]);
|
|
249
|
+
expect(children[2]?.error).toBeTruthy();
|
|
250
|
+
const aggregate = (run.context as Record<string, unknown>).fan as {
|
|
251
|
+
okCount: number;
|
|
252
|
+
failedCount: number;
|
|
253
|
+
};
|
|
254
|
+
expect(aggregate).toMatchObject({ okCount: 2, failedCount: 1 });
|
|
255
|
+
// The parent step is authoritative: item failures are not a partial failure.
|
|
256
|
+
expect(run.error).toBeUndefined();
|
|
257
|
+
expect((await stepByNodeId(runId, "after"))?.status).toBe("completed");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
spawnTest("instant items each get their own budget, outside the parent's", async () => {
|
|
261
|
+
const definition = foreachOver({
|
|
262
|
+
type: "script",
|
|
263
|
+
config: { runtime: "bash", script: "sleep 0.6; echo ok", timeout: 5_000 },
|
|
264
|
+
});
|
|
265
|
+
// Three 0.6s items run back to back: 1.8s, past the parent's 1s budget.
|
|
266
|
+
definition.nodes[0]!.config.timeoutMs = 1_000;
|
|
267
|
+
const workflow = await makeWorkflow(definition);
|
|
268
|
+
const runId = await startWorkflowExecution(workflow, { items: ITEMS }, registry);
|
|
269
|
+
const run = await waitForRun(runId, (r) => r.status === "completed", 10_000);
|
|
270
|
+
|
|
271
|
+
expect((await childSteps(runId)).map((step) => step.status)).toEqual([
|
|
272
|
+
"completed",
|
|
273
|
+
"completed",
|
|
274
|
+
"completed",
|
|
275
|
+
]);
|
|
276
|
+
expect(((run.context as Record<string, unknown>).fan as { okCount: number }).okCount).toBe(3);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
spawnTest(
|
|
280
|
+
"a parent alias named item or index cannot smuggle item data into script source",
|
|
281
|
+
async () => {
|
|
282
|
+
// `item` and `index` are per-item values. An alias with those names on the
|
|
283
|
+
// parent must not make them look like an allowed (`workflow`) source.
|
|
284
|
+
const definition = foreachOver({
|
|
285
|
+
type: "script",
|
|
286
|
+
config: { runtime: "bash", script: "echo {{item.name}} {{index}}", timeout: 5_000 },
|
|
287
|
+
});
|
|
288
|
+
definition.nodes[0]!.inputs = { item: "workflow.id", index: "workflow.id" };
|
|
289
|
+
const workflow = await makeWorkflow({ ...definition, onNodeFailure: "continue" });
|
|
290
|
+
const runId = await startWorkflowExecution(workflow, { items: ITEMS }, registry);
|
|
291
|
+
await waitForRun(runId, (r) => r.status === "completed" || r.status === "failed");
|
|
292
|
+
|
|
293
|
+
const children = await childSteps(runId);
|
|
294
|
+
expect(children.map((step) => step.status)).toEqual(["failed", "failed", "failed"]);
|
|
295
|
+
for (const child of children)
|
|
296
|
+
expect(child.error).toContain("Script body interpolation failed");
|
|
297
|
+
expect(JSON.stringify(children.map((child) => child.output ?? null))).not.toMatch(
|
|
298
|
+
/Ada|Babbage|Curie/,
|
|
299
|
+
);
|
|
300
|
+
},
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
test("a swarm-script body runs once per item with per-item args", async () => {
|
|
304
|
+
const original = registry.get("swarm-script");
|
|
305
|
+
registry.register(swarmScript);
|
|
306
|
+
try {
|
|
307
|
+
const workflow = await makeWorkflow(
|
|
308
|
+
foreachOver({
|
|
309
|
+
type: "swarm-script",
|
|
310
|
+
config: { name: "per-item", args: { name: "{{item.name}}", index: "{{index}}" } },
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
const runId = await startWorkflowExecution(workflow, { items: ITEMS }, registry);
|
|
314
|
+
await waitForRun(runId, (r) => r.status === "completed");
|
|
315
|
+
|
|
316
|
+
const children = await childSteps(runId);
|
|
317
|
+
expect(swarmScript.calls.map((call) => call.stepId)).toEqual(children.map((s) => s.id));
|
|
318
|
+
expect(swarmScript.calls.map((call) => call.args.name)).toEqual(["Ada", "Babbage", "Curie"]);
|
|
319
|
+
expect(children.every((step) => step.nodeType === "swarm-script")).toBe(true);
|
|
320
|
+
} finally {
|
|
321
|
+
registry.register(original);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("a human-in-the-loop body settles each item and joins after the last", async () => {
|
|
326
|
+
const workflow = await makeWorkflow(
|
|
327
|
+
foreachOver({
|
|
328
|
+
type: "human-in-the-loop",
|
|
329
|
+
config: {
|
|
330
|
+
title: "Approve {{item.name}}",
|
|
331
|
+
questions: [{ id: "ok", type: "approval", label: "Ship {{item.name}}?" }],
|
|
332
|
+
approvers: { policy: "any" },
|
|
333
|
+
},
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
const runId = await startWorkflowExecution(workflow, { items: ITEMS.slice(0, 2) }, registry);
|
|
337
|
+
expect((await getWorkflowRun(runId))?.status).toBe("waiting");
|
|
338
|
+
|
|
339
|
+
const [first, second] = await childSteps(runId);
|
|
340
|
+
expect(first?.nodeType).toBe("human-in-the-loop");
|
|
341
|
+
await resolveApproval(runId, first!, "approved");
|
|
342
|
+
await waitForStep(first!.id, "completed");
|
|
343
|
+
expect((await stepByNodeId(runId, "fan"))?.status).toBe("waiting");
|
|
344
|
+
expect(await stepByNodeId(runId, "after")).toBeUndefined();
|
|
345
|
+
|
|
346
|
+
await resolveApproval(runId, second!, "rejected");
|
|
347
|
+
const run = await waitForRun(runId, (r) => r.status === "completed");
|
|
348
|
+
// A rejected item is a completed item that carries its port.
|
|
349
|
+
expect((await stepById(runId, second!.id))?.nextPort).toBe("rejected");
|
|
350
|
+
const aggregate = (run.context as Record<string, unknown>).fan as {
|
|
351
|
+
okCount: number;
|
|
352
|
+
results: Array<{ output: { status: string } }>;
|
|
353
|
+
};
|
|
354
|
+
expect(aggregate.okCount).toBe(2);
|
|
355
|
+
expect(aggregate.results.map((result) => result.output.status)).toEqual([
|
|
356
|
+
"approved",
|
|
357
|
+
"rejected",
|
|
358
|
+
]);
|
|
359
|
+
expect((await stepByNodeId(runId, "after"))?.status).toBe("completed");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("a wait body settles each item when its wait fires", async () => {
|
|
363
|
+
const workflow = await makeWorkflow(
|
|
364
|
+
foreachOver({ type: "wait", config: { mode: "time", durationMs: 3_600_000 } }),
|
|
365
|
+
);
|
|
366
|
+
const runId = await startWorkflowExecution(workflow, { items: ITEMS.slice(0, 2) }, registry);
|
|
367
|
+
expect((await getWorkflowRun(runId))?.status).toBe("waiting");
|
|
368
|
+
|
|
369
|
+
const children = await childSteps(runId);
|
|
370
|
+
expect(children.map((step) => [step.nodeType, step.status])).toEqual([
|
|
371
|
+
["wait", "waiting"],
|
|
372
|
+
["wait", "waiting"],
|
|
373
|
+
]);
|
|
374
|
+
for (const child of children) {
|
|
375
|
+
const wait = (await getWaitStateByStepId(child.id))!;
|
|
376
|
+
await resumeWaitState(wait.id, "fired", undefined, registry);
|
|
377
|
+
}
|
|
378
|
+
const run = await waitForRun(runId, (r) => r.status === "completed");
|
|
379
|
+
expect(((run.context as Record<string, unknown>).fan as { okCount: number }).okCount).toBe(2);
|
|
380
|
+
expect((await stepByNodeId(runId, "after"))?.status).toBe("completed");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test.each([
|
|
384
|
+
["the body config", "{{trigger.items}}"],
|
|
385
|
+
["over", "{{ITEMS}}"],
|
|
386
|
+
])("install params render into %s", async (_path, over) => {
|
|
387
|
+
const workflow = await createWorkflow({
|
|
388
|
+
name: `foreach-bodies-${crypto.randomUUID()}`,
|
|
389
|
+
params: { P: "hi", ITEMS },
|
|
390
|
+
definition: {
|
|
391
|
+
nodes: [
|
|
392
|
+
{
|
|
393
|
+
id: "fan",
|
|
394
|
+
type: "foreach",
|
|
395
|
+
config: {
|
|
396
|
+
over,
|
|
397
|
+
itemKey: "id",
|
|
398
|
+
body: {
|
|
399
|
+
type: "agent-task",
|
|
400
|
+
config: { agentId, template: "Hello {{P}} {{item.name}}" },
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
const runId = await startWorkflowExecution(workflow, { items: ITEMS }, registry);
|
|
408
|
+
expect((await getWorkflowRun(runId))?.status).toBe("waiting");
|
|
409
|
+
|
|
410
|
+
const children = await childSteps(runId);
|
|
411
|
+
expect(children.map((step) => step.status)).toEqual(["waiting", "waiting", "waiting"]);
|
|
412
|
+
const prompts = await Promise.all(
|
|
413
|
+
children.map(async (step) =>
|
|
414
|
+
(await getTaskByWorkflowRunStepId(step.id))?.task.split("\n").at(-1),
|
|
415
|
+
),
|
|
416
|
+
);
|
|
417
|
+
expect(prompts).toEqual(["Hello hi Ada", "Hello hi Babbage", "Hello hi Curie"]);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("a cancel during an instant body stops the rest of the batch", async () => {
|
|
421
|
+
const workflow = await makeWorkflow(
|
|
422
|
+
foreachOver({ type: "test-gated", config: { value: "x" } }),
|
|
423
|
+
);
|
|
424
|
+
const walk = startWorkflowExecution(workflow, { items: ITEMS }, registry);
|
|
425
|
+
const deadline = Date.now() + 2_000;
|
|
426
|
+
while (gated.calls.length === 0 && Date.now() < deadline) await Bun.sleep(5);
|
|
427
|
+
const runId = gated.calls[0]!.runId;
|
|
428
|
+
|
|
429
|
+
await cancelWorkflowRun(runId);
|
|
430
|
+
gated.release();
|
|
431
|
+
await walk;
|
|
432
|
+
await drainForeachDrives();
|
|
433
|
+
|
|
434
|
+
expect(gated.calls.map((call) => call.stepId)).toEqual([(await childSteps(runId))[0]!.id]);
|
|
435
|
+
expect((await childSteps(runId)).map((step) => step.status)).toEqual([
|
|
436
|
+
"cancelled",
|
|
437
|
+
"cancelled",
|
|
438
|
+
"cancelled",
|
|
439
|
+
]);
|
|
440
|
+
expect((await getWorkflowRun(runId))?.status).toBe("cancelled");
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
describe("foreach restart reconciliation", () => {
|
|
445
|
+
test("a restart after claim re-drives each item once (agent-task and sub-workflow)", async () => {
|
|
446
|
+
const taskRun = await startWorkflowExecution(
|
|
447
|
+
await makeWorkflow(foreachOver(agentTaskBody())),
|
|
448
|
+
{ items: ITEMS },
|
|
449
|
+
registry,
|
|
450
|
+
);
|
|
451
|
+
const [claimedNeverStarted, startedNeverParked, queued] = await childSteps(taskRun);
|
|
452
|
+
// Crash after claim, before start: the row is `running` with no task.
|
|
453
|
+
await getDbClient().run("DELETE FROM agent_tasks WHERE workflowRunStepId = ?", [
|
|
454
|
+
claimedNeverStarted!.id,
|
|
455
|
+
]);
|
|
456
|
+
await setStepStatus(claimedNeverStarted!.id, "running");
|
|
457
|
+
// Crash after start, before park: `running` with its task.
|
|
458
|
+
await setStepStatus(startedNeverParked!.id, "running");
|
|
459
|
+
// Crash after a settlement, before the next drive: still queued.
|
|
460
|
+
await getDbClient().run("DELETE FROM agent_tasks WHERE workflowRunStepId = ?", [queued!.id]);
|
|
461
|
+
await setStepStatus(queued!.id, "pending");
|
|
462
|
+
|
|
463
|
+
const child = await makeWorkflow({
|
|
464
|
+
nodes: [{ id: "pause", type: "wait", config: { mode: "time", durationMs: 3_600_000 } }],
|
|
465
|
+
});
|
|
466
|
+
const subRun = await startWorkflowExecution(
|
|
467
|
+
await makeWorkflow(
|
|
468
|
+
foreachOver({ type: "sub-workflow", config: { workflowId: child.id, inputs: {} } }),
|
|
469
|
+
),
|
|
470
|
+
{ items: ITEMS.slice(0, 2) },
|
|
471
|
+
registry,
|
|
472
|
+
);
|
|
473
|
+
const subChildren = await childSteps(subRun);
|
|
474
|
+
for (const step of subChildren) await setStepStatus(step.id, "running");
|
|
475
|
+
|
|
476
|
+
await recoverIncompleteRuns(registry);
|
|
477
|
+
await recoverIncompleteRuns(registry);
|
|
478
|
+
|
|
479
|
+
for (const step of await childSteps(taskRun)) {
|
|
480
|
+
expect(step.status).toBe("waiting");
|
|
481
|
+
expect(await countRows("agent_tasks", "workflowRunStepId", step.id)).toBe(1);
|
|
482
|
+
}
|
|
483
|
+
for (const step of await childSteps(subRun)) {
|
|
484
|
+
expect(step.status).toBe("waiting");
|
|
485
|
+
expect(await countRows("workflow_runs", "parentStepId", step.id)).toBe(1);
|
|
486
|
+
}
|
|
487
|
+
expect((await getWorkflowRun(taskRun))?.status).toBe("waiting");
|
|
488
|
+
expect((await getWorkflowRun(subRun))?.status).toBe("waiting");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("a restart during collection checkpoints the parent and starts successors once", async () => {
|
|
492
|
+
teardown?.();
|
|
493
|
+
try {
|
|
494
|
+
const runId = await startWorkflowExecution(
|
|
495
|
+
await makeWorkflow(foreachOver(agentTaskBody())),
|
|
496
|
+
{ items: ITEMS },
|
|
497
|
+
registry,
|
|
498
|
+
);
|
|
499
|
+
const children = await childSteps(runId);
|
|
500
|
+
for (const step of children) {
|
|
501
|
+
await completeTask((await getTaskByWorkflowRunStepId(step.id))!.id, `done ${step.id}`);
|
|
502
|
+
}
|
|
503
|
+
teardown = setupWorkflowResumeListener(workflowEventBus, registry);
|
|
504
|
+
|
|
505
|
+
await recoverIncompleteRuns(registry);
|
|
506
|
+
// The listener replays a completion the sweep already routed.
|
|
507
|
+
const task = (await getTaskByWorkflowRunStepId(children[0]!.id))!;
|
|
508
|
+
workflowEventBus.emit("task.completed", {
|
|
509
|
+
taskId: task.id,
|
|
510
|
+
output: "replayed",
|
|
511
|
+
workflowRunId: runId,
|
|
512
|
+
workflowRunStepId: children[0]!.id,
|
|
513
|
+
});
|
|
514
|
+
await recoverIncompleteRuns(registry);
|
|
515
|
+
await waitForRun(runId, (r) => r.status === "completed");
|
|
516
|
+
await Bun.sleep(20);
|
|
517
|
+
|
|
518
|
+
const steps = await getWorkflowRunStepsByRunId(runId);
|
|
519
|
+
const parents = steps.filter((step) => step.nodeId === "fan");
|
|
520
|
+
expect(parents.map((step) => step.status)).toEqual(["completed"]);
|
|
521
|
+
expect(steps.filter((step) => step.nodeId === "after")).toHaveLength(1);
|
|
522
|
+
expect(await stepById(runId, children[0]!.id)).toMatchObject({
|
|
523
|
+
output: { taskOutput: `done ${children[0]!.id}` },
|
|
524
|
+
});
|
|
525
|
+
} finally {
|
|
526
|
+
teardown?.();
|
|
527
|
+
teardown = setupWorkflowResumeListener(workflowEventBus, registry);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const ITEMS = [
|
|
533
|
+
{ id: "a", name: "Ada" },
|
|
534
|
+
{ id: "b", name: "Babbage" },
|
|
535
|
+
{ id: "c", name: "Curie" },
|
|
536
|
+
];
|
|
537
|
+
|
|
538
|
+
function agentTaskBody(): { type: string; config: Record<string, unknown> } {
|
|
539
|
+
return { type: "agent-task", config: { agentId, template: "Reflect {{item.name}}" } };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** `fan` (a foreach over `trigger.items` with `body`) followed by `after`. */
|
|
543
|
+
function foreachOver(body: { type: string; config: Record<string, unknown> }): WorkflowDefinition {
|
|
544
|
+
return {
|
|
545
|
+
nodes: [
|
|
546
|
+
{
|
|
547
|
+
id: "fan",
|
|
548
|
+
type: "foreach",
|
|
549
|
+
config: { over: "{{trigger.items}}", itemKey: "id", body },
|
|
550
|
+
next: "after",
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
id: "after",
|
|
554
|
+
type: "test-echo",
|
|
555
|
+
inputs: { aggregate: "fan" },
|
|
556
|
+
config: { value: "joined {{aggregate.okCount}}" },
|
|
557
|
+
},
|
|
558
|
+
],
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function makeWorkflow(definition: WorkflowDefinition): Promise<Workflow> {
|
|
563
|
+
return createWorkflow({ name: `foreach-bodies-${crypto.randomUUID()}`, definition });
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function childSteps(runId: string): Promise<WorkflowRunStep[]> {
|
|
567
|
+
return (await getWorkflowRunStepsByRunId(runId)).filter((step) => step.nodeId.startsWith("fan#"));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function stepByNodeId(runId: string, nodeId: string) {
|
|
571
|
+
return (await getWorkflowRunStepsByRunId(runId)).find((step) => step.nodeId === nodeId);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function stepById(runId: string, stepId: string) {
|
|
575
|
+
return (await getWorkflowRunStepsByRunId(runId)).find((step) => step.id === stepId);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function setStepStatus(stepId: string, status: string): Promise<void> {
|
|
579
|
+
await getDbClient().run("UPDATE workflow_run_steps SET status = ? WHERE id = ?", [
|
|
580
|
+
status,
|
|
581
|
+
stepId,
|
|
582
|
+
]);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function countRows(table: string, column: string, value: string): Promise<number> {
|
|
586
|
+
const row = await getDbClient().get<{ count: number }>(
|
|
587
|
+
`SELECT COUNT(*) AS count FROM ${table} WHERE ${column} = ?`,
|
|
588
|
+
[value],
|
|
589
|
+
);
|
|
590
|
+
return row?.count ?? 0;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function resolveApproval(
|
|
594
|
+
runId: string,
|
|
595
|
+
step: WorkflowRunStep,
|
|
596
|
+
status: "approved" | "rejected",
|
|
597
|
+
): Promise<void> {
|
|
598
|
+
const request = (await getApprovalRequestByStepId(step.id))!;
|
|
599
|
+
await resolveApprovalRequest(request.id, { status, responses: { ok: status === "approved" } });
|
|
600
|
+
workflowEventBus.emit("approval.resolved", {
|
|
601
|
+
requestId: request.id,
|
|
602
|
+
status,
|
|
603
|
+
responses: { ok: status === "approved" },
|
|
604
|
+
workflowRunId: runId,
|
|
605
|
+
workflowRunStepId: step.id,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function waitForStep(stepId: string, status: string): Promise<void> {
|
|
610
|
+
const deadline = Date.now() + 2_000;
|
|
611
|
+
while (Date.now() < deadline) {
|
|
612
|
+
if ((await db.getWorkflowRunStep(stepId))?.status === status) return;
|
|
613
|
+
await Bun.sleep(10);
|
|
614
|
+
}
|
|
615
|
+
throw new Error(`Timed out waiting for step ${stepId} to be ${status}`);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function waitForRun(
|
|
619
|
+
runId: string,
|
|
620
|
+
done: (run: WorkflowRun) => boolean,
|
|
621
|
+
timeoutMs = 3_000,
|
|
622
|
+
): Promise<WorkflowRun> {
|
|
623
|
+
const deadline = Date.now() + timeoutMs;
|
|
624
|
+
let run: WorkflowRun | null = null;
|
|
625
|
+
while (Date.now() < deadline) {
|
|
626
|
+
run = await getWorkflowRun(runId);
|
|
627
|
+
if (run && done(run)) return run;
|
|
628
|
+
await Bun.sleep(10);
|
|
629
|
+
}
|
|
630
|
+
throw new Error(`Timed out waiting for run ${runId}; last status ${run?.status} ${run?.error}`);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function removeDbFiles(): Promise<void> {
|
|
634
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
635
|
+
await unlink(TEST_DB_PATH + suffix).catch(() => {});
|
|
636
|
+
}
|
|
637
|
+
}
|