@desplega.ai/agent-swarm 2.5.3 → 2.6.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 +2 -2
- package/dist/{acp-adapter-gr2fcyyj.js → acp-adapter-dgfakfpw.js} +9 -9
- package/dist/{actions-wv766ghf.js → actions-fbqma8aa.js} +21 -21
- package/dist/{amp-adapter-speb906t.js → amp-adapter-en0jy6hs.js} +4 -4
- package/dist/{app-ag29n14f.js → app-k8a5cgpp.js} +3 -3
- package/dist/{assistant-3x8bz3eg.js → assistant-a16433gr.js} +30 -26
- package/dist/{boot-reembed-jsmfrpzf.js → boot-reembed-6c1sjfnr.js} +1 -1
- package/dist/{boot-reembed-5b3d2eea.js → boot-reembed-f4nn561n.js} +1 -1
- package/dist/{boot-scrub-sweep-s73gerxy.js → boot-scrub-sweep-d4w9gajf.js} +4 -4
- package/dist/{boot-seeds-5zmax2wr.js → boot-seeds-9bmkspyw.js} +4 -4
- package/dist/{claude-adapter-hswdnm3t.js → claude-adapter-nys1kjxa.js} +9 -9
- package/dist/{claude-managed-adapter-cvbzj1h6.js → claude-managed-adapter-55207973.js} +3 -3
- package/dist/{claude-managed-setup-3k8m3hhc.js → claude-managed-setup-3cm35gey.js} +11 -4
- package/dist/{claude-sdk-session-fkbyk66x.js → claude-sdk-session-nw5yd8cp.js} +9313 -9260
- package/dist/{cli-210xd1f4.js → cli-03gg9pd1.js} +4 -4
- package/dist/{cli-kxpfx2xr.js → cli-0z1xjzwa.js} +2 -2
- package/dist/cli-1tem6ns9.js +894 -0
- package/dist/{cli-25j01dpj.js → cli-24j1jeeb.js} +3 -3
- package/dist/{cli-ta8bd7th.js → cli-2sjhpsns.js} +2 -2
- package/dist/{cli-p859hcc8.js → cli-39aet655.js} +2 -2
- package/dist/{cli-g1cw7xtt.js → cli-39rrw8yp.js} +1 -1
- package/dist/{cli-e2ar4bp1.js → cli-3aqd4jaa.js} +121 -47
- package/dist/{cli-jjznk3f7.js → cli-3fwqy71j.js} +69 -35
- package/dist/{cli-p9rafdrb.js → cli-3gfvmzht.js} +7 -7
- package/dist/{cli-03vpq45j.js → cli-3ndmpxxv.js} +2 -2
- package/dist/{cli-aecdcr77.js → cli-4hxrf0p4.js} +228 -74
- package/dist/{cli-mz9afxxf.js → cli-4rf0f5vg.js} +15 -3
- package/dist/{cli-qzme5949.js → cli-5rj83bam.js} +284 -202
- package/dist/{cli-fsdprwrg.js → cli-5sckpqcx.js} +2 -2
- package/dist/{cli-cz5nmdxt.js → cli-683y0b3a.js} +2 -2
- package/dist/{cli-3ngdk5te.js → cli-6y51ryva.js} +2 -2
- package/dist/{cli-n4c8e3s5.js → cli-708wdy32.js} +1 -1
- package/dist/{cli-pasyvjmm.js → cli-737pyqxr.js} +1 -1
- package/dist/{cli-gx4rm0qx.js → cli-7enjwya9.js} +1 -1
- package/dist/{cli-6x9ma5w5.js → cli-803188pb.js} +1 -1
- package/dist/{cli-avdvgcay.js → cli-9abdgn5t.js} +28 -9
- package/dist/{cli-99xgw26r.js → cli-a2rr0fmg.js} +1 -1
- package/dist/{cli-e5bxnjps.js → cli-a34pr3w1.js} +7 -7
- package/dist/{cli-502eqv93.js → cli-abkm71vr.js} +1 -1
- package/dist/{cli-mmhj8vd7.js → cli-ag2yrnt5.js} +11 -2
- package/dist/{cli-n45mr7zh.js → cli-aj8djyqt.js} +5 -5
- package/dist/{cli-1fc85mvt.js → cli-art7c1zh.js} +5 -5
- package/dist/{cli-40jsnrjm.js → cli-az25fn5f.js} +10 -8
- package/dist/{cli-9s05422w.js → cli-bqztfyve.js} +1 -1
- package/dist/{cli-bn0rewsy.js → cli-c081bj3b.js} +2 -2
- package/dist/{cli-svrj71pg.js → cli-c2habvsf.js} +12 -12
- package/dist/{cli-s49hpn37.js → cli-ca6banwz.js} +85 -19
- package/dist/{cli-hhxjnjzs.js → cli-cz37jqss.js} +12 -10
- package/dist/{cli-6n8axjv1.js → cli-d2dpf95n.js} +2 -2
- package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
- package/dist/{cli-z21whrrg.js → cli-emhjcj2y.js} +17 -17
- package/dist/{cli-e8jyq7d8.js → cli-esakngh8.js} +7 -3
- package/dist/{cli-ha9rmwgq.js → cli-fb8m699c.js} +2 -2
- package/dist/{cli-35gtk1p6.js → cli-ffvsj2qw.js} +44 -13
- package/dist/{cli-ce0ak9ms.js → cli-g20jjwt4.js} +26 -26
- package/dist/{cli-kxawjrxg.js → cli-g8ka5j4j.js} +772 -194
- package/dist/{cli-vwn81jbs.js → cli-gf95q3bm.js} +5 -5
- package/dist/{cli-s53vp709.js → cli-gnsb1zxa.js} +2 -2
- package/dist/{cli-nwyw3zhg.js → cli-gxee0bqa.js} +1 -1
- package/dist/{cli-gpv60278.js → cli-hfeb68z4.js} +7 -7
- package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
- package/dist/{cli-ewm3pwbq.js → cli-k21gx5ht.js} +46 -23
- package/dist/{cli-egjggehm.js → cli-kf4jree9.js} +2 -2
- package/dist/{cli-dabehxhp.js → cli-kt907da6.js} +1 -1
- package/dist/{cli-p0rzjkya.js → cli-m5a9m0ak.js} +32 -13
- package/dist/{cli-fzcn3d87.js → cli-m70bajzr.js} +3 -3
- package/dist/{cli-w1zwctj2.js → cli-mtnj4fwa.js} +11 -5
- package/dist/{cli-gpsz7g88.js → cli-mvjz5an3.js} +16 -12
- package/dist/{cli-hcpj2xqz.js → cli-n7asmce6.js} +225 -3
- package/dist/{cli-y561h5a9.js → cli-nn6vx2mq.js} +5 -5
- package/dist/cli-nq677csy.js +140 -0
- package/dist/{cli-a5ebc95n.js → cli-pfk92eqz.js} +18 -15
- package/dist/{cli-p6zs9h4y.js → cli-rr7zx19j.js} +767 -249
- package/dist/{cli-qgf8emrn.js → cli-sqma6vw1.js} +10 -7
- package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
- package/dist/{cli-nzfnt2gs.js → cli-tf1n0r0b.js} +1 -1
- package/dist/{cli-qqzmjxkf.js → cli-v49fkghp.js} +11 -6
- package/dist/{cli-x77gen2p.js → cli-w2656ka1.js} +7 -7
- package/dist/{cli-5kqnemen.js → cli-yvy48785.js} +1 -1
- package/dist/{cli-8s5wx3nz.js → cli-zqne1qcj.js} +44 -17
- package/dist/cli.js +22 -22
- package/dist/{codex-adapter-n8ka66vf.js → codex-adapter-d95q4qsn.js} +7 -7
- package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
- package/dist/{codex-login-rn7c5gxt.js → codex-login-8cg0vhf2.js} +1 -1
- package/dist/{codex-session-runner-fsebts37.js → codex-session-runner-gdwd8h0c.js} +8 -8
- package/dist/{commands-t04rt2pc.js → commands-jqmgk54b.js} +2 -2
- package/dist/{controller-rgna04r2.js → controller-z3jq589f.js} +30 -4
- package/dist/{controllers-connect-bqm1rtft.js → controllers-connect-r2za39ef.js} +23 -23
- package/dist/{cursor-adapter-swr2k95y.js → cursor-adapter-4mr4jgkw.js} +2 -3
- package/dist/{db-a4m7haw0.js → db-ppv2tze7.js} +3 -3
- package/dist/{db-24ck09xy.js → db-w36wwb5g.js} +4 -2
- package/dist/{devin-adapter-4ycy4vrf.js → devin-adapter-byen2cxt.js} +1 -1
- package/dist/{dispatcher-nwf9ss0q.js → dispatcher-pv5h77vf.js} +20 -20
- package/dist/{docker-nfy2gc8t.js → docker-fdzyewpz.js} +4 -4
- package/dist/{dsh-adapter-kpdnhjst.js → dsh-adapter-d4pdpb3r.js} +2 -3
- package/dist/{e2b-9gkdhm3c.js → e2b-222hsajc.js} +2 -2
- package/dist/{e2b-cjtf8k1z.js → e2b-fn3zacvt.js} +2 -2
- package/dist/{easter-egg-3d-e4qv8dxj.js → easter-egg-3d-4sp4dvmc.js} +1 -1
- package/dist/{event-bus-3z78ep5n.js → event-bus-pghg8hqy.js} +1 -1
- package/dist/{execute-0xpz8950.js → execute-jev839y1.js} +1 -1
- package/dist/{external-channel-j1e2p34p.js → external-channel-y6nkzhnn.js} +2 -2
- package/dist/{grok-adapter-qtjz0whk.js → grok-adapter-t08b9ntm.js} +10 -10
- package/dist/handlers-eqsnmv95.js +41 -0
- package/dist/{heartbeat-vtesda5d.js → heartbeat-vn31xwdk.js} +23 -23
- package/dist/{hook-b2hhj6rd.js → hook-6d0zbvvt.js} +6 -5
- package/dist/{hook-8wqaszda.js → hook-cpjs8q51.js} +13 -7
- package/dist/{http-mxcqhhcs.js → http-9c6medda.js} +1085 -136
- package/dist/{index-spwxjgay.js → index-38t35h0n.js} +20 -20
- package/dist/{index-1r8wfpzs.js → index-g6k9tctj.js} +1 -1
- package/dist/{index-srg522m6.js → index-k0yt1tvq.js} +20 -20
- package/dist/{index-31ceyf42.js → index-pkma1yf8.js} +1 -1
- package/dist/{index-n1vbvta9.js → index-qhjf2htm.js} +29 -24
- package/dist/index-yqgbfgaj.js +43 -0
- package/dist/{keepalive-dsd8rtgx.js → keepalive-em0117bp.js} +4 -4
- package/dist/lead-23e7z59t.js +79 -0
- package/dist/{maintenance-jqmqwnet.js → maintenance-kbme3qy9.js} +1 -1
- package/dist/{oauth-refresh-sweep-q2c3bnvm.js → oauth-refresh-sweep-xysj4yse.js} +3 -3
- package/dist/{onboard-w4yd7s8t.js → onboard-p6q0vz97.js} +3 -3
- package/dist/{opencode-adapter-9tppvxar.js → opencode-adapter-esv3k9yn.js} +7 -7
- package/dist/{otel-impl-f36byzbd.js → otel-impl-54qhqh1z.js} +2 -2
- package/dist/{pi-mono-adapter-tqsge1ay.js → pi-mono-adapter-mymbwhj0.js} +41 -14
- package/dist/{pricing-refresh-xvak5p4v.js → pricing-refresh-yb75wv33.js} +3 -3
- package/dist/{rbac-roles-gfn30fb8.js → rbac-roles-1vp2mw4p.js} +2 -2
- package/dist/{rbac-roles-682k2mt5.js → rbac-roles-dexhc9x8.js} +1 -1
- package/dist/render-v2-tjay5hhf.js +16 -0
- package/dist/{resume-2dvq348g.js → resume-8ayf37g5.js} +11 -9
- package/dist/{runtime-bt5pkpm1.js → runtime-hqe5cqcd.js} +1 -1
- package/dist/{session-access-7f21ekq9.js → session-access-fdhp987s.js} +4 -4
- package/dist/session-source-backfill-qgtc86z1.js +110 -0
- package/dist/{setup-epffxtbk.js → setup-4rrmqcq3.js} +1 -1
- package/dist/{virtual-modules-mnfbh5mr.js → virtual-modules-52km00d6.js} +2 -2
- package/dist/{work-objects-xz1a5rqk.js → work-objects-p28fgd70.js} +23 -23
- package/dist/worker-e3w7vye1.js +79 -0
- package/dist/{x-pbpezr0s.js → x-wxrzvzx8.js} +2 -2
- package/openapi.json +1323 -16
- package/package.json +5 -5
- package/plugin/commands/create-pr.md +2 -2
- package/plugin/pi-skills/create-pr/SKILL.md +2 -2
- package/src/assets/source-key.ts +95 -0
- package/src/be/db/api-keys.ts +80 -0
- package/src/be/db/approvals.ts +48 -0
- package/src/be/db/session-labels.ts +28 -0
- package/src/be/db/session-moves.ts +5 -6
- package/src/be/db/session-tree.ts +153 -14
- package/src/be/db/tasks/read.ts +11 -0
- package/src/be/db/tasks/write.ts +21 -6
- package/src/be/db/workflow-idempotency.ts +183 -0
- package/src/be/db/workflow-runs.ts +500 -7
- package/src/be/db-retention.ts +61 -6
- package/src/be/db.ts +126 -131
- package/src/be/memory/providers/openai-embedding.ts +5 -1
- package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
- package/src/be/migrations/244_api_key_validation.sql +4 -0
- package/src/be/onboarding.ts +4 -0
- package/src/be/scripts/typecheck.ts +1 -1
- package/src/be/sealed-json.ts +1 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
- package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
- package/src/be/seed-skills/bundled-files.generated.json +1 -1
- package/src/be/session-source-backfill.ts +163 -0
- package/src/be/sql-connections.ts +7 -1
- package/src/be/swarm-config-guard.ts +3 -0
- package/src/be/users.ts +9 -0
- package/src/comb/review-batch.ts +28 -1
- package/src/comb/templates.ts +7 -2
- package/src/commands/claude-managed-setup.ts +7 -1
- package/src/commands/controller.ts +33 -0
- package/src/commands/credential-refresh.ts +76 -1
- package/src/commands/credential-wait.ts +2 -2
- package/src/commands/provider-credentials.ts +210 -170
- package/src/commands/runner.ts +31 -6
- package/src/extensions/apply-task-create.ts +64 -11
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/extensions/contract.ts +30 -0
- package/src/extensions/dispatcher.ts +4 -0
- package/src/extensions/post-bridge.ts +8 -0
- package/src/hooks/context-bound-note.ts +196 -0
- package/src/hooks/hook.ts +12 -0
- package/src/http/all-routes.ts +2 -0
- package/src/http/api-keys.ts +53 -0
- package/src/http/assets.ts +15 -0
- package/src/http/codex-oauth-accounts.ts +191 -0
- package/src/http/comb.ts +15 -2
- package/src/http/config.ts +49 -17
- package/src/http/core.ts +6 -0
- package/src/http/fleet-benchmark.ts +5 -2
- package/src/http/idempotency-key.ts +117 -0
- package/src/http/index.ts +11 -0
- package/src/http/integrations.ts +5 -1
- package/src/http/kv.ts +8 -3
- package/src/http/onboarding.ts +6 -1
- package/src/http/pool-keys.ts +354 -0
- package/src/http/sessions.ts +37 -12
- package/src/http/stats.ts +1 -0
- package/src/http/status.ts +26 -12
- package/src/http/tasks.ts +3 -0
- package/src/http/used-by.ts +7 -2
- package/src/http/workflows.ts +207 -33
- package/src/integrations/chat-sdk/inbound.ts +27 -0
- package/src/kv-reserved-namespaces.ts +19 -0
- package/src/prompts/context-bound-note.ts +48 -0
- package/src/prompts/session-templates.ts +25 -1
- package/src/providers/acp-targets.ts +1 -1
- package/src/providers/amp-adapter.ts +30 -8
- package/src/providers/claude-adapter.ts +34 -5
- package/src/providers/claude-managed-adapter.ts +42 -9
- package/src/providers/claude-sdk-session.ts +10 -0
- package/src/providers/claude-session-events.ts +13 -0
- package/src/providers/codex-adapter.ts +83 -11
- package/src/providers/opencode-adapter.ts +18 -0
- package/src/providers/pi-mono-adapter.ts +35 -0
- package/src/runtime-providers/docker.ts +6 -2
- package/src/scheduler/scheduler.ts +3 -2
- package/src/scripts-runtime/swarm-sdk.ts +7 -1
- package/src/scripts-runtime/types/stdlib.d.ts +1 -1
- package/src/scripts-runtime/types/swarm-extension.d.ts +52 -8
- package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -1
- package/src/slack/assistant.ts +7 -0
- package/src/slack/channel-labels.ts +74 -0
- package/src/slack/handlers.ts +8 -0
- package/src/slack/inbound-files.ts +5 -2
- package/src/slack/thread-buffer.ts +4 -0
- package/src/tasks/sibling-awareness.ts +12 -3
- package/src/telemetry-context.ts +5 -6
- package/src/telemetry-identity.ts +3 -3
- package/src/tests/amp-adapter.test.ts +97 -0
- package/src/tests/api-keys-validation.test.ts +170 -0
- package/src/tests/approval-requests.test.ts +10 -7
- package/src/tests/approval-sweeps.test.ts +12 -6
- package/src/tests/base-prompt.test.ts +1 -1
- package/src/tests/claude-hook-output.test.ts +79 -1
- package/src/tests/claude-managed-adapter.test.ts +321 -0
- package/src/tests/claude-routes-e2e.test.ts +7 -2
- package/src/tests/codex-adapter.test.ts +163 -0
- package/src/tests/codex-credential-pools.test.ts +120 -0
- package/src/tests/codex-gateway-route.test.ts +165 -0
- package/src/tests/codex-oauth-accounts.test.ts +357 -0
- package/src/tests/comb-review-batch.test.ts +63 -2
- package/src/tests/config-pool-keys.test.ts +533 -0
- package/src/tests/context-bound-note.test.ts +269 -0
- package/src/tests/credential-check.test.ts +290 -2
- package/src/tests/credential-refresh.test.ts +172 -1
- package/src/tests/db-retention.test.ts +9 -3
- package/src/tests/db-task-read-contract.test.ts +1 -0
- package/src/tests/db-task-write-contract.test.ts +1 -3
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/extensions-pre-task.test.ts +325 -0
- package/src/tests/fetch-resolved-env.test.ts +17 -0
- package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
- package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
- package/src/tests/fixtures/extensions/record-task-create/hooks.ts +9 -0
- package/src/tests/fixtures/extensions/record-task-create/manifest.json +7 -0
- package/src/tests/fixtures/fake-amp.ts +22 -8
- package/src/tests/fixtures/held-template-render.ts +42 -0
- package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
- package/src/tests/fleet-benchmark.test.ts +8 -0
- package/src/tests/get-workflow-run-tool.test.ts +98 -0
- package/src/tests/integrations-http.test.ts +32 -2
- package/src/tests/internal-ai/complete-structured.test.ts +37 -1
- package/src/tests/internal-ai/credentials.test.ts +23 -0
- package/src/tests/kv-http.test.ts +32 -0
- package/src/tests/kv-tool.test.ts +25 -0
- package/src/tests/multi-runtime-registration.test.ts +12 -12
- package/src/tests/onboarding.test.ts +108 -1
- package/src/tests/openai-embedding-credentials.test.ts +35 -0
- package/src/tests/openapi-route-headers.test.ts +2 -0
- package/src/tests/opencode-adapter.test.ts +134 -0
- package/src/tests/openrouter-attribution-call-sites.test.ts +2 -2
- package/src/tests/org-website.test.ts +36 -0
- package/src/tests/pi-mono-adapter.test.ts +105 -0
- package/src/tests/prompt-template-session.test.ts +4 -3
- package/src/tests/provider-key-probes.test.ts +181 -0
- package/src/tests/rbac-e2e-helpers.test.ts +88 -1
- package/src/tests/rbac-e2e-helpers.ts +36 -11
- package/src/tests/runner-push-delivery.test.ts +25 -0
- package/src/tests/runtime-controller-liveness.test.ts +133 -0
- package/src/tests/runtime-providers-docker.test.ts +2 -0
- package/src/tests/script-connections.test.ts +5 -0
- package/src/tests/script-run-config-scope.test.ts +91 -3
- package/src/tests/script-runs-retention.test.ts +212 -0
- package/src/tests/sdk-allowlist.test.ts +39 -0
- package/src/tests/session-logs.test.ts +70 -0
- package/src/tests/session-source-backfill.test.ts +429 -0
- package/src/tests/session-source-keys.test.ts +236 -0
- package/src/tests/session-source-labels.test.ts +419 -0
- package/src/tests/sessions-detail-batch.test.ts +2 -0
- package/src/tests/sessions-move.test.ts +131 -0
- package/src/tests/sessions-tree.test.ts +336 -2
- package/src/tests/slack-inbound-files.test.ts +103 -23
- package/src/tests/status.test.ts +101 -18
- package/src/tests/tasks-workflow-run-filter.test.ts +146 -0
- package/src/tests/test-net.ts +31 -0
- package/src/tests/transcription.test.ts +8 -0
- package/src/tests/used-by-route.test.ts +11 -0
- package/src/tests/workflow-cascade-cancel.test.ts +1341 -0
- package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
- package/src/tests/workflow-http-v2.test.ts +131 -0
- package/src/tests/workflow-idempotency.test.ts +915 -0
- package/src/tests/workflow-step-order.test.ts +92 -0
- package/src/tests/workflow-sub-workflow.test.ts +30 -0
- package/src/tools/get-tasks.ts +8 -0
- package/src/tools/kv/kv-read-auth.ts +4 -2
- package/src/tools/task-action.ts +1 -1
- package/src/tools/workflows/cancel-workflow-run.ts +1 -1
- package/src/tools/workflows/get-workflow-run.ts +14 -4
- package/src/types.ts +24 -0
- package/src/utils/company-profile.ts +51 -1
- package/src/utils/context-window.ts +8 -6
- package/src/utils/credentials.ts +5 -2
- package/src/utils/internal-ai/complete-structured.ts +5 -0
- package/src/utils/internal-ai/credentials.ts +4 -3
- package/src/utils/provider-key-probes.ts +328 -0
- package/src/utils/transcription.ts +4 -1
- package/src/workflows/approval-cancel.ts +15 -13
- package/src/workflows/approval-notifications.ts +5 -1
- package/src/workflows/checkpoint.ts +6 -3
- package/src/workflows/engine.ts +30 -8
- package/src/workflows/executors/foreach.ts +2 -2
- package/src/workflows/executors/sub-workflow.ts +8 -1
- package/src/workflows/foreach-driver.ts +10 -3
- package/src/workflows/foreach-join.ts +29 -6
- package/src/workflows/recovery.ts +6 -1
- package/src/workflows/resume.ts +168 -56
- package/src/workflows/retry-poller.ts +49 -3
- package/src/workflows/task-step-routing.ts +29 -7
- package/templates/ai-toolbox.manifest.json +8 -8
- package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
- package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
- package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
- package/templates/skills/agent-browser/SKILL.md +4 -4
- package/templates/skills/agent-browser/content.md +4 -4
- package/templates/skills/code-quality/config.json +1 -1
- package/templates/skills/code-quality/content.md +2 -2
- package/templates/skills/delegate-work/content.md +29 -25
- package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
- package/templates/skills/qa/content.md +2 -2
- package/templates/skills/researching/content.md +4 -0
- package/templates/skills/swarm-scripts/SKILL.md +1 -0
- package/templates/skills/swarm-scripts/content.md +1 -0
- package/templates/skills/work-on-task/SKILL.md +1 -1
- package/templates/skills/work-on-task/content.md +1 -1
- package/dist/cli-7421jzsw.js +0 -84
- package/dist/cli-e45e4m6b.js +0 -413
- package/dist/cli-zw88zjba.js +0 -307
- package/dist/handlers-19xr6dnz.js +0 -41
- package/dist/index-kw2mmat7.js +0 -43
- package/dist/lead-zyerk1x2.js +0 -79
- package/dist/render-v2-hgytz8nn.js +0 -12
- package/dist/worker-42t8emjt.js +0 -79
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, mock, spyOn, test } from "bun:test";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { mkdtemp, rm, unlink } from "node:fs/promises";
|
|
4
|
+
import {
|
|
5
|
+
createServer as createHttpServer,
|
|
6
|
+
type IncomingMessage,
|
|
7
|
+
type Server,
|
|
8
|
+
type ServerResponse,
|
|
9
|
+
} from "node:http";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import * as preflightAlerts from "../automation-preflight-alert";
|
|
13
|
+
import {
|
|
14
|
+
closeDb,
|
|
15
|
+
createUser,
|
|
16
|
+
createWorkflow,
|
|
17
|
+
createWorkflowRun,
|
|
18
|
+
getDbClient,
|
|
19
|
+
getWorkflowRun,
|
|
20
|
+
initDb,
|
|
21
|
+
listWorkflowRuns,
|
|
22
|
+
updateWorkflowRun,
|
|
23
|
+
upsertSwarmConfig,
|
|
24
|
+
} from "../be/db";
|
|
25
|
+
import * as idempotencyDb from "../be/db/workflow-idempotency";
|
|
26
|
+
import { mintToken, revokeToken } from "../be/users";
|
|
27
|
+
import { resolveHttpRequestAuth } from "../http/auth";
|
|
28
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
29
|
+
import { handleWorkflows } from "../http/workflows";
|
|
30
|
+
import type { Workflow, WorkflowDefinition, WorkflowRun } from "../types";
|
|
31
|
+
import { beginRequestAuthScope, setRequestAuth } from "../utils/request-auth-context";
|
|
32
|
+
import { getExecutorRegistry, recoverIncompleteRuns, startWorkflowExecution } from "../workflows";
|
|
33
|
+
import { listenOnFreePort } from "./test-net";
|
|
34
|
+
import { CHILD_PROCESS_TEST_BUDGET_MS, expectChildOk, runChild } from "./test-proc";
|
|
35
|
+
|
|
36
|
+
const TEST_DB_PATH = "./test-workflow-idempotency.sqlite";
|
|
37
|
+
const API_KEY = "workflow-idempotency-test-api-key";
|
|
38
|
+
const ACTOR = { kind: "system", id: "workflow-idempotency-test" } as const;
|
|
39
|
+
|
|
40
|
+
// ─── Test server: the real auth resolution, then the workflow routes ─────────
|
|
41
|
+
|
|
42
|
+
function createTestServer(): Server {
|
|
43
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
44
|
+
beginRequestAuthScope();
|
|
45
|
+
const auth = await resolveHttpRequestAuth(req, API_KEY);
|
|
46
|
+
if (!auth) {
|
|
47
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
48
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
setRequestAuth(req, auth);
|
|
52
|
+
// Commit everything, then lose the response on the wire.
|
|
53
|
+
if (req.headers["x-test-drop-response"]) {
|
|
54
|
+
res.writeHead = (() => {
|
|
55
|
+
req.socket.destroy();
|
|
56
|
+
return res;
|
|
57
|
+
}) as unknown as typeof res.writeHead;
|
|
58
|
+
res.end = (() => res) as unknown as typeof res.end;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const handled = await handleWorkflows(
|
|
62
|
+
req,
|
|
63
|
+
res,
|
|
64
|
+
getPathSegments(req.url || ""),
|
|
65
|
+
parseQueryParams(req.url || ""),
|
|
66
|
+
req.headers["x-agent-id"] as string | undefined,
|
|
67
|
+
);
|
|
68
|
+
if (!handled) {
|
|
69
|
+
res.writeHead(404);
|
|
70
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
|
|
74
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let baseUrl = "";
|
|
80
|
+
const agentA = crypto.randomUUID();
|
|
81
|
+
const agentB = crypto.randomUUID();
|
|
82
|
+
|
|
83
|
+
interface CallOptions {
|
|
84
|
+
key?: string;
|
|
85
|
+
body?: unknown;
|
|
86
|
+
/** Sent verbatim instead of `JSON.stringify(body)`. */
|
|
87
|
+
rawBody?: string;
|
|
88
|
+
bearer?: string;
|
|
89
|
+
agentId?: string | null;
|
|
90
|
+
dropResponse?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface CallResult {
|
|
94
|
+
status: number;
|
|
95
|
+
body: any;
|
|
96
|
+
replayed: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function call(method: string, path: string, opts: CallOptions = {}): Promise<CallResult> {
|
|
100
|
+
const headers: Record<string, string> = {
|
|
101
|
+
"Content-Type": "application/json",
|
|
102
|
+
Authorization: `Bearer ${opts.bearer ?? API_KEY}`,
|
|
103
|
+
};
|
|
104
|
+
const agentId = opts.agentId === undefined ? agentA : opts.agentId;
|
|
105
|
+
if (agentId) headers["X-Agent-ID"] = agentId;
|
|
106
|
+
if (opts.key !== undefined) headers["Idempotency-Key"] = opts.key;
|
|
107
|
+
if (opts.dropResponse) headers["x-test-drop-response"] = "1";
|
|
108
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
109
|
+
method,
|
|
110
|
+
headers,
|
|
111
|
+
body: opts.rawBody ?? (opts.body === undefined ? undefined : JSON.stringify(opts.body)),
|
|
112
|
+
});
|
|
113
|
+
const text = await res.text();
|
|
114
|
+
return {
|
|
115
|
+
status: res.status,
|
|
116
|
+
body: text ? JSON.parse(text) : undefined,
|
|
117
|
+
replayed: res.headers.get("idempotent-replayed") === "true",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const createCall = (body: unknown, opts: CallOptions = {}) =>
|
|
122
|
+
call("POST", "/api/workflows", { ...opts, body });
|
|
123
|
+
const triggerCall = (workflowId: string, body: unknown, opts: CallOptions = {}) =>
|
|
124
|
+
call("POST", `/api/workflows/${workflowId}/trigger`, { ...opts, body });
|
|
125
|
+
|
|
126
|
+
const newKey = () => `key-${crypto.randomUUID()}`;
|
|
127
|
+
const uniqueName = () => `idem-${crypto.randomUUID()}`;
|
|
128
|
+
|
|
129
|
+
function instantDefinition(): WorkflowDefinition {
|
|
130
|
+
return { nodes: [{ id: "n1", type: "notify", config: { channel: "swarm", template: "hi" } }] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function makeWorkflow(overrides: Partial<Parameters<typeof createWorkflow>[0]> = {}) {
|
|
134
|
+
return createWorkflow({ name: uniqueName(), definition: instantDefinition(), ...overrides });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function countWorkflowsNamed(name: string): Promise<number> {
|
|
138
|
+
const row = await getDbClient().get<{ n: number }>(
|
|
139
|
+
"SELECT COUNT(*) AS n FROM workflows WHERE name = ?",
|
|
140
|
+
[name],
|
|
141
|
+
);
|
|
142
|
+
return row?.n ?? 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function countSteps(workflowId: string): Promise<number> {
|
|
146
|
+
const row = await getDbClient().get<{ n: number }>(
|
|
147
|
+
`SELECT COUNT(*) AS n FROM workflow_run_steps
|
|
148
|
+
WHERE runId IN (SELECT id FROM workflow_runs WHERE workflowId = ?)`,
|
|
149
|
+
[workflowId],
|
|
150
|
+
);
|
|
151
|
+
return row?.n ?? 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function countRecords(workflowId?: string): Promise<number> {
|
|
155
|
+
const row = await getDbClient().get<{ n: number }>(
|
|
156
|
+
workflowId
|
|
157
|
+
? "SELECT COUNT(*) AS n FROM workflow_idempotency_keys WHERE workflowId = ?"
|
|
158
|
+
: "SELECT COUNT(*) AS n FROM workflow_idempotency_keys",
|
|
159
|
+
workflowId ? [workflowId] : [],
|
|
160
|
+
);
|
|
161
|
+
return row?.n ?? 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const TERMINAL = new Set(["completed", "failed", "cancelled", "skipped"]);
|
|
165
|
+
|
|
166
|
+
async function waitForTerminalRun(runId: string): Promise<WorkflowRun> {
|
|
167
|
+
const deadline = Date.now() + 5_000;
|
|
168
|
+
for (;;) {
|
|
169
|
+
const run = await getWorkflowRun(runId);
|
|
170
|
+
if (run && TERMINAL.has(run.status)) return run;
|
|
171
|
+
if (Date.now() > deadline) throw new Error(`run ${runId} still ${run?.status}`);
|
|
172
|
+
await Bun.sleep(10);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Hold every request at its idempotency lookup until `parties` requests have
|
|
178
|
+
* looked the key up, so all of them see it unused before any one commits.
|
|
179
|
+
*/
|
|
180
|
+
function holdLookupsUntil(parties: number) {
|
|
181
|
+
const original = idempotencyDb.findAcceptedWorkflowOperation;
|
|
182
|
+
let arrived = 0;
|
|
183
|
+
let release!: () => void;
|
|
184
|
+
const allArrived = new Promise<void>((resolve) => {
|
|
185
|
+
release = resolve;
|
|
186
|
+
});
|
|
187
|
+
return spyOn(idempotencyDb, "findAcceptedWorkflowOperation").mockImplementation(async (scope) => {
|
|
188
|
+
const found = await original(scope);
|
|
189
|
+
if (++arrived === parties) release();
|
|
190
|
+
await allArrived;
|
|
191
|
+
return found;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A real file instead of the preload's in-memory template: the restart tests
|
|
196
|
+
// reopen it and the cross-process test opens it from child processes.
|
|
197
|
+
const testGlobals = globalThis as typeof globalThis & {
|
|
198
|
+
__testMigrationTemplate?: Uint8Array;
|
|
199
|
+
__savedWorkflowIdempotencyTemplate?: Uint8Array;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
async function restartDb(): Promise<void> {
|
|
203
|
+
closeDb();
|
|
204
|
+
initDb(TEST_DB_PATH);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function removeDbFiles(): Promise<void> {
|
|
208
|
+
for (const suffix of ["", "-wal", "-shm"]) await unlink(TEST_DB_PATH + suffix).catch(() => {});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── Setup ───────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
let server: Server;
|
|
214
|
+
|
|
215
|
+
beforeAll(async () => {
|
|
216
|
+
testGlobals.__savedWorkflowIdempotencyTemplate = testGlobals.__testMigrationTemplate;
|
|
217
|
+
testGlobals.__testMigrationTemplate = undefined;
|
|
218
|
+
closeDb();
|
|
219
|
+
await removeDbFiles();
|
|
220
|
+
initDb(TEST_DB_PATH);
|
|
221
|
+
server = createTestServer();
|
|
222
|
+
baseUrl = `http://localhost:${await listenOnFreePort(server)}`;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
afterAll(async () => {
|
|
226
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
227
|
+
closeDb();
|
|
228
|
+
testGlobals.__testMigrationTemplate = testGlobals.__savedWorkflowIdempotencyTemplate;
|
|
229
|
+
testGlobals.__savedWorkflowIdempotencyTemplate = undefined;
|
|
230
|
+
await removeDbFiles();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
afterEach(() => {
|
|
234
|
+
mock.restore();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ─── Create ──────────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
describe("POST /api/workflows with Idempotency-Key", () => {
|
|
240
|
+
test("a retry returns the original workflow and creates no second one", async () => {
|
|
241
|
+
const key = newKey();
|
|
242
|
+
const body = { name: uniqueName(), definition: instantDefinition() };
|
|
243
|
+
|
|
244
|
+
const first = await createCall(body, { key });
|
|
245
|
+
const retry = await createCall(body, { key });
|
|
246
|
+
|
|
247
|
+
expect(first.status).toBe(201);
|
|
248
|
+
expect(first.replayed).toBe(false);
|
|
249
|
+
expect(retry.status).toBe(201);
|
|
250
|
+
expect(retry.replayed).toBe(true);
|
|
251
|
+
expect(retry.body).toEqual(first.body);
|
|
252
|
+
expect(await countWorkflowsNamed(body.name)).toBe(1);
|
|
253
|
+
expect(await countRecords(first.body.id)).toBe(1);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("object key order does not change the request; array order does", async () => {
|
|
257
|
+
const key = newKey();
|
|
258
|
+
const name = uniqueName();
|
|
259
|
+
const definition = JSON.stringify(instantDefinition());
|
|
260
|
+
const first = await call("POST", "/api/workflows", {
|
|
261
|
+
key,
|
|
262
|
+
rawBody: `{"name":"${name}","requiredParams":["A","B"],"definition":${definition}}`,
|
|
263
|
+
});
|
|
264
|
+
const reordered = await call("POST", "/api/workflows", {
|
|
265
|
+
key,
|
|
266
|
+
rawBody: `{"definition":${definition},"requiredParams":["A","B"],"name":"${name}"}`,
|
|
267
|
+
});
|
|
268
|
+
const swapped = await call("POST", "/api/workflows", {
|
|
269
|
+
key,
|
|
270
|
+
rawBody: `{"name":"${name}","requiredParams":["B","A"],"definition":${definition}}`,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(first.status).toBe(201);
|
|
274
|
+
expect(reordered.status).toBe(201);
|
|
275
|
+
expect(reordered.replayed).toBe(true);
|
|
276
|
+
expect(reordered.body.id).toBe(first.body.id);
|
|
277
|
+
expect(swapped.status).toBe(409);
|
|
278
|
+
expect(await countWorkflowsNamed(name)).toBe(1);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("a different request under the same key is a 409 and creates nothing", async () => {
|
|
282
|
+
const key = newKey();
|
|
283
|
+
const first = await createCall(
|
|
284
|
+
{ name: uniqueName(), definition: instantDefinition() },
|
|
285
|
+
{ key },
|
|
286
|
+
);
|
|
287
|
+
const otherName = uniqueName();
|
|
288
|
+
const conflict = await createCall(
|
|
289
|
+
{ name: otherName, definition: instantDefinition() },
|
|
290
|
+
{ key },
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
expect(first.status).toBe(201);
|
|
294
|
+
expect(conflict.status).toBe(409);
|
|
295
|
+
expect(conflict.body.error).toContain("different request");
|
|
296
|
+
expect(await countWorkflowsNamed(otherName)).toBe(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("a retry gets the saved snapshot, warnings included, after the workflow changed", async () => {
|
|
300
|
+
const savedOpenRouterKey = process.env.OPENROUTER_API_KEY;
|
|
301
|
+
delete process.env.OPENROUTER_API_KEY;
|
|
302
|
+
try {
|
|
303
|
+
const key = newKey();
|
|
304
|
+
const body = {
|
|
305
|
+
name: uniqueName(),
|
|
306
|
+
definition: {
|
|
307
|
+
nodes: [
|
|
308
|
+
{
|
|
309
|
+
id: "qualify",
|
|
310
|
+
type: "system-one-decision",
|
|
311
|
+
inputs: { lead: "trigger.lead" },
|
|
312
|
+
config: {
|
|
313
|
+
state: "{{lead}}",
|
|
314
|
+
questions: { fit: { type: "noul", instructions: "Is this a real lead?" } },
|
|
315
|
+
returns: { fit: { type: "noul" } },
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
const first = await createCall(body, { key });
|
|
322
|
+
expect(first.status).toBe(201);
|
|
323
|
+
expect(first.body.warnings).toHaveLength(1);
|
|
324
|
+
|
|
325
|
+
// Rename it and configure the key: a recomputed view would differ on both.
|
|
326
|
+
const renamed = await call("PUT", `/api/workflows/${first.body.id}`, {
|
|
327
|
+
body: { name: uniqueName() },
|
|
328
|
+
});
|
|
329
|
+
expect(renamed.status).toBe(200);
|
|
330
|
+
await upsertSwarmConfig({
|
|
331
|
+
scope: "global",
|
|
332
|
+
key: "TYPESAFE_API_KEY",
|
|
333
|
+
value: "tsk_example-idempotency-key.0123456789",
|
|
334
|
+
isSecret: true,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const retry = await createCall(body, { key });
|
|
338
|
+
expect(retry.replayed).toBe(true);
|
|
339
|
+
expect(retry.body).toEqual(first.body);
|
|
340
|
+
expect(retry.body.name).toBe(body.name);
|
|
341
|
+
expect(retry.body.warnings).toEqual(first.body.warnings);
|
|
342
|
+
} finally {
|
|
343
|
+
await getDbClient().run("DELETE FROM swarm_config WHERE key = 'TYPESAFE_API_KEY'");
|
|
344
|
+
if (savedOpenRouterKey !== undefined) process.env.OPENROUTER_API_KEY = savedOpenRouterKey;
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("a request rejected before it is accepted does not use up the key", async () => {
|
|
349
|
+
const key = newKey();
|
|
350
|
+
const name = uniqueName();
|
|
351
|
+
const invalid = await createCall(
|
|
352
|
+
{ name, definition: { nodes: [{ id: "x", type: "no-such-executor", config: {} }] } },
|
|
353
|
+
{ key },
|
|
354
|
+
);
|
|
355
|
+
const valid = await createCall({ name, definition: instantDefinition() }, { key });
|
|
356
|
+
|
|
357
|
+
expect(invalid.status).toBe(400);
|
|
358
|
+
expect(valid.status).toBe(201);
|
|
359
|
+
expect(valid.replayed).toBe(false);
|
|
360
|
+
expect(await countWorkflowsNamed(name)).toBe(1);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("a failure inside the accepting transaction leaves neither workflow nor key", async () => {
|
|
364
|
+
const key = newKey();
|
|
365
|
+
const body = { name: uniqueName(), definition: instantDefinition() };
|
|
366
|
+
spyOn(idempotencyDb, "recordAcceptedWorkflowOperation").mockRejectedValueOnce(
|
|
367
|
+
new Error("disk I/O error"),
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const failed = await createCall(body, { key });
|
|
371
|
+
expect(failed.status).toBe(500);
|
|
372
|
+
expect(await countWorkflowsNamed(body.name)).toBe(0);
|
|
373
|
+
|
|
374
|
+
const retry = await createCall(body, { key });
|
|
375
|
+
expect(retry.status).toBe(201);
|
|
376
|
+
expect(retry.replayed).toBe(false);
|
|
377
|
+
expect(await countWorkflowsNamed(body.name)).toBe(1);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("a response lost after the commit: the retry gets the committed workflow", async () => {
|
|
381
|
+
const key = newKey();
|
|
382
|
+
const body = { name: uniqueName(), definition: instantDefinition() };
|
|
383
|
+
|
|
384
|
+
await expect(createCall(body, { key, dropResponse: true })).rejects.toThrow();
|
|
385
|
+
expect(await countWorkflowsNamed(body.name)).toBe(1);
|
|
386
|
+
|
|
387
|
+
const retry = await createCall(body, { key });
|
|
388
|
+
expect(retry.status).toBe(201);
|
|
389
|
+
expect(retry.replayed).toBe(true);
|
|
390
|
+
expect(retry.body.name).toBe(body.name);
|
|
391
|
+
expect(await countWorkflowsNamed(body.name)).toBe(1);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("the guarantee survives a restart", async () => {
|
|
395
|
+
const key = newKey();
|
|
396
|
+
const body = { name: uniqueName(), definition: instantDefinition() };
|
|
397
|
+
const first = await createCall(body, { key });
|
|
398
|
+
|
|
399
|
+
await restartDb();
|
|
400
|
+
|
|
401
|
+
const retry = await createCall(body, { key });
|
|
402
|
+
expect(retry.replayed).toBe(true);
|
|
403
|
+
expect(retry.body).toEqual(first.body);
|
|
404
|
+
expect(await countWorkflowsNamed(body.name)).toBe(1);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("concurrent requests with one key create one workflow", async () => {
|
|
408
|
+
const key = newKey();
|
|
409
|
+
const body = { name: uniqueName(), definition: instantDefinition() };
|
|
410
|
+
holdLookupsUntil(2);
|
|
411
|
+
|
|
412
|
+
const results = await Promise.all([createCall(body, { key }), createCall(body, { key })]);
|
|
413
|
+
|
|
414
|
+
expect(results.map((r) => r.status)).toEqual([201, 201]);
|
|
415
|
+
expect(results[0]!.body).toEqual(results[1]!.body);
|
|
416
|
+
expect(results.filter((r) => r.replayed)).toHaveLength(1);
|
|
417
|
+
expect(await countWorkflowsNamed(body.name)).toBe(1);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("the stored response is sealed", async () => {
|
|
421
|
+
const key = newKey();
|
|
422
|
+
const literal = `literal-input-${crypto.randomUUID()}`;
|
|
423
|
+
const first = await createCall(
|
|
424
|
+
{ name: uniqueName(), definition: instantDefinition(), input: { TOKEN: literal } },
|
|
425
|
+
{ key },
|
|
426
|
+
);
|
|
427
|
+
const row = await getDbClient().get<{ responseBody: string; keyHash: string }>(
|
|
428
|
+
"SELECT responseBody, keyHash FROM workflow_idempotency_keys WHERE workflowId = ?",
|
|
429
|
+
[first.body.id],
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
expect(row?.responseBody.startsWith("sealed:v1:")).toBe(true);
|
|
433
|
+
expect(row?.responseBody).not.toContain(literal);
|
|
434
|
+
expect(row?.keyHash).not.toContain(key);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("deleting the workflow retires the key: 410, nothing is created again", async () => {
|
|
438
|
+
const key = newKey();
|
|
439
|
+
const body = { name: uniqueName(), definition: instantDefinition() };
|
|
440
|
+
const first = await createCall(body, { key });
|
|
441
|
+
expect((await call("DELETE", `/api/workflows/${first.body.id}`)).status).toBe(204);
|
|
442
|
+
|
|
443
|
+
const retry = await createCall(body, { key });
|
|
444
|
+
|
|
445
|
+
expect(retry.status).toBe(410);
|
|
446
|
+
expect(await countWorkflowsNamed(body.name)).toBe(0);
|
|
447
|
+
const row = await getDbClient().get<{ responseBody: string | null; resourceDeletedAt: string }>(
|
|
448
|
+
"SELECT responseBody, resourceDeletedAt FROM workflow_idempotency_keys WHERE workflowId = ?",
|
|
449
|
+
[first.body.id],
|
|
450
|
+
);
|
|
451
|
+
expect(row?.responseBody).toBeNull();
|
|
452
|
+
expect(row?.resourceDeletedAt).toBeTruthy();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("keys are scoped to the caller", async () => {
|
|
456
|
+
const key = newKey();
|
|
457
|
+
// Each caller sends its own request (names are unique) under one key: in
|
|
458
|
+
// a shared scope every request after the first would be a 409.
|
|
459
|
+
const body = () => ({ name: uniqueName(), definition: instantDefinition() });
|
|
460
|
+
const userBody = body();
|
|
461
|
+
const userA = await createUser({ name: "idem-user-a" });
|
|
462
|
+
const userB = await createUser({ name: "idem-user-b" });
|
|
463
|
+
const tokenA = (await mintToken(userA.id, null, ACTOR)).plaintext;
|
|
464
|
+
const tokenB = (await mintToken(userB.id, null, ACTOR)).plaintext;
|
|
465
|
+
|
|
466
|
+
const results = [
|
|
467
|
+
await createCall(body(), { key, agentId: agentA }),
|
|
468
|
+
await createCall(body(), { key, agentId: agentB }),
|
|
469
|
+
await createCall(body(), { key, agentId: null }),
|
|
470
|
+
await createCall(userBody, { key, bearer: tokenA, agentId: null }),
|
|
471
|
+
await createCall(body(), { key, bearer: tokenB, agentId: null }),
|
|
472
|
+
];
|
|
473
|
+
|
|
474
|
+
expect(results.map((r) => [r.status, r.replayed])).toEqual(
|
|
475
|
+
Array.from({ length: 5 }, () => [201, false]),
|
|
476
|
+
);
|
|
477
|
+
expect(new Set(results.map((r) => r.body.id)).size).toBe(5);
|
|
478
|
+
// A user's key follows the user, not the token.
|
|
479
|
+
const tokenA2 = (await mintToken(userA.id, null, ACTOR)).plaintext;
|
|
480
|
+
const sameUser = await createCall(userBody, { key, bearer: tokenA2, agentId: null });
|
|
481
|
+
expect(sameUser.replayed).toBe(true);
|
|
482
|
+
expect(sameUser.body.id).toBe(results[3]!.body.id);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("a revoked token cannot replay", async () => {
|
|
486
|
+
const key = newKey();
|
|
487
|
+
const body = { name: uniqueName(), definition: instantDefinition() };
|
|
488
|
+
const user = await createUser({ name: "idem-user-revoked" });
|
|
489
|
+
const token = await mintToken(user.id, null, ACTOR);
|
|
490
|
+
expect((await createCall(body, { key, bearer: token.plaintext, agentId: null })).status).toBe(
|
|
491
|
+
201,
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
await revokeToken(token.tokenId, ACTOR);
|
|
495
|
+
const retry = await createCall(body, { key, bearer: token.plaintext, agentId: null });
|
|
496
|
+
|
|
497
|
+
expect(retry.status).toBe(401);
|
|
498
|
+
expect(retry.replayed).toBe(false);
|
|
499
|
+
expect(await countWorkflowsNamed(body.name)).toBe(1);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("a malformed key is a 400 and nothing is created", async () => {
|
|
503
|
+
const name = uniqueName();
|
|
504
|
+
for (const key of ["has space", "x".repeat(256)]) {
|
|
505
|
+
const res = await createCall({ name, definition: instantDefinition() }, { key });
|
|
506
|
+
expect(res.status).toBe(400);
|
|
507
|
+
}
|
|
508
|
+
expect(await countWorkflowsNamed(name)).toBe(0);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("without a key nothing is recorded or replayed, as before", async () => {
|
|
512
|
+
const body = { name: uniqueName(), definition: instantDefinition() };
|
|
513
|
+
const before = await countRecords();
|
|
514
|
+
|
|
515
|
+
const first = await createCall(body);
|
|
516
|
+
// Same request again: the workflow name is taken, as it always was.
|
|
517
|
+
const second = await createCall(body);
|
|
518
|
+
|
|
519
|
+
expect(first.status).toBe(201);
|
|
520
|
+
expect(second.status).toBe(500);
|
|
521
|
+
expect(second.replayed).toBe(false);
|
|
522
|
+
expect(await countWorkflowsNamed(body.name)).toBe(1);
|
|
523
|
+
expect(await countRecords()).toBe(before);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// ─── Trigger ─────────────────────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
describe("POST /api/workflows/{id}/trigger with Idempotency-Key", () => {
|
|
530
|
+
test("a retry returns the original run and dispatches nothing", async () => {
|
|
531
|
+
const workflow = await makeWorkflow();
|
|
532
|
+
const key = newKey();
|
|
533
|
+
|
|
534
|
+
const first = await triggerCall(workflow.id, { n: 1 }, { key });
|
|
535
|
+
expect(first.status).toBe(201);
|
|
536
|
+
expect(first.body).toEqual({ runId: expect.any(String), skipped: false });
|
|
537
|
+
expect((await waitForTerminalRun(first.body.runId)).status).toBe("completed");
|
|
538
|
+
const steps = await countSteps(workflow.id);
|
|
539
|
+
expect(steps).toBe(1);
|
|
540
|
+
|
|
541
|
+
const retry = await triggerCall(workflow.id, { n: 1 }, { key });
|
|
542
|
+
|
|
543
|
+
expect(retry.status).toBe(201);
|
|
544
|
+
expect(retry.replayed).toBe(true);
|
|
545
|
+
expect(retry.body).toEqual(first.body);
|
|
546
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(1);
|
|
547
|
+
expect(await countSteps(workflow.id)).toBe(steps);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("object key order does not change the payload; a different payload is a 409", async () => {
|
|
551
|
+
const workflow = await makeWorkflow();
|
|
552
|
+
const key = newKey();
|
|
553
|
+
const path = `/api/workflows/${workflow.id}/trigger`;
|
|
554
|
+
|
|
555
|
+
const first = await call("POST", path, { key, rawBody: '{"a":1,"b":{"c":2,"d":[1,2]}}' });
|
|
556
|
+
const reordered = await call("POST", path, { key, rawBody: '{"b":{"d":[1,2],"c":2},"a":1}' });
|
|
557
|
+
const different = await call("POST", path, { key, rawBody: '{"a":1,"b":{"c":2,"d":[2,1]}}' });
|
|
558
|
+
|
|
559
|
+
expect(first.status).toBe(201);
|
|
560
|
+
expect(reordered.replayed).toBe(true);
|
|
561
|
+
expect(reordered.body).toEqual(first.body);
|
|
562
|
+
expect(different.status).toBe(409);
|
|
563
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(1);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("the same key on another workflow is a separate operation", async () => {
|
|
567
|
+
const one = await makeWorkflow();
|
|
568
|
+
const two = await makeWorkflow();
|
|
569
|
+
const key = newKey();
|
|
570
|
+
|
|
571
|
+
const a = await triggerCall(one.id, {}, { key });
|
|
572
|
+
const b = await triggerCall(two.id, {}, { key });
|
|
573
|
+
|
|
574
|
+
expect([a.status, b.status]).toEqual([201, 201]);
|
|
575
|
+
expect(b.replayed).toBe(false);
|
|
576
|
+
expect(a.body.runId).not.toBe(b.body.runId);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("a cooldown skip is the accepted result and replays as it was", async () => {
|
|
580
|
+
const workflow = await makeWorkflow({ cooldown: { hours: 1 } });
|
|
581
|
+
const firstKey = newKey();
|
|
582
|
+
const secondKey = newKey();
|
|
583
|
+
|
|
584
|
+
const started = await triggerCall(workflow.id, {}, { key: firstKey });
|
|
585
|
+
const skipped = await triggerCall(workflow.id, {}, { key: secondKey });
|
|
586
|
+
expect(started.body.skipped).toBe(false);
|
|
587
|
+
expect(skipped.body.skipped).toBe(true);
|
|
588
|
+
|
|
589
|
+
const startedRetry = await triggerCall(workflow.id, {}, { key: firstKey });
|
|
590
|
+
const skippedRetry = await triggerCall(workflow.id, {}, { key: secondKey });
|
|
591
|
+
|
|
592
|
+
expect(startedRetry.body).toEqual(started.body);
|
|
593
|
+
expect(skippedRetry.body).toEqual(skipped.body);
|
|
594
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(2);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("a setup failure is accepted once and alerts once", async () => {
|
|
598
|
+
const notify = spyOn(preflightAlerts, "notifyAutomationPreflightFailure").mockResolvedValue(
|
|
599
|
+
undefined,
|
|
600
|
+
);
|
|
601
|
+
const workflow = await makeWorkflow({ requiredParams: ["REPO_URL"] });
|
|
602
|
+
const key = newKey();
|
|
603
|
+
|
|
604
|
+
const first = await triggerCall(workflow.id, {}, { key });
|
|
605
|
+
const retry = await triggerCall(workflow.id, {}, { key });
|
|
606
|
+
|
|
607
|
+
expect(first.status).toBe(201);
|
|
608
|
+
expect((await getWorkflowRun(first.body.runId))?.status).toBe("failed");
|
|
609
|
+
expect(retry.replayed).toBe(true);
|
|
610
|
+
expect(retry.body).toEqual(first.body);
|
|
611
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(1);
|
|
612
|
+
expect(notify).toHaveBeenCalledTimes(1);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("a readiness failure is an accepted, failed run", async () => {
|
|
616
|
+
const savedOpenRouterKey = process.env.OPENROUTER_API_KEY;
|
|
617
|
+
delete process.env.OPENROUTER_API_KEY;
|
|
618
|
+
try {
|
|
619
|
+
const workflow = await makeWorkflow({
|
|
620
|
+
definition: {
|
|
621
|
+
nodes: [
|
|
622
|
+
{
|
|
623
|
+
id: "qualify",
|
|
624
|
+
type: "system-one-decision",
|
|
625
|
+
inputs: { lead: "trigger.lead" },
|
|
626
|
+
config: {
|
|
627
|
+
state: "{{lead}}",
|
|
628
|
+
questions: { fit: { type: "noul", instructions: "Real lead?" } },
|
|
629
|
+
returns: { fit: { type: "noul" } },
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
const key = newKey();
|
|
636
|
+
|
|
637
|
+
const first = await triggerCall(workflow.id, { lead: "x" }, { key });
|
|
638
|
+
const retry = await triggerCall(workflow.id, { lead: "x" }, { key });
|
|
639
|
+
|
|
640
|
+
expect((await getWorkflowRun(first.body.runId))?.status).toBe("failed");
|
|
641
|
+
expect(retry.replayed).toBe(true);
|
|
642
|
+
expect(retry.body).toEqual(first.body);
|
|
643
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(1);
|
|
644
|
+
} finally {
|
|
645
|
+
if (savedOpenRouterKey !== undefined) process.env.OPENROUTER_API_KEY = savedOpenRouterKey;
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("a replay leaves a completed, failed or cancelled run as it is", async () => {
|
|
650
|
+
for (const status of ["completed", "failed", "cancelled"] as const) {
|
|
651
|
+
const workflow = await makeWorkflow();
|
|
652
|
+
const key = newKey();
|
|
653
|
+
const first = await triggerCall(workflow.id, {}, { key });
|
|
654
|
+
await waitForTerminalRun(first.body.runId);
|
|
655
|
+
await updateWorkflowRun(first.body.runId, { status, error: `set ${status}` });
|
|
656
|
+
const steps = await countSteps(workflow.id);
|
|
657
|
+
|
|
658
|
+
const retry = await triggerCall(workflow.id, {}, { key });
|
|
659
|
+
|
|
660
|
+
expect(retry.body).toEqual(first.body);
|
|
661
|
+
const run = await getWorkflowRun(first.body.runId);
|
|
662
|
+
expect(run?.status).toBe(status);
|
|
663
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(1);
|
|
664
|
+
expect(await countSteps(workflow.id)).toBe(steps);
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("replays after the workflow was disabled or its trigger schema changed", async () => {
|
|
669
|
+
const workflow = await makeWorkflow();
|
|
670
|
+
const key = newKey();
|
|
671
|
+
const first = await triggerCall(workflow.id, { n: 1 }, { key });
|
|
672
|
+
|
|
673
|
+
const edited = await call("PUT", `/api/workflows/${workflow.id}`, {
|
|
674
|
+
body: {
|
|
675
|
+
enabled: false,
|
|
676
|
+
triggerSchema: { type: "object", required: ["mustHave"] },
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
expect(edited.status).toBe(200);
|
|
680
|
+
|
|
681
|
+
const retry = await triggerCall(workflow.id, { n: 1 }, { key });
|
|
682
|
+
const fresh = await triggerCall(workflow.id, { n: 1 }, { key: newKey() });
|
|
683
|
+
|
|
684
|
+
expect(retry.status).toBe(201);
|
|
685
|
+
expect(retry.body).toEqual(first.body);
|
|
686
|
+
expect(fresh.status).toBe(400);
|
|
687
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(1);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("a keyed trigger with a null body is accepted and replays, like the no-key control", async () => {
|
|
691
|
+
const workflow = await makeWorkflow();
|
|
692
|
+
const key = newKey();
|
|
693
|
+
const path = `/api/workflows/${workflow.id}/trigger`;
|
|
694
|
+
|
|
695
|
+
const control = await call("POST", path, { rawBody: "null" });
|
|
696
|
+
const first = await call("POST", path, { key, rawBody: "null" });
|
|
697
|
+
const retry = await call("POST", path, { key, rawBody: "null" });
|
|
698
|
+
|
|
699
|
+
expect(control.status).toBe(201);
|
|
700
|
+
expect(first.status).toBe(201);
|
|
701
|
+
expect(first.replayed).toBe(false);
|
|
702
|
+
expect(retry.replayed).toBe(true);
|
|
703
|
+
expect(retry.body).toEqual(first.body);
|
|
704
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(2);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("a trigger-schema rejection does not use up the key", async () => {
|
|
708
|
+
const workflow = await makeWorkflow({
|
|
709
|
+
triggerSchema: { type: "object", required: ["pr"] },
|
|
710
|
+
});
|
|
711
|
+
const key = newKey();
|
|
712
|
+
|
|
713
|
+
const rejected = await triggerCall(workflow.id, {}, { key });
|
|
714
|
+
const accepted = await triggerCall(workflow.id, { pr: 1 }, { key });
|
|
715
|
+
|
|
716
|
+
expect(rejected.status).toBe(400);
|
|
717
|
+
expect(accepted.status).toBe(201);
|
|
718
|
+
expect(accepted.replayed).toBe(false);
|
|
719
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(1);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("a failure inside the run transaction leaves neither run nor key", async () => {
|
|
723
|
+
const workflow = await makeWorkflow();
|
|
724
|
+
const key = newKey();
|
|
725
|
+
spyOn(idempotencyDb, "recordAcceptedWorkflowOperation").mockRejectedValueOnce(
|
|
726
|
+
new Error("disk I/O error"),
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
const failed = await triggerCall(workflow.id, {}, { key });
|
|
730
|
+
expect(failed.status).toBe(500);
|
|
731
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(0);
|
|
732
|
+
expect(await countRecords(workflow.id)).toBe(0);
|
|
733
|
+
|
|
734
|
+
const retry = await triggerCall(workflow.id, {}, { key });
|
|
735
|
+
expect(retry.status).toBe(201);
|
|
736
|
+
expect(retry.replayed).toBe(false);
|
|
737
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(1);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
test("an accept hook that throws rolls the run back and walks nothing", async () => {
|
|
741
|
+
const workflow = await makeWorkflow();
|
|
742
|
+
|
|
743
|
+
await expect(
|
|
744
|
+
startWorkflowExecution(workflow, {}, getExecutorRegistry(), {
|
|
745
|
+
triggerType: "api",
|
|
746
|
+
accept: async () => {
|
|
747
|
+
throw new idempotencyDb.IdempotencyKeyTakenError();
|
|
748
|
+
},
|
|
749
|
+
}),
|
|
750
|
+
).rejects.toBeInstanceOf(idempotencyDb.IdempotencyKeyTakenError);
|
|
751
|
+
|
|
752
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(0);
|
|
753
|
+
expect(await countSteps(workflow.id)).toBe(0);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test("a response lost after the commit: the retry gets the run, which ran once", async () => {
|
|
757
|
+
const workflow = await makeWorkflow();
|
|
758
|
+
const key = newKey();
|
|
759
|
+
|
|
760
|
+
await expect(triggerCall(workflow.id, {}, { key, dropResponse: true })).rejects.toThrow();
|
|
761
|
+
const [run] = await listWorkflowRuns(workflow.id);
|
|
762
|
+
expect(run).toBeDefined();
|
|
763
|
+
|
|
764
|
+
const retry = await triggerCall(workflow.id, {}, { key });
|
|
765
|
+
|
|
766
|
+
expect(retry.replayed).toBe(true);
|
|
767
|
+
expect(retry.body).toEqual({ runId: run!.id, skipped: false });
|
|
768
|
+
expect((await waitForTerminalRun(run!.id)).status).toBe("completed");
|
|
769
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(1);
|
|
770
|
+
expect(await countSteps(workflow.id)).toBe(1);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test("a crash between the commit and the walk: the retry replays, recovery runs it once", async () => {
|
|
774
|
+
const workflow = await makeWorkflow();
|
|
775
|
+
const key = newKey();
|
|
776
|
+
const body = { n: 1 };
|
|
777
|
+
// What the trigger commits before its walk: the run, its initial context
|
|
778
|
+
// and the key, in one transaction. The process then dies.
|
|
779
|
+
const runId = crypto.randomUUID();
|
|
780
|
+
await getDbClient().transaction(async () => {
|
|
781
|
+
await createWorkflowRun({ id: runId, workflowId: workflow.id, triggerType: "api" });
|
|
782
|
+
await updateWorkflowRun(runId, { context: { trigger: body } });
|
|
783
|
+
await idempotencyDb.recordAcceptedWorkflowOperation(
|
|
784
|
+
{
|
|
785
|
+
principal: `agent:${agentA}`,
|
|
786
|
+
operation: "workflow.trigger",
|
|
787
|
+
targetId: workflow.id,
|
|
788
|
+
key,
|
|
789
|
+
fingerprint: idempotencyDb.workflowRequestFingerprint("workflow.trigger", body),
|
|
790
|
+
},
|
|
791
|
+
{ workflowId: workflow.id, runId, status: 201, body: { runId, skipped: false } },
|
|
792
|
+
);
|
|
793
|
+
});
|
|
794
|
+
await restartDb();
|
|
795
|
+
|
|
796
|
+
const retry = await triggerCall(workflow.id, body, { key });
|
|
797
|
+
expect(retry.replayed).toBe(true);
|
|
798
|
+
expect(retry.body).toEqual({ runId, skipped: false });
|
|
799
|
+
expect(await countSteps(workflow.id)).toBe(0);
|
|
800
|
+
|
|
801
|
+
await recoverIncompleteRuns(getExecutorRegistry());
|
|
802
|
+
|
|
803
|
+
expect((await waitForTerminalRun(runId)).status).toBe("completed");
|
|
804
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(1);
|
|
805
|
+
expect(await countSteps(workflow.id)).toBe(1);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
test("concurrent requests with one key start one run", async () => {
|
|
809
|
+
const workflow = await makeWorkflow();
|
|
810
|
+
const key = newKey();
|
|
811
|
+
holdLookupsUntil(2);
|
|
812
|
+
|
|
813
|
+
const results = await Promise.all([
|
|
814
|
+
triggerCall(workflow.id, { n: 1 }, { key }),
|
|
815
|
+
triggerCall(workflow.id, { n: 1 }, { key }),
|
|
816
|
+
]);
|
|
817
|
+
|
|
818
|
+
expect(results.map((r) => r.status)).toEqual([201, 201]);
|
|
819
|
+
expect(results[0]!.body).toEqual(results[1]!.body);
|
|
820
|
+
expect(results.filter((r) => r.replayed)).toHaveLength(1);
|
|
821
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(1);
|
|
822
|
+
expect(await countSteps(workflow.id)).toBe(1);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test(
|
|
826
|
+
"concurrent requests on independent DB connections start one run",
|
|
827
|
+
async () => {
|
|
828
|
+
const workflow = await makeWorkflow();
|
|
829
|
+
const key = newKey();
|
|
830
|
+
const barrierDir = await mkdtemp(join(tmpdir(), "workflow-idempotency-"));
|
|
831
|
+
try {
|
|
832
|
+
const fixture = join(import.meta.dir, "fixtures", "workflow-idempotency-trigger.child.ts");
|
|
833
|
+
const children = [1, 2].map((n) =>
|
|
834
|
+
runChild(["bun", fixture, TEST_DB_PATH, workflow.id, key, barrierDir, String(n)], {
|
|
835
|
+
env: { ...process.env },
|
|
836
|
+
}),
|
|
837
|
+
);
|
|
838
|
+
// Release both only once both found the key unused.
|
|
839
|
+
const looked = [1, 2].map((n) => Bun.file(join(barrierDir, `looked-${n}`)));
|
|
840
|
+
const deadline = Date.now() + 20_000;
|
|
841
|
+
while (!(await Promise.all(looked.map((file) => file.exists()))).every(Boolean)) {
|
|
842
|
+
if (Date.now() > deadline) throw new Error("trigger children never reached the barrier");
|
|
843
|
+
await Bun.sleep(10);
|
|
844
|
+
}
|
|
845
|
+
await Bun.write(join(barrierDir, "go"), "");
|
|
846
|
+
|
|
847
|
+
const outputs = (await Promise.all(children)).map((result, i) => {
|
|
848
|
+
expectChildOk(result, `trigger child ${i + 1}`);
|
|
849
|
+
return JSON.parse(result.stdout.trim().split("\n").at(-1)!) as {
|
|
850
|
+
runId: string;
|
|
851
|
+
replayed: boolean;
|
|
852
|
+
};
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
expect(outputs[0]!.runId).toBe(outputs[1]!.runId);
|
|
856
|
+
expect(outputs.filter((o) => o.replayed)).toHaveLength(1);
|
|
857
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(1);
|
|
858
|
+
expect(await countRecords(workflow.id)).toBe(1);
|
|
859
|
+
} finally {
|
|
860
|
+
await rm(barrierDir, { recursive: true, force: true });
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
test("the database rejects a second record for one scope", async () => {
|
|
867
|
+
const workflow = await makeWorkflow();
|
|
868
|
+
const insert = () =>
|
|
869
|
+
getDbClient().run(
|
|
870
|
+
`INSERT INTO workflow_idempotency_keys
|
|
871
|
+
(id, principal, operation, targetId, keyHash, requestFingerprint, workflowId,
|
|
872
|
+
responseStatus, createdAt)
|
|
873
|
+
VALUES (?, 'operator', 'workflow.trigger', ?, 'same-hash', 'fp', ?, 201, ?)`,
|
|
874
|
+
[crypto.randomUUID(), workflow.id, workflow.id, new Date().toISOString()],
|
|
875
|
+
);
|
|
876
|
+
await insert();
|
|
877
|
+
await expect(insert()).rejects.toThrow(/UNIQUE constraint failed/);
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
test("deleting the workflow retires the key: 410, nothing is started", async () => {
|
|
881
|
+
const workflow = await makeWorkflow();
|
|
882
|
+
const key = newKey();
|
|
883
|
+
expect((await triggerCall(workflow.id, {}, { key })).status).toBe(201);
|
|
884
|
+
expect((await call("DELETE", `/api/workflows/${workflow.id}`)).status).toBe(204);
|
|
885
|
+
|
|
886
|
+
const retry = await triggerCall(workflow.id, {}, { key });
|
|
887
|
+
|
|
888
|
+
expect(retry.status).toBe(410);
|
|
889
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(0);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("keys are scoped to the caller", async () => {
|
|
893
|
+
const workflow = await makeWorkflow();
|
|
894
|
+
const key = newKey();
|
|
895
|
+
|
|
896
|
+
const a = await triggerCall(workflow.id, {}, { key, agentId: agentA });
|
|
897
|
+
const b = await triggerCall(workflow.id, {}, { key, agentId: agentB });
|
|
898
|
+
|
|
899
|
+
expect(b.replayed).toBe(false);
|
|
900
|
+
expect(a.body.runId).not.toBe(b.body.runId);
|
|
901
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(2);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("without a key every request starts a run, as before", async () => {
|
|
905
|
+
const workflow: Workflow = await makeWorkflow();
|
|
906
|
+
|
|
907
|
+
const first = await triggerCall(workflow.id, { n: 1 });
|
|
908
|
+
const second = await triggerCall(workflow.id, { n: 1 });
|
|
909
|
+
|
|
910
|
+
expect([first.status, second.status]).toEqual([201, 201]);
|
|
911
|
+
expect(first.body.runId).not.toBe(second.body.runId);
|
|
912
|
+
expect(await listWorkflowRuns(workflow.id)).toHaveLength(2);
|
|
913
|
+
expect(await countRecords(workflow.id)).toBe(0);
|
|
914
|
+
});
|
|
915
|
+
});
|