@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,537 @@
|
|
|
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
|
+
failTask,
|
|
11
|
+
getDbClient,
|
|
12
|
+
getTaskByWorkflowRunStepId,
|
|
13
|
+
getWorkflow,
|
|
14
|
+
getWorkflowRun,
|
|
15
|
+
getWorkflowRunStepsByRunId,
|
|
16
|
+
initDb,
|
|
17
|
+
} from "../be/db";
|
|
18
|
+
import type { Workflow, WorkflowDefinition, WorkflowRun, WorkflowRunStep } from "../types";
|
|
19
|
+
import { startWorkflowExecution } from "../workflows/engine";
|
|
20
|
+
import { workflowEventBus } from "../workflows/event-bus";
|
|
21
|
+
import {
|
|
22
|
+
BaseExecutor,
|
|
23
|
+
type ExecutorDependencies,
|
|
24
|
+
type ExecutorResult,
|
|
25
|
+
} from "../workflows/executors/base";
|
|
26
|
+
import { createExecutorRegistry } from "../workflows/executors/registry";
|
|
27
|
+
import { drainForeachDrives } from "../workflows/foreach-driver";
|
|
28
|
+
import { retryFailedRun, setupWorkflowResumeListener } from "../workflows/resume";
|
|
29
|
+
|
|
30
|
+
const TEST_DB_PATH = "./test-workflow-foreach-retry.sqlite";
|
|
31
|
+
|
|
32
|
+
const deps: ExecutorDependencies = {
|
|
33
|
+
db,
|
|
34
|
+
eventBus: workflowEventBus,
|
|
35
|
+
interpolate: (t: string) => t,
|
|
36
|
+
};
|
|
37
|
+
const registry = createExecutorRegistry(deps);
|
|
38
|
+
|
|
39
|
+
/** Item ids the build step fails on; a test clears one to let its retry pass. */
|
|
40
|
+
const failingSpecs = new Set<string>();
|
|
41
|
+
|
|
42
|
+
/** The child workflow's one step: "builds" a spec, failing for `failingSpecs`. */
|
|
43
|
+
class BuildExecutor extends BaseExecutor<z.ZodObject, z.ZodObject> {
|
|
44
|
+
readonly type = "test-build";
|
|
45
|
+
readonly mode = "instant" as const;
|
|
46
|
+
readonly configSchema = z.object({ spec: z.unknown(), deps: z.unknown().optional() });
|
|
47
|
+
readonly outputSchema = z.object({ built: z.string(), deps: z.unknown().optional() });
|
|
48
|
+
|
|
49
|
+
protected async execute(config: {
|
|
50
|
+
spec: unknown;
|
|
51
|
+
deps?: unknown;
|
|
52
|
+
}): Promise<ExecutorResult<{ built: string; deps?: unknown }>> {
|
|
53
|
+
// A sub-workflow passes a non-string input value as JSON text.
|
|
54
|
+
const spec = parseJsonText(config.spec) as { id?: string } | undefined;
|
|
55
|
+
const id = spec?.id ?? "?";
|
|
56
|
+
if (failingSpecs.has(id)) return { status: "failed", error: `build of ${id} failed` };
|
|
57
|
+
const deps = config.deps === "" ? undefined : parseJsonText(config.deps);
|
|
58
|
+
return { status: "success", output: { built: `built-${id}`, ...(deps ? { deps } : {}) } };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
registry.register(new BuildExecutor(deps));
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Stands in for `swarm-script` (the scripts runtime is not booted here) and
|
|
65
|
+
* runs the two scripts of the documented dependency-wave example.
|
|
66
|
+
*/
|
|
67
|
+
class WaveScriptExecutor extends BaseExecutor<z.ZodObject, z.ZodObject> {
|
|
68
|
+
readonly type = "swarm-script";
|
|
69
|
+
readonly mode = "instant" as const;
|
|
70
|
+
readonly configSchema = z.object({
|
|
71
|
+
scriptName: z.string(),
|
|
72
|
+
args: z.record(z.string(), z.unknown()).default({}),
|
|
73
|
+
});
|
|
74
|
+
readonly outputSchema = z.object({ result: z.unknown() });
|
|
75
|
+
|
|
76
|
+
protected async execute(config: {
|
|
77
|
+
scriptName: string;
|
|
78
|
+
args: Record<string, unknown>;
|
|
79
|
+
}): Promise<ExecutorResult<{ result: unknown }>> {
|
|
80
|
+
if (config.scriptName === "plan-waves") {
|
|
81
|
+
return {
|
|
82
|
+
status: "success",
|
|
83
|
+
output: {
|
|
84
|
+
result: {
|
|
85
|
+
wave1: [{ id: "a" }, { id: "b" }],
|
|
86
|
+
wave2: [{ id: "c", dependsOn: ["a", "b"] }],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// prep-wave2: attach each wave-2 job's built dependencies from wave 1.
|
|
92
|
+
const wave1 = config.args.wave1 as {
|
|
93
|
+
results: Array<{ itemKey: string; output: { outputs: { build: unknown } } }>;
|
|
94
|
+
};
|
|
95
|
+
const wave2 = config.args.wave2 as Array<{ id: string; dependsOn: string[] }>;
|
|
96
|
+
const done = Object.fromEntries(
|
|
97
|
+
wave1.results.map((result) => [result.itemKey, result.output.outputs.build]),
|
|
98
|
+
);
|
|
99
|
+
return {
|
|
100
|
+
status: "success",
|
|
101
|
+
output: {
|
|
102
|
+
result: wave2.map((job) => ({ ...job, deps: job.dependsOn.map((key) => done[key]) })),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
registry.register(new WaveScriptExecutor(deps));
|
|
108
|
+
|
|
109
|
+
function parseJsonText(value: unknown): unknown {
|
|
110
|
+
if (typeof value !== "string") return value;
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(value);
|
|
113
|
+
} catch {
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let teardown: (() => void) | undefined;
|
|
119
|
+
let agentId: string;
|
|
120
|
+
|
|
121
|
+
beforeAll(async () => {
|
|
122
|
+
await removeDbFiles();
|
|
123
|
+
initDb(TEST_DB_PATH);
|
|
124
|
+
agentId = (await createAgent({ name: "Retrier", status: "idle" })).id;
|
|
125
|
+
teardown = setupWorkflowResumeListener(workflowEventBus, registry);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterAll(async () => {
|
|
129
|
+
teardown?.();
|
|
130
|
+
await drainForeachDrives();
|
|
131
|
+
closeDb();
|
|
132
|
+
await removeDbFiles();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("foreach run retry with attempt rows", () => {
|
|
136
|
+
test("a retry reruns only the failed and cancelled items as new attempts", async () => {
|
|
137
|
+
const runId = await failSecondOfFour();
|
|
138
|
+
|
|
139
|
+
await retryFailedRun(runId, registry);
|
|
140
|
+
await drainForeachDrives();
|
|
141
|
+
|
|
142
|
+
const steps = await children(runId);
|
|
143
|
+
const attemptOne = steps.filter((step) => attemptOf(step) === 1).map((step) => step.nodeId);
|
|
144
|
+
expect(attemptOne.sort()).toEqual(["fan#i1", "fan#i2", "fan#i3"]);
|
|
145
|
+
expect(steps.filter((step) => step.nodeId === "fan#i0")).toHaveLength(1);
|
|
146
|
+
|
|
147
|
+
for (;;) {
|
|
148
|
+
const [next] = await inFlightTasks(runId);
|
|
149
|
+
if (!next) break;
|
|
150
|
+
await completeTask(next.taskId, `retry ${next.nodeId}`);
|
|
151
|
+
await waitForStep(next.stepId, "completed");
|
|
152
|
+
await drainForeachDrives();
|
|
153
|
+
}
|
|
154
|
+
const run = await waitForRun(runId, (r) => r.status === "completed");
|
|
155
|
+
expect(await taskCountForNode(runId, "fan#i0")).toBe(1);
|
|
156
|
+
const aggregate = (run.context as Record<string, unknown>).fan as {
|
|
157
|
+
okCount: number;
|
|
158
|
+
failedCount: number;
|
|
159
|
+
results: Array<{ itemKey: string; output: { taskOutput: unknown } }>;
|
|
160
|
+
};
|
|
161
|
+
expect(aggregate.okCount).toBe(4);
|
|
162
|
+
expect(aggregate.failedCount).toBe(0);
|
|
163
|
+
expect(aggregate.results.map((result) => result.output.taskOutput)).toEqual([
|
|
164
|
+
"first ok",
|
|
165
|
+
"retry fan#i1",
|
|
166
|
+
"retry fan#i2",
|
|
167
|
+
"retry fan#i3",
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("two concurrent retries add one attempt row per item", async () => {
|
|
172
|
+
const runId = await failSecondOfFour();
|
|
173
|
+
const results = await Promise.allSettled([
|
|
174
|
+
retryFailedRun(runId, registry),
|
|
175
|
+
retryFailedRun(runId, registry),
|
|
176
|
+
]);
|
|
177
|
+
await drainForeachDrives();
|
|
178
|
+
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
|
|
179
|
+
const steps = await children(runId);
|
|
180
|
+
for (const nodeId of ["fan#i1", "fan#i2", "fan#i3"]) {
|
|
181
|
+
expect(steps.filter((step) => step.nodeId === nodeId)).toHaveLength(2);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("a retry that would breach the step cap is refused and the run stays failed", async () => {
|
|
186
|
+
const runId = await failSecondOfFour();
|
|
187
|
+
// 1 parent + 4 items exist; 3 attempts would make 8, and `after` needs headroom.
|
|
188
|
+
const previous = process.env.WORKFLOW_MAX_STEPS_PER_RUN;
|
|
189
|
+
process.env.WORKFLOW_MAX_STEPS_PER_RUN = "8";
|
|
190
|
+
try {
|
|
191
|
+
await expect(retryFailedRun(runId, registry)).rejects.toThrow("WORKFLOW_MAX_STEPS_PER_RUN");
|
|
192
|
+
} finally {
|
|
193
|
+
if (previous === undefined) delete process.env.WORKFLOW_MAX_STEPS_PER_RUN;
|
|
194
|
+
else process.env.WORKFLOW_MAX_STEPS_PER_RUN = previous;
|
|
195
|
+
}
|
|
196
|
+
expect((await getWorkflowRun(runId))?.status).toBe("failed");
|
|
197
|
+
expect(await children(runId)).toHaveLength(4);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("a retry landing exactly on the cap of a terminal fan-out is refused and inserts nothing", async () => {
|
|
201
|
+
const workflow = await makeWorkflow({
|
|
202
|
+
nodes: [
|
|
203
|
+
{
|
|
204
|
+
id: "fan",
|
|
205
|
+
type: "foreach",
|
|
206
|
+
config: {
|
|
207
|
+
over: [{ id: "a" }],
|
|
208
|
+
itemKey: "id",
|
|
209
|
+
body: { type: "agent-task", config: { agentId, template: "Item {{item.id}}" } },
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
});
|
|
214
|
+
const runId = await startWorkflowExecution(workflow, {}, registry);
|
|
215
|
+
await drainForeachDrives();
|
|
216
|
+
const [item] = await inFlightTasks(runId);
|
|
217
|
+
await failTask(item!.taskId, "first failure");
|
|
218
|
+
await waitForRun(runId, (r) => r.status === "failed");
|
|
219
|
+
|
|
220
|
+
// 1 parent + 1 item exist; one attempt makes 3, which the retry walk's
|
|
221
|
+
// circuit breaker refuses after the row is already in.
|
|
222
|
+
const previous = process.env.WORKFLOW_MAX_STEPS_PER_RUN;
|
|
223
|
+
process.env.WORKFLOW_MAX_STEPS_PER_RUN = "3";
|
|
224
|
+
try {
|
|
225
|
+
await expect(retryFailedRun(runId, registry)).rejects.toThrow("WORKFLOW_MAX_STEPS_PER_RUN");
|
|
226
|
+
} finally {
|
|
227
|
+
if (previous === undefined) delete process.env.WORKFLOW_MAX_STEPS_PER_RUN;
|
|
228
|
+
else process.env.WORKFLOW_MAX_STEPS_PER_RUN = previous;
|
|
229
|
+
}
|
|
230
|
+
expect((await getWorkflowRun(runId))?.status).toBe("failed");
|
|
231
|
+
expect((await children(runId)).map((step) => step.status)).toEqual(["failed"]);
|
|
232
|
+
|
|
233
|
+
// The failed attempt is still the latest row, so a retry under a higher cap works.
|
|
234
|
+
await retryFailedRun(runId, registry);
|
|
235
|
+
await drainForeachDrives();
|
|
236
|
+
const [replacement] = await inFlightTasks(runId);
|
|
237
|
+
await completeTask(replacement!.taskId, "ok");
|
|
238
|
+
await waitForRun(runId, (r) => r.status === "completed");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("a sibling that fails while the retry checks readiness gets its own attempt", async () => {
|
|
242
|
+
const workflow = await makeWorkflow({
|
|
243
|
+
nodes: [
|
|
244
|
+
{
|
|
245
|
+
id: "fan",
|
|
246
|
+
type: "foreach",
|
|
247
|
+
config: {
|
|
248
|
+
over: [{ id: "a" }, { id: "b" }],
|
|
249
|
+
itemKey: "id",
|
|
250
|
+
failurePolicy: "fail-fast",
|
|
251
|
+
body: { type: "agent-task", config: { agentId, template: "Item {{item.id}}" } },
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
});
|
|
256
|
+
const runId = await startWorkflowExecution(workflow, {}, registry);
|
|
257
|
+
await drainForeachDrives();
|
|
258
|
+
const [first, second] = await inFlightTasks(runId);
|
|
259
|
+
await failTask(first!.taskId, "a failed");
|
|
260
|
+
await waitForRun(runId, (r) => r.status === "failed");
|
|
261
|
+
|
|
262
|
+
// Hold the retry inside its awaited readiness check while b fails late.
|
|
263
|
+
const foreach = registry.get("foreach");
|
|
264
|
+
const checkReadiness = foreach.checkReadiness;
|
|
265
|
+
const entered = Promise.withResolvers<void>();
|
|
266
|
+
const release = Promise.withResolvers<void>();
|
|
267
|
+
foreach.checkReadiness = async () => {
|
|
268
|
+
entered.resolve();
|
|
269
|
+
await release.promise;
|
|
270
|
+
return [];
|
|
271
|
+
};
|
|
272
|
+
const retry = retryFailedRun(runId, registry);
|
|
273
|
+
try {
|
|
274
|
+
await entered.promise;
|
|
275
|
+
await failTask(second!.taskId, "b failed during the retry's readiness check");
|
|
276
|
+
await waitForStep(second!.stepId, "failed");
|
|
277
|
+
} finally {
|
|
278
|
+
foreach.checkReadiness = checkReadiness;
|
|
279
|
+
release.resolve();
|
|
280
|
+
await retry;
|
|
281
|
+
}
|
|
282
|
+
await drainForeachDrives();
|
|
283
|
+
|
|
284
|
+
const steps = await children(runId);
|
|
285
|
+
expect(steps.filter((step) => step.nodeId === "fan#a")).toHaveLength(2);
|
|
286
|
+
expect(steps.filter((step) => step.nodeId === "fan#b")).toHaveLength(2);
|
|
287
|
+
for (const next of await inFlightTasks(runId)) {
|
|
288
|
+
await completeTask(next.taskId, `retry ${next.nodeId}`);
|
|
289
|
+
}
|
|
290
|
+
const run = await waitForRun(runId, (r) => r.status === "completed");
|
|
291
|
+
const aggregate = (run.context as Record<string, unknown>).fan as {
|
|
292
|
+
okCount: number;
|
|
293
|
+
failedCount: number;
|
|
294
|
+
};
|
|
295
|
+
expect(aggregate.okCount).toBe(2);
|
|
296
|
+
expect(aggregate.failedCount).toBe(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("a sub-workflow item retry starts a new child run and keeps the old one", async () => {
|
|
300
|
+
const child = await makeWorkflow(buildWorkflow());
|
|
301
|
+
const parent = await makeWorkflow({
|
|
302
|
+
nodes: [
|
|
303
|
+
{
|
|
304
|
+
id: "fan",
|
|
305
|
+
type: "foreach",
|
|
306
|
+
config: {
|
|
307
|
+
over: "{{trigger.items}}",
|
|
308
|
+
itemKey: "id",
|
|
309
|
+
failurePolicy: "fail-fast",
|
|
310
|
+
body: {
|
|
311
|
+
type: "sub-workflow",
|
|
312
|
+
config: { workflowId: child.id, inputs: { spec: "{{item}}" } },
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
});
|
|
318
|
+
failingSpecs.add("b");
|
|
319
|
+
const runId = await startWorkflowExecution(
|
|
320
|
+
parent,
|
|
321
|
+
{ items: [{ id: "a" }, { id: "b" }] },
|
|
322
|
+
registry,
|
|
323
|
+
);
|
|
324
|
+
await drainForeachDrives();
|
|
325
|
+
await waitForRun(runId, (r) => r.status === "failed");
|
|
326
|
+
const failedAttempt = (await children(runId)).find((step) => step.nodeId === "fan#b")!;
|
|
327
|
+
expect(failedAttempt.status).toBe("failed");
|
|
328
|
+
|
|
329
|
+
failingSpecs.delete("b");
|
|
330
|
+
await retryFailedRun(runId, registry);
|
|
331
|
+
await drainForeachDrives();
|
|
332
|
+
await waitForRun(runId, (r) => r.status === "completed");
|
|
333
|
+
|
|
334
|
+
const attempts = (await children(runId)).filter((step) => step.nodeId === "fan#b");
|
|
335
|
+
expect(attempts.map((step) => step.status)).toEqual(["failed", "completed"]);
|
|
336
|
+
const childRuns = await getDbClient().query<{ parentStepId: string; status: string }>(
|
|
337
|
+
"SELECT parentStepId, status FROM workflow_runs WHERE workflowId = ? ORDER BY startedAt",
|
|
338
|
+
[child.id],
|
|
339
|
+
);
|
|
340
|
+
const forB = childRuns.filter((row) => attempts.some((step) => step.id === row.parentStepId));
|
|
341
|
+
expect(forB).toHaveLength(2);
|
|
342
|
+
expect(forB.find((row) => row.parentStepId === failedAttempt.id)?.status).toBe("failed");
|
|
343
|
+
expect(forB.find((row) => row.parentStepId === attempts[1]!.id)?.status).toBe("completed");
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("dependency waves", () => {
|
|
348
|
+
test("two caller-composed waves run end to end without changing the definition", async () => {
|
|
349
|
+
const child = await makeWorkflow(buildWorkflow());
|
|
350
|
+
const workflow = await makeWorkflow(twoWaveDefinition(child.id));
|
|
351
|
+
const before = await getWorkflow(workflow.id);
|
|
352
|
+
|
|
353
|
+
const runId = await startWorkflowExecution(workflow, {}, registry);
|
|
354
|
+
await drainForeachDrives();
|
|
355
|
+
const run = await waitForRun(runId, (r) => r.status === "completed");
|
|
356
|
+
|
|
357
|
+
const wave2 = (run.context as Record<string, unknown>).wave2 as {
|
|
358
|
+
okCount: number;
|
|
359
|
+
results: Array<{ itemKey: string; output: { outputs: { build: { deps: unknown } } } }>;
|
|
360
|
+
};
|
|
361
|
+
expect(wave2.okCount).toBe(1);
|
|
362
|
+
expect(wave2.results[0]?.output.outputs.build.deps).toEqual([
|
|
363
|
+
{ built: "built-a" },
|
|
364
|
+
{ built: "built-b" },
|
|
365
|
+
]);
|
|
366
|
+
|
|
367
|
+
const after = await getWorkflow(workflow.id);
|
|
368
|
+
expect(JSON.stringify(after?.definition)).toBe(JSON.stringify(before?.definition));
|
|
369
|
+
expect(after?.updatedAt).toBe(before?.updatedAt);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
/** The documented two-wave example (runbooks/workflows.md), with `workflowId` filled in. */
|
|
374
|
+
function twoWaveDefinition(buildWorkflowId: string): WorkflowDefinition {
|
|
375
|
+
return {
|
|
376
|
+
nodes: [
|
|
377
|
+
{ id: "plan", type: "swarm-script", config: { scriptName: "plan-waves" }, next: "wave1" },
|
|
378
|
+
{
|
|
379
|
+
id: "wave1",
|
|
380
|
+
type: "foreach",
|
|
381
|
+
inputs: { p: "plan" },
|
|
382
|
+
config: {
|
|
383
|
+
over: "{{p.result.wave1}}",
|
|
384
|
+
itemKey: "id",
|
|
385
|
+
concurrency: 3,
|
|
386
|
+
failurePolicy: "fail-fast",
|
|
387
|
+
body: {
|
|
388
|
+
type: "sub-workflow",
|
|
389
|
+
config: { workflowId: buildWorkflowId, inputs: { spec: "{{item}}" } },
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
next: "prep2",
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
id: "prep2",
|
|
396
|
+
type: "swarm-script",
|
|
397
|
+
inputs: { p: "plan", w1: "wave1" },
|
|
398
|
+
config: {
|
|
399
|
+
scriptName: "prep-wave2",
|
|
400
|
+
args: { wave1: "{{w1}}", wave2: "{{p.result.wave2}}" },
|
|
401
|
+
},
|
|
402
|
+
next: "wave2",
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
id: "wave2",
|
|
406
|
+
type: "foreach",
|
|
407
|
+
inputs: { prep: "prep2" },
|
|
408
|
+
config: {
|
|
409
|
+
over: "{{prep.result}}",
|
|
410
|
+
itemKey: "id",
|
|
411
|
+
concurrency: 3,
|
|
412
|
+
body: {
|
|
413
|
+
type: "sub-workflow",
|
|
414
|
+
config: {
|
|
415
|
+
workflowId: buildWorkflowId,
|
|
416
|
+
inputs: { spec: "{{item}}", deps: "{{item.deps}}" },
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
],
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function buildWorkflow(): WorkflowDefinition {
|
|
426
|
+
return {
|
|
427
|
+
nodes: [
|
|
428
|
+
{
|
|
429
|
+
id: "build",
|
|
430
|
+
type: "test-build",
|
|
431
|
+
config: { spec: "{{trigger.spec}}", deps: "{{trigger.deps}}" },
|
|
432
|
+
},
|
|
433
|
+
],
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* A 4-item agent-task fan-out, cap 1, fail-fast: item i0 completes, i1 fails,
|
|
439
|
+
* i2 and i3 are cancelled before they start. Returns the failed run id.
|
|
440
|
+
*/
|
|
441
|
+
async function failSecondOfFour(): Promise<string> {
|
|
442
|
+
const workflow = await makeWorkflow({
|
|
443
|
+
nodes: [
|
|
444
|
+
{
|
|
445
|
+
id: "fan",
|
|
446
|
+
type: "foreach",
|
|
447
|
+
config: {
|
|
448
|
+
over: "{{trigger.items}}",
|
|
449
|
+
itemKey: "id",
|
|
450
|
+
concurrency: 1,
|
|
451
|
+
failurePolicy: "fail-fast",
|
|
452
|
+
body: { type: "agent-task", config: { agentId, template: "Item {{item.id}}" } },
|
|
453
|
+
},
|
|
454
|
+
next: "after",
|
|
455
|
+
},
|
|
456
|
+
{ id: "after", type: "notify", config: { channel: "swarm", template: "done" } },
|
|
457
|
+
],
|
|
458
|
+
});
|
|
459
|
+
const items = ["i0", "i1", "i2", "i3"].map((id) => ({ id }));
|
|
460
|
+
const runId = await startWorkflowExecution(workflow, { items }, registry);
|
|
461
|
+
await drainForeachDrives();
|
|
462
|
+
const [first] = await inFlightTasks(runId);
|
|
463
|
+
await completeTask(first!.taskId, "first ok");
|
|
464
|
+
await waitForStep(first!.stepId, "completed");
|
|
465
|
+
await drainForeachDrives();
|
|
466
|
+
const [second] = await inFlightTasks(runId);
|
|
467
|
+
await failTask(second!.taskId, "second broke");
|
|
468
|
+
await waitForRun(runId, (r) => r.status === "failed");
|
|
469
|
+
const statuses = (await children(runId)).map((step) => step.status);
|
|
470
|
+
expect(statuses).toEqual(["completed", "failed", "cancelled", "cancelled"]);
|
|
471
|
+
return runId;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function makeWorkflow(definition: WorkflowDefinition): Promise<Workflow> {
|
|
475
|
+
return createWorkflow({ name: `foreach-retry-${crypto.randomUUID()}`, definition });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function children(runId: string): Promise<WorkflowRunStep[]> {
|
|
479
|
+
return (await getWorkflowRunStepsByRunId(runId)).filter((step) => step.nodeId.startsWith("fan#"));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function attemptOf(step: WorkflowRunStep): number {
|
|
483
|
+
const attempt = (step.input as { attempt?: unknown } | null)?.attempt;
|
|
484
|
+
return typeof attempt === "number" ? attempt : 0;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function inFlightTasks(
|
|
488
|
+
runId: string,
|
|
489
|
+
): Promise<Array<{ stepId: string; nodeId: string; taskId: string }>> {
|
|
490
|
+
const result = [];
|
|
491
|
+
for (const step of await children(runId)) {
|
|
492
|
+
if (step.status !== "waiting" && step.status !== "running") continue;
|
|
493
|
+
const task = await getTaskByWorkflowRunStepId(step.id);
|
|
494
|
+
if (task) result.push({ stepId: step.id, nodeId: step.nodeId, taskId: task.id });
|
|
495
|
+
}
|
|
496
|
+
return result;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function taskCountForNode(runId: string, nodeId: string): Promise<number> {
|
|
500
|
+
const row = await getDbClient().get<{ count: number }>(
|
|
501
|
+
`SELECT COUNT(*) AS count FROM agent_tasks t
|
|
502
|
+
JOIN workflow_run_steps s ON s.id = t.workflowRunStepId
|
|
503
|
+
WHERE s.runId = ? AND s.nodeId = ?`,
|
|
504
|
+
[runId, nodeId],
|
|
505
|
+
);
|
|
506
|
+
return row?.count ?? 0;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function waitForStep(stepId: string, status: string): Promise<void> {
|
|
510
|
+
const deadline = Date.now() + 3_000;
|
|
511
|
+
while (Date.now() < deadline) {
|
|
512
|
+
if ((await db.getWorkflowRunStep(stepId))?.status === status) return;
|
|
513
|
+
await Bun.sleep(10);
|
|
514
|
+
}
|
|
515
|
+
throw new Error(`Timed out waiting for step ${stepId} to be ${status}`);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function waitForRun(
|
|
519
|
+
runId: string,
|
|
520
|
+
done: (run: WorkflowRun) => boolean,
|
|
521
|
+
timeoutMs = 3_000,
|
|
522
|
+
): Promise<WorkflowRun> {
|
|
523
|
+
const deadline = Date.now() + timeoutMs;
|
|
524
|
+
let run: WorkflowRun | null = null;
|
|
525
|
+
while (Date.now() < deadline) {
|
|
526
|
+
run = await getWorkflowRun(runId);
|
|
527
|
+
if (run && done(run)) return run;
|
|
528
|
+
await Bun.sleep(10);
|
|
529
|
+
}
|
|
530
|
+
throw new Error(`Timed out waiting for run ${runId}; last status ${run?.status} ${run?.error}`);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function removeDbFiles(): Promise<void> {
|
|
534
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
535
|
+
await unlink(TEST_DB_PATH + suffix).catch(() => {});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
@@ -386,16 +386,20 @@ describe("workflow foreach", () => {
|
|
|
386
386
|
await waitFor(async () => (await getWorkflowRun(runId))?.status === "failed");
|
|
387
387
|
|
|
388
388
|
await expect(retryFailedRun(runId, registry)).resolves.toBeUndefined();
|
|
389
|
-
|
|
389
|
+
// The retry is a new attempt row for the same item; the failed row stays.
|
|
390
|
+
const retried = (await latestChild(runId, child.nodeId))!;
|
|
391
|
+
expect(retried.id).not.toBe(child.id);
|
|
392
|
+
const replacementTask = await getTaskByWorkflowRunStepId(retried.id);
|
|
390
393
|
expect(replacementTask).not.toBeNull();
|
|
391
394
|
expect(replacementTask?.id).not.toBe(originalTask.id);
|
|
392
395
|
expect((await getWorkflowRun(runId))?.status).toBe("waiting");
|
|
393
396
|
|
|
394
|
-
await completeChild(runId,
|
|
397
|
+
await completeChild(runId, retried.id, "retried", bus);
|
|
395
398
|
await waitFor(async () => (await getWorkflowRun(runId))?.status === "completed");
|
|
396
399
|
expect((await stepByNodeId(runId, "after"))?.status).toBe("completed");
|
|
397
400
|
expect(((await getContext(runId)).reflect as { okCount: number }).okCount).toBe(1);
|
|
398
|
-
expect((await stepById(runId,
|
|
401
|
+
expect((await stepById(runId, retried.id))?.error).toBeUndefined();
|
|
402
|
+
expect((await stepById(runId, child.id))?.status).toBe("failed");
|
|
399
403
|
expect((await getWorkflowRun(runId))?.error).toBeUndefined();
|
|
400
404
|
});
|
|
401
405
|
|
|
@@ -433,10 +437,11 @@ describe("workflow foreach", () => {
|
|
|
433
437
|
});
|
|
434
438
|
await Bun.sleep(20);
|
|
435
439
|
|
|
440
|
+
const retried = (await latestChild(runId, child.nodeId))!;
|
|
436
441
|
expect((await getWorkflowRun(runId))?.status).toBe("waiting");
|
|
437
|
-
expect((await stepById(runId,
|
|
442
|
+
expect((await stepById(runId, retried.id))?.status).toBe("waiting");
|
|
438
443
|
|
|
439
|
-
await completeChild(runId,
|
|
444
|
+
await completeChild(runId, retried.id, "real retry output", bus);
|
|
440
445
|
await waitFor(async () => (await getWorkflowRun(runId))?.status === "completed");
|
|
441
446
|
const aggregate = (await getContext(runId)).reflect as {
|
|
442
447
|
results: Array<{ output: { taskOutput: string } }>;
|
|
@@ -915,7 +920,7 @@ describe("workflow foreach", () => {
|
|
|
915
920
|
expect(deferred.valid).toBe(true);
|
|
916
921
|
});
|
|
917
922
|
|
|
918
|
-
test("definition validation rejects reserved hashes and foreach concurrency", () => {
|
|
923
|
+
test("definition validation rejects reserved hashes and validates foreach concurrency", () => {
|
|
919
924
|
const hashResult = validateDefinition({
|
|
920
925
|
nodes: [{ id: "reflect#bad", type: "record", config: { message: "bad" } }],
|
|
921
926
|
});
|
|
@@ -947,24 +952,35 @@ describe("workflow foreach", () => {
|
|
|
947
952
|
newHashId.errors.some((error) => error.includes('Node "fresh#node" contains reserved')),
|
|
948
953
|
).toBe(true);
|
|
949
954
|
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
955
|
+
const withConcurrency = (concurrency: unknown) =>
|
|
956
|
+
validateDefinition({
|
|
957
|
+
nodes: [
|
|
958
|
+
{
|
|
959
|
+
id: "reflect",
|
|
960
|
+
type: "foreach",
|
|
961
|
+
config: {
|
|
962
|
+
over: [],
|
|
963
|
+
itemKey: "id",
|
|
964
|
+
concurrency,
|
|
965
|
+
body: { type: "agent-task", config: { template: "Reflect" } },
|
|
966
|
+
},
|
|
960
967
|
},
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
+
],
|
|
969
|
+
});
|
|
970
|
+
for (const accepted of [1, 2, 50]) {
|
|
971
|
+
expect(withConcurrency(accepted).valid).toBe(true);
|
|
972
|
+
}
|
|
973
|
+
for (const rejected of [0, -1, 1.5, "2"]) {
|
|
974
|
+
const result = withConcurrency(rejected);
|
|
975
|
+
expect(result.valid).toBe(false);
|
|
976
|
+
expect(
|
|
977
|
+
result.errors.some((error) =>
|
|
978
|
+
error.includes(
|
|
979
|
+
'Node "reflect" foreach config.concurrency must be an integer of 1 or more',
|
|
980
|
+
),
|
|
981
|
+
),
|
|
982
|
+
).toBe(true);
|
|
983
|
+
}
|
|
968
984
|
});
|
|
969
985
|
|
|
970
986
|
test("definition validation rejects foreach inside a cycle through a port edge", () => {
|
|
@@ -1055,6 +1071,11 @@ async function foreachChildren(runId: string) {
|
|
|
1055
1071
|
);
|
|
1056
1072
|
}
|
|
1057
1073
|
|
|
1074
|
+
/** The latest attempt row of a foreach item. */
|
|
1075
|
+
async function latestChild(runId: string, nodeId: string) {
|
|
1076
|
+
return (await getWorkflowRunStepsByRunId(runId)).filter((step) => step.nodeId === nodeId).at(-1);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1058
1079
|
async function stepByNodeId(runId: string, nodeId: string) {
|
|
1059
1080
|
return (await getWorkflowRunStepsByRunId(runId)).find((step) => step.nodeId === nodeId);
|
|
1060
1081
|
}
|
|
@@ -74,23 +74,23 @@ describe("failStepAndRunIfWaiting", () => {
|
|
|
74
74
|
// The live task.failed handler and the recovery sweep both saw `waiting`
|
|
75
75
|
// before their awaits; only the transactional re-read may win.
|
|
76
76
|
const [a, b] = await Promise.all([
|
|
77
|
-
failStepAndRunIfWaiting(stepId, runId, "live event"),
|
|
78
|
-
failStepAndRunIfWaiting(stepId, runId, "recovery sweep"),
|
|
77
|
+
failStepAndRunIfWaiting(def, stepId, runId, "live event"),
|
|
78
|
+
failStepAndRunIfWaiting(def, stepId, runId, "recovery sweep"),
|
|
79
79
|
]);
|
|
80
|
-
expect([a, b].filter(Boolean).length).toBe(1);
|
|
80
|
+
expect([a.claimed, b.claimed].filter(Boolean).length).toBe(1);
|
|
81
81
|
const step = await getWorkflowRunStep(stepId);
|
|
82
82
|
const run = await getWorkflowRun(runId);
|
|
83
83
|
expect(step?.status).toBe("failed");
|
|
84
84
|
expect(run?.status).toBe("failed");
|
|
85
85
|
// The loser must not have overwritten the winner's reason.
|
|
86
|
-
expect(step?.error).toBe(a ? "live event" : "recovery sweep");
|
|
86
|
+
expect(step?.error).toBe(a.claimed ? "live event" : "recovery sweep");
|
|
87
87
|
});
|
|
88
88
|
|
|
89
89
|
test("does not stomp a step another handler already routed", async () => {
|
|
90
90
|
const { runId, stepId } = await createWaitingStep();
|
|
91
91
|
await updateWorkflowRunStep(stepId, { status: "completed" });
|
|
92
92
|
await updateWorkflowRun(runId, { status: "running" });
|
|
93
|
-
expect(await failStepAndRunIfWaiting(stepId, runId, "stale sweep")).toBe(false);
|
|
93
|
+
expect((await failStepAndRunIfWaiting(def, stepId, runId, "stale sweep")).claimed).toBe(false);
|
|
94
94
|
expect((await getWorkflowRunStep(stepId))?.status).toBe("completed");
|
|
95
95
|
expect((await getWorkflowRun(runId))?.status).toBe("running");
|
|
96
96
|
});
|
|
@@ -145,7 +145,7 @@ describe("checkpointPortStepAndResolveSuccessors", () => {
|
|
|
145
145
|
);
|
|
146
146
|
expect(routing.claimed).toBe(true);
|
|
147
147
|
expect(routing.successors.map((n) => n.id)).toEqual(["after-reject"]);
|
|
148
|
-
expect(await failStepAndRunIfWaiting(stepId, runId, "late failure")).toBe(false);
|
|
148
|
+
expect((await failStepAndRunIfWaiting(def, stepId, runId, "late failure")).claimed).toBe(false);
|
|
149
149
|
expect((await getWorkflowRunStep(stepId))?.status).toBe("completed");
|
|
150
150
|
expect((await getWorkflowRun(runId))?.status).toBe("running");
|
|
151
151
|
});
|