@desplega.ai/agent-swarm 2.6.0 → 2.7.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 -0
- package/dist/{acp-adapter-dgfakfpw.js → acp-adapter-ma1js5n7.js} +6 -6
- package/dist/{actions-fbqma8aa.js → actions-1vhmtvg9.js} +18 -18
- package/dist/{amp-adapter-en0jy6hs.js → amp-adapter-sxqqvmdk.js} +3 -3
- package/dist/{app-k8a5cgpp.js → app-mwx1d5a9.js} +3 -3
- package/dist/{assistant-a16433gr.js → assistant-yx1p6g82.js} +20 -20
- package/dist/{boot-reembed-f4nn561n.js → boot-reembed-5h37a2c2.js} +1 -1
- package/dist/{boot-reembed-6c1sjfnr.js → boot-reembed-7h78vmvv.js} +2 -1
- package/dist/{boot-scrub-sweep-d4w9gajf.js → boot-scrub-sweep-8mawd4gw.js} +3 -3
- package/dist/{boot-seeds-9bmkspyw.js → boot-seeds-ca84dhnn.js} +4 -4
- package/dist/{claude-adapter-nys1kjxa.js → claude-adapter-wqt4hfkq.js} +7 -7
- package/dist/{claude-managed-adapter-55207973.js → claude-managed-adapter-6fjqtr2z.js} +2 -2
- package/dist/{claude-managed-setup-3cm35gey.js → claude-managed-setup-rqv98sea.js} +2 -2
- package/dist/{claude-sdk-session-nw5yd8cp.js → claude-sdk-session-f29f3b2x.js} +7 -7
- package/dist/{cli-sxgtf5fw.js → cli-07q03xm2.js} +1 -1
- package/dist/{cli-k21gx5ht.js → cli-1hhbzkbb.js} +6 -6
- package/dist/{cli-v49fkghp.js → cli-1m9x8dr5.js} +1 -1
- package/dist/{cli-24j1jeeb.js → cli-2pvzmas1.js} +3 -3
- package/dist/{cli-w2656ka1.js → cli-2y7v133v.js} +7 -7
- package/dist/{cli-g8ka5j4j.js → cli-48gxjmcz.js} +94 -36
- package/dist/{cli-2sjhpsns.js → cli-54sj64fc.js} +1 -1
- package/dist/{cli-aj8djyqt.js → cli-60sty27p.js} +2 -2
- package/dist/{cli-4hxrf0p4.js → cli-6a9qfa8b.js} +308 -47
- package/dist/{cli-n7asmce6.js → cli-6mgdba42.js} +3 -3
- package/dist/{cli-gf95q3bm.js → cli-6srv653c.js} +3 -3
- package/dist/{cli-9abdgn5t.js → cli-86ehn4s5.js} +3 -3
- package/dist/{cli-bqztfyve.js → cli-8mbc9za9.js} +1 -1
- package/dist/{cli-a2rr0fmg.js → cli-8wmj18cz.js} +1 -1
- package/dist/{cli-c2habvsf.js → cli-9gsm2rzp.js} +8 -8
- package/dist/{cli-4rf0f5vg.js → cli-9pshx6rd.js} +4 -2
- package/dist/{cli-abkm71vr.js → cli-a6ds2epe.js} +1 -1
- package/dist/{cli-esakngh8.js → cli-a89zdqz9.js} +1 -1
- package/dist/{cli-3ndmpxxv.js → cli-ay40dnj3.js} +2 -2
- package/dist/{cli-kf4jree9.js → cli-aypd09nf.js} +1 -1
- package/dist/{cli-6y51ryva.js → cli-b3yzyc57.js} +1 -1
- package/dist/{cli-ag2yrnt5.js → cli-c668vvcf.js} +2 -2
- package/dist/{cli-5sckpqcx.js → cli-d20qvpr2.js} +1 -1
- package/dist/{cli-a34pr3w1.js → cli-e1595bgx.js} +2 -2
- package/dist/{cli-m70bajzr.js → cli-f12mr20z.js} +1 -1
- package/dist/{cli-dqygeeh5.js → cli-f1e0ptjj.js} +10 -1
- package/dist/{cli-mvjz5an3.js → cli-f4dbh7a5.js} +7 -7
- package/dist/{cli-az25fn5f.js → cli-gsfk58nw.js} +2 -2
- package/dist/{cli-1tem6ns9.js → cli-gt4h8cxw.js} +3 -2
- package/dist/{cli-cz37jqss.js → cli-h5jeg46p.js} +2 -2
- package/dist/{cli-emhjcj2y.js → cli-he9gbdx0.js} +60 -10
- package/dist/{cli-3fwqy71j.js → cli-j0ppywdr.js} +38 -31
- package/dist/{cli-5rj83bam.js → cli-j3y0x2n4.js} +34 -33
- package/dist/{cli-3gfvmzht.js → cli-j9bpmykh.js} +73 -25
- package/dist/{cli-m5a9m0ak.js → cli-kv7sbc2c.js} +3 -3
- package/dist/{cli-g20jjwt4.js → cli-m30k9777.js} +12 -12
- package/dist/{cli-683y0b3a.js → cli-m5e802r7.js} +2 -2
- package/dist/{cli-nn6vx2mq.js → cli-ptfq202x.js} +5 -3
- package/dist/{cli-d2dpf95n.js → cli-qj442fws.js} +1 -1
- package/dist/{cli-03gg9pd1.js → cli-rd5m70gj.js} +4 -4
- package/dist/{cli-gnsb1zxa.js → cli-s09j9vp5.js} +1 -1
- package/dist/{cli-zqne1qcj.js → cli-s0mgx214.js} +2 -2
- package/dist/{cli-sqma6vw1.js → cli-sgqm2582.js} +5 -5
- package/dist/{cli-nq677csy.js → cli-sjctjr48.js} +1 -1
- package/dist/{cli-3aqd4jaa.js → cli-twctnkng.js} +3 -3
- package/dist/{cli-j6rktc91.js → cli-v6shg7na.js} +1 -1
- package/dist/{cli-7enjwya9.js → cli-vz78s5wm.js} +1 -1
- package/dist/{cli-art7c1zh.js → cli-wqr7cea4.js} +4 -4
- package/dist/{cli-hfeb68z4.js → cli-x37r7qwf.js} +11 -3
- package/dist/{cli-ffvsj2qw.js → cli-yd2nreqw.js} +2 -2
- package/dist/{cli-ca6banwz.js → cli-ymjpkxgn.js} +6 -6
- package/dist/{cli-mtnj4fwa.js → cli-yxbsmx56.js} +5 -5
- package/dist/cli.js +18 -16
- package/dist/{codex-adapter-d95q4qsn.js → codex-adapter-zk59x2zh.js} +5 -5
- package/dist/{codex-hook-brpnycn5.js → codex-hook-xyjhvxwd.js} +1 -1
- package/dist/{codex-session-runner-gdwd8h0c.js → codex-session-runner-kh6s4j3n.js} +5 -5
- package/dist/{commands-jqmgk54b.js → commands-68et1qv2.js} +2 -2
- package/dist/{controller-z3jq589f.js → controller-fyrbnzg4.js} +2 -2
- package/dist/{controllers-connect-r2za39ef.js → controllers-connect-nep9h2xv.js} +19 -19
- package/dist/{cursor-adapter-4mr4jgkw.js → cursor-adapter-yss3pg7t.js} +1 -1
- package/dist/{db-w36wwb5g.js → db-28gjyvxj.js} +2 -2
- package/dist/{db-ppv2tze7.js → db-8ftwvzba.js} +3 -3
- package/dist/{dispatcher-pv5h77vf.js → dispatcher-hayexmkr.js} +17 -17
- package/dist/{dsh-adapter-d4pdpb3r.js → dsh-adapter-46wawff9.js} +1 -1
- package/dist/{easter-egg-3d-4sp4dvmc.js → easter-egg-3d-8d1e0h4m.js} +1 -1
- package/dist/{execute-jev839y1.js → execute-cjrfxfnm.js} +1 -1
- package/dist/{external-channel-y6nkzhnn.js → external-channel-xbmxcg09.js} +2 -2
- package/dist/{grok-adapter-t08b9ntm.js → grok-adapter-ag4wks2a.js} +7 -7
- package/dist/handlers-tzmevqb5.js +41 -0
- package/dist/{heartbeat-vn31xwdk.js → heartbeat-psfvcgwp.js} +20 -20
- package/dist/{hook-6d0zbvvt.js → hook-8pz04697.js} +5 -5
- package/dist/{hook-cpjs8q51.js → hook-cyq4mryn.js} +6 -6
- package/dist/{http-9c6medda.js → http-haj1nd6k.js} +109 -114
- package/dist/{index-qhjf2htm.js → index-jwt2bj6f.js} +261 -96
- package/dist/{index-g6k9tctj.js → index-q52f47pe.js} +1 -1
- package/dist/{index-pkma1yf8.js → index-syvhnvtw.js} +1 -1
- package/dist/{index-yqgbfgaj.js → index-y1ajx04a.js} +20 -20
- package/dist/{index-k0yt1tvq.js → index-z5h22pes.js} +17 -17
- package/dist/{index-38t35h0n.js → index-zyq8ct52.js} +17 -17
- package/dist/{keepalive-em0117bp.js → keepalive-xdvsamvj.js} +3 -3
- package/dist/{lead-23e7z59t.js → lead-hqg57kdp.js} +35 -35
- package/dist/{maintenance-kbme3qy9.js → maintenance-4y2xzh3a.js} +1 -1
- package/dist/{oauth-refresh-sweep-xysj4yse.js → oauth-refresh-sweep-jd4r7zbs.js} +8 -3
- package/dist/{onboard-p6q0vz97.js → onboard-18447q9q.js} +5 -5
- package/dist/{opencode-adapter-esv3k9yn.js → opencode-adapter-26b2frdf.js} +5 -5
- package/dist/{otel-impl-54qhqh1z.js → otel-impl-f9tgr8yw.js} +1 -1
- package/dist/{pi-mono-adapter-mymbwhj0.js → pi-mono-adapter-qn6qbga1.js} +33 -12
- package/dist/{pricing-refresh-yb75wv33.js → pricing-refresh-dw2tgb53.js} +3 -3
- package/dist/{rbac-roles-1vp2mw4p.js → rbac-roles-aygm2760.js} +2 -2
- package/dist/{rbac-roles-dexhc9x8.js → rbac-roles-s4nwj2m3.js} +1 -1
- package/dist/{render-v2-tjay5hhf.js → render-v2-yfvdfr0x.js} +3 -3
- package/dist/{resume-8ayf37g5.js → resume-1bmvjbf3.js} +7 -7
- package/dist/{runtime-hqe5cqcd.js → runtime-f7tpzedy.js} +1 -1
- package/dist/{session-access-fdhp987s.js → session-access-r5garej6.js} +4 -4
- package/dist/{session-source-backfill-qgtc86z1.js → session-source-backfill-424wzv0r.js} +2 -2
- package/dist/{setup-4rrmqcq3.js → setup-8tygvxkq.js} +1 -1
- package/dist/{virtual-modules-52km00d6.js → virtual-modules-xsw323b3.js} +2 -2
- package/dist/{work-objects-p28fgd70.js → work-objects-ggnghpgf.js} +20 -20
- package/dist/{worker-e3w7vye1.js → worker-sqjd1nqf.js} +35 -35
- package/openapi.json +69 -1
- package/package.json +5 -3
- package/src/be/automation-preflight.ts +2 -0
- package/src/be/db/api-keys.ts +9 -0
- package/src/be/db/workflow-runs.ts +36 -5
- package/src/be/db.ts +80 -22
- package/src/be/memory/task-learning.ts +155 -0
- package/src/be/migrations/245_script_executor.sql +3 -0
- package/src/be/oauth-refresh-sweep.ts +2 -1
- package/src/be/scripts/boot-reembed.ts +2 -0
- package/src/be/scripts/db.ts +14 -4
- package/src/be/scripts/embeddings.ts +2 -0
- package/src/be/scripts/run-saved.ts +1 -0
- package/src/be/scripts/typecheck.ts +9 -3
- package/src/be/seed-skills/index.ts +7 -0
- package/src/commands/onboard/compose-generator.ts +5 -3
- package/src/commands/provider-credentials.ts +2 -1
- package/src/heartbeat/heartbeat.ts +14 -1
- package/src/heartbeat/script-runner-probe.ts +47 -0
- package/src/http/api-keys.ts +24 -9
- package/src/http/idempotency-key.ts +1 -1
- package/src/http/index.ts +3 -3
- package/src/http/pages-public.ts +38 -5
- package/src/http/scripts.ts +27 -0
- package/src/http/workflows.ts +10 -0
- package/src/http/x.ts +5 -0
- package/src/prompts/base-prompt.ts +21 -1
- package/src/prompts/session-templates.ts +17 -3
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/pi-mono-adapter.ts +41 -11
- package/src/scheduler/scheduler.ts +2 -1
- package/src/scripts-runtime/eval-harness.ts +8 -1
- package/src/scripts-runtime/executors/native.ts +22 -16
- package/src/scripts-runtime/executors/quickjs-prelude.ts +103 -1
- package/src/scripts-runtime/executors/quickjs-runner.ts +2 -1
- package/src/scripts-runtime/executors/quickjs.ts +7 -0
- package/src/scripts-runtime/executors/registry.ts +32 -0
- package/src/scripts-runtime/executors/types.ts +7 -0
- package/src/scripts-runtime/host-validated-args.ts +133 -0
- package/src/scripts-runtime/loader.ts +30 -3
- package/src/scripts-runtime/swarm-sdk.ts +9 -2
- package/src/scripts-runtime/types/stdlib.d.ts +9 -1
- package/src/scripts-runtime/types/swarm-sdk.d.ts +9 -1
- package/src/tests/api-key-status-by-suffix.test.ts +242 -0
- package/src/tests/base-prompt.test.ts +70 -2
- package/src/tests/community-linear-delivery-loop.test.ts +2099 -0
- package/src/tests/credential-check.test.ts +4 -2
- package/src/tests/docs-index.test.ts +135 -0
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/fetch-resolved-env.test.ts +16 -2
- package/src/tests/heartbeat-checklist.test.ts +89 -0
- package/src/tests/heartbeat-script-runner-probe.test.ts +83 -0
- package/src/tests/oauth-refresh-sweep.test.ts +34 -1
- package/src/tests/onboard-compose.test.ts +20 -0
- package/src/tests/opencode-adapter.test.ts +2 -1
- package/src/tests/pages-frame-ancestors.test.ts +162 -0
- package/src/tests/pi-codemode.test.ts +2 -1
- package/src/tests/pi-mono-adapter.test.ts +54 -0
- package/src/tests/pi-native-mcp.test.ts +13 -1
- package/src/tests/pi-tool-exposure.test.ts +8 -8
- package/src/tests/probe-tool-schema-tokens.test.ts +144 -0
- package/src/tests/prompt-template-session.test.ts +4 -3
- package/src/tests/request-composition-derive.test.ts +329 -0
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/script-executor-conformance.test.ts +36 -1
- package/src/tests/script-executor-quickjs-pool.test.ts +50 -0
- package/src/tests/script-executor-quickjs.test.ts +140 -0
- package/src/tests/script-host-validated-args-native.test.ts +196 -0
- package/src/tests/script-per-script-executor.test.ts +415 -0
- package/src/tests/scripts-external-api.test.ts +45 -0
- package/src/tests/sdk-allowlist.test.ts +3 -2
- package/src/tests/self-improvement.test.ts +4 -4
- package/src/tests/store-progress-learning.test.ts +288 -0
- package/src/tests/ui-logs-parser.test.ts +180 -0
- package/src/tests/workflow-engine-v2.test.ts +116 -1
- package/src/tests/workflow-mcp-trigger-schema.test.ts +209 -0
- package/src/tests/workflow-run-finished-event.test.ts +263 -0
- package/src/tests/workflow-swarm-script.test.ts +34 -0
- package/src/tools/script-common.ts +1 -0
- package/src/tools/script-run.ts +7 -0
- package/src/tools/script-upsert.ts +7 -0
- package/src/tools/store-progress.ts +67 -1
- package/src/tools/workflows/trigger-workflow.ts +104 -10
- package/src/types.ts +12 -1
- package/src/utils/credentials.ts +5 -2
- package/src/workflows/engine.ts +18 -16
- package/src/workflows/executors/swarm-script.ts +5 -0
- package/src/workflows/foreach-driver.ts +5 -11
- package/src/workflows/node-timeout.ts +30 -0
- package/templates/community/linear-delivery-loop/PLAYBOOK.md +323 -0
- package/templates/community/linear-delivery-loop/README.md +38 -0
- package/templates/community/linear-delivery-loop/config.example.json +31 -0
- package/templates/community/linear-delivery-loop/lead-prompt.md +34 -0
- package/templates/community/linear-delivery-loop/schedule.json +10 -0
- package/templates/community/linear-delivery-loop/scripts/linear-delivery-setup.ts +148 -0
- package/templates/community/linear-delivery-loop/scripts/linear-delivery-tick.ts +1781 -0
- package/templates/skills/swarm-docs/config.json +14 -0
- package/templates/skills/swarm-docs/content.md +144 -0
- package/dist/handlers-eqsnmv95.js +0 -41
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
11
|
import {
|
|
12
12
|
closeDb,
|
|
13
|
+
countWorkflowRuns,
|
|
13
14
|
createAgent,
|
|
14
15
|
createTaskExtended,
|
|
15
16
|
createUser,
|
|
@@ -20,10 +21,13 @@ import {
|
|
|
20
21
|
getWorkflowRun,
|
|
21
22
|
getWorkflowRunStepsByRunId,
|
|
22
23
|
initDb,
|
|
24
|
+
updateWorkflow,
|
|
23
25
|
} from "../be/db";
|
|
24
26
|
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
25
27
|
import { handleWorkflows } from "../http/workflows";
|
|
26
28
|
import { SCRIPT_LONG_TIMEOUT_HINT_MS } from "../scripts-runtime/executors/types";
|
|
29
|
+
import { SwarmConfig } from "../scripts-runtime/swarm-config";
|
|
30
|
+
import { createSwarmSdk } from "../scripts-runtime/swarm-sdk";
|
|
27
31
|
import { WORKFLOW_LONG_SCRIPT_TIMEOUT_NUDGE } from "../tools/utils";
|
|
28
32
|
import { registerCreateWorkflowTool } from "../tools/workflows/create-workflow";
|
|
29
33
|
import { registerPatchWorkflowTool } from "../tools/workflows/patch-workflow";
|
|
@@ -56,6 +60,8 @@ type ToolResult = {
|
|
|
56
60
|
versionCreated?: number;
|
|
57
61
|
runId?: string;
|
|
58
62
|
skipped?: boolean;
|
|
63
|
+
replayed?: boolean;
|
|
64
|
+
idempotencyConflict?: boolean;
|
|
59
65
|
validationErrors?: string[];
|
|
60
66
|
triggerSchema?: Record<string, unknown>;
|
|
61
67
|
};
|
|
@@ -627,6 +633,209 @@ describe("MCP create-workflow / update-workflow / patch-workflow accept triggerS
|
|
|
627
633
|
expect((await getTaskByWorkflowRunStepId(step!.id))?.requestedByUserId).toBe(requester.id);
|
|
628
634
|
});
|
|
629
635
|
|
|
636
|
+
test("trigger-workflow wait:false returns the runId before the run's instant steps finish", async () => {
|
|
637
|
+
const workflow = await createWorkflow({
|
|
638
|
+
name: uniqueName("mcp-trigger-detach"),
|
|
639
|
+
definition: {
|
|
640
|
+
nodes: [
|
|
641
|
+
{
|
|
642
|
+
id: "slow",
|
|
643
|
+
type: "script",
|
|
644
|
+
config: { runtime: "bash", script: "sleep 0.6; echo done", timeout: 10_000 },
|
|
645
|
+
},
|
|
646
|
+
],
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
createdWorkflowIds.push(workflow.id);
|
|
650
|
+
|
|
651
|
+
const started = performance.now();
|
|
652
|
+
const triggered = await tools.callTrigger({ id: workflow.id, triggerData: {}, wait: false });
|
|
653
|
+
const elapsedMs = performance.now() - started;
|
|
654
|
+
|
|
655
|
+
expect(triggered.structuredContent?.success).toBe(true);
|
|
656
|
+
expect(triggered.structuredContent?.skipped).toBe(false);
|
|
657
|
+
const runId = triggered.structuredContent?.runId;
|
|
658
|
+
expect(runId).toBeString();
|
|
659
|
+
expect(elapsedMs).toBeLessThan(500);
|
|
660
|
+
expect((await getWorkflowRun(runId!))?.status).toBe("running");
|
|
661
|
+
|
|
662
|
+
const deadline = Date.now() + 10_000;
|
|
663
|
+
while ((await getWorkflowRun(runId!))?.status === "running" && Date.now() < deadline) {
|
|
664
|
+
await Bun.sleep(50);
|
|
665
|
+
}
|
|
666
|
+
expect((await getWorkflowRun(runId!))?.status).toBe("completed");
|
|
667
|
+
}, 15_000);
|
|
668
|
+
|
|
669
|
+
// A caller killed after its trigger committed (or while its request was in
|
|
670
|
+
// flight) retries with the same key: the retry must return that run and
|
|
671
|
+
// start no second one. Before idempotencyKey, the only guard was a separate
|
|
672
|
+
// claim the caller wrote first, which a kill between the two left held.
|
|
673
|
+
test("trigger-workflow idempotencyKey: a retry replays the accepted run and starts no second one", async () => {
|
|
674
|
+
const workflow = await createWorkflow({
|
|
675
|
+
name: uniqueName("mcp-trigger-idempotent"),
|
|
676
|
+
definition: { nodes: [{ id: "n", type: "property-match", config: { conditions: [] } }] },
|
|
677
|
+
});
|
|
678
|
+
createdWorkflowIds.push(workflow.id);
|
|
679
|
+
const agentId = crypto.randomUUID();
|
|
680
|
+
const args = {
|
|
681
|
+
id: workflow.id,
|
|
682
|
+
triggerData: { pr: 7, sha: "a".repeat(40) },
|
|
683
|
+
wait: false,
|
|
684
|
+
idempotencyKey: "pr-review/repo/pr/7/aaaa",
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
const first = await tools.callTrigger(args, agentId);
|
|
688
|
+
expect(first.structuredContent?.success).toBe(true);
|
|
689
|
+
const runId = first.structuredContent?.runId;
|
|
690
|
+
expect(runId).toBeString();
|
|
691
|
+
expect(first.structuredContent?.replayed).toBeUndefined();
|
|
692
|
+
|
|
693
|
+
// The caller never saw `first` (killed): its retry replays it.
|
|
694
|
+
const retry = await tools.callTrigger(args, agentId);
|
|
695
|
+
expect(retry.structuredContent?.success).toBe(true);
|
|
696
|
+
expect(retry.structuredContent?.runId).toBe(runId);
|
|
697
|
+
expect(retry.structuredContent?.replayed).toBe(true);
|
|
698
|
+
|
|
699
|
+
// The same key with other data starts nothing either.
|
|
700
|
+
const conflict = await tools.callTrigger(
|
|
701
|
+
{ ...args, triggerData: { pr: 7, sha: "b".repeat(40) } },
|
|
702
|
+
agentId,
|
|
703
|
+
);
|
|
704
|
+
expect(conflict.structuredContent?.success).toBe(false);
|
|
705
|
+
expect(conflict.structuredContent?.idempotencyConflict).toBe(true);
|
|
706
|
+
|
|
707
|
+
// The key is scoped to the caller: another agent's key does not collide.
|
|
708
|
+
const other = await tools.callTrigger(args, crypto.randomUUID());
|
|
709
|
+
expect(other.structuredContent?.runId).not.toBe(runId);
|
|
710
|
+
|
|
711
|
+
expect(await countWorkflowRuns(workflow.id)).toBe(2);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("trigger-workflow idempotencyKey: concurrent triggers with one key start exactly one run", async () => {
|
|
715
|
+
const workflow = await createWorkflow({
|
|
716
|
+
name: uniqueName("mcp-trigger-idempotent-race"),
|
|
717
|
+
definition: { nodes: [{ id: "n", type: "property-match", config: { conditions: [] } }] },
|
|
718
|
+
});
|
|
719
|
+
createdWorkflowIds.push(workflow.id);
|
|
720
|
+
const agentId = crypto.randomUUID();
|
|
721
|
+
const args = { id: workflow.id, triggerData: { pr: 8 }, idempotencyKey: "race-key" };
|
|
722
|
+
|
|
723
|
+
const results = await Promise.all(
|
|
724
|
+
Array.from({ length: 6 }, () => tools.callTrigger(args, agentId)),
|
|
725
|
+
);
|
|
726
|
+
const runIds = new Set(results.map((r) => r.structuredContent?.runId));
|
|
727
|
+
expect(results.every((r) => r.structuredContent?.success === true)).toBe(true);
|
|
728
|
+
expect(runIds.size).toBe(1);
|
|
729
|
+
expect(results.filter((r) => r.structuredContent?.replayed !== true)).toHaveLength(1);
|
|
730
|
+
expect(await countWorkflowRuns(workflow.id)).toBe(1);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("trigger-workflow idempotencyKey: a key rejected before acceptance is not used up", async () => {
|
|
734
|
+
const workflow = await createWorkflow({
|
|
735
|
+
name: uniqueName("mcp-trigger-idempotent-disabled"),
|
|
736
|
+
definition: { nodes: [{ id: "n", type: "property-match", config: { conditions: [] } }] },
|
|
737
|
+
enabled: false,
|
|
738
|
+
});
|
|
739
|
+
createdWorkflowIds.push(workflow.id);
|
|
740
|
+
const agentId = crypto.randomUUID();
|
|
741
|
+
const args = { id: workflow.id, triggerData: {}, idempotencyKey: "after-enable" };
|
|
742
|
+
|
|
743
|
+
expect((await tools.callTrigger(args, agentId)).structuredContent?.success).toBe(false);
|
|
744
|
+
expect(await countWorkflowRuns(workflow.id)).toBe(0);
|
|
745
|
+
|
|
746
|
+
await updateWorkflow(workflow.id, { enabled: true });
|
|
747
|
+
const accepted = await tools.callTrigger(args, agentId);
|
|
748
|
+
expect(accepted.structuredContent?.success).toBe(true);
|
|
749
|
+
expect(accepted.structuredContent?.replayed).toBeUndefined();
|
|
750
|
+
expect(await countWorkflowRuns(workflow.id)).toBe(1);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
// `ctx.swarm.workflow_trigger` in a script calls the REST trigger route, not
|
|
754
|
+
// the MCP tool, so `wait` and `idempotencyKey` must reach that route too.
|
|
755
|
+
const scriptSdk = (agentId: string) =>
|
|
756
|
+
createSwarmSdk(
|
|
757
|
+
new SwarmConfig({
|
|
758
|
+
system: {
|
|
759
|
+
apiKey: { value: "sdk-test-key", isSecret: true },
|
|
760
|
+
agentId: { value: agentId, isSecret: false },
|
|
761
|
+
mcpBaseUrl: { value: `http://127.0.0.1:${TEST_PORT}`, isSecret: false },
|
|
762
|
+
},
|
|
763
|
+
user: {},
|
|
764
|
+
}),
|
|
765
|
+
);
|
|
766
|
+
type SdkTriggerResult = { success: boolean; status: number; data: { runId?: string } };
|
|
767
|
+
|
|
768
|
+
test("ctx.swarm.workflow_trigger idempotencyKey: a retry after a kill replays the run", async () => {
|
|
769
|
+
const workflow = await createWorkflow({
|
|
770
|
+
name: uniqueName("sdk-trigger-idempotent"),
|
|
771
|
+
definition: { nodes: [{ id: "n", type: "property-match", config: { conditions: [] } }] },
|
|
772
|
+
});
|
|
773
|
+
createdWorkflowIds.push(workflow.id);
|
|
774
|
+
const sdk = scriptSdk(crypto.randomUUID());
|
|
775
|
+
const args = {
|
|
776
|
+
id: workflow.id,
|
|
777
|
+
triggerData: { pr: 9 },
|
|
778
|
+
wait: false,
|
|
779
|
+
idempotencyKey: "pr-review/repo/pr/9/sha",
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
const first = (await sdk.workflow_trigger(args)) as SdkTriggerResult;
|
|
783
|
+
const retry = (await sdk.workflow_trigger(args)) as SdkTriggerResult;
|
|
784
|
+
expect(first.success).toBe(true);
|
|
785
|
+
expect(first.data.runId).toBeString();
|
|
786
|
+
expect(retry.success).toBe(true);
|
|
787
|
+
expect(retry.data.runId).toBe(first.data.runId);
|
|
788
|
+
expect(await countWorkflowRuns(workflow.id)).toBe(1);
|
|
789
|
+
|
|
790
|
+
// Two concurrent calls (a killed attempt's in-flight request and its retry).
|
|
791
|
+
const racing = await createWorkflow({
|
|
792
|
+
name: uniqueName("sdk-trigger-idempotent-race"),
|
|
793
|
+
definition: { nodes: [{ id: "n", type: "property-match", config: { conditions: [] } }] },
|
|
794
|
+
});
|
|
795
|
+
createdWorkflowIds.push(racing.id);
|
|
796
|
+
const raced = (await Promise.all(
|
|
797
|
+
Array.from({ length: 4 }, () => sdk.workflow_trigger({ ...args, id: racing.id })),
|
|
798
|
+
)) as SdkTriggerResult[];
|
|
799
|
+
expect(new Set(raced.map((r) => r.data.runId)).size).toBe(1);
|
|
800
|
+
expect(await countWorkflowRuns(racing.id)).toBe(1);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test("ctx.swarm.workflow_trigger wait:false returns before the run's instant steps finish", async () => {
|
|
804
|
+
const workflow = await createWorkflow({
|
|
805
|
+
name: uniqueName("sdk-trigger-detach"),
|
|
806
|
+
definition: {
|
|
807
|
+
nodes: [
|
|
808
|
+
{
|
|
809
|
+
id: "slow",
|
|
810
|
+
type: "script",
|
|
811
|
+
config: { runtime: "bash", script: "sleep 0.6; echo done", timeout: 10_000 },
|
|
812
|
+
},
|
|
813
|
+
],
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
createdWorkflowIds.push(workflow.id);
|
|
817
|
+
|
|
818
|
+
const started = performance.now();
|
|
819
|
+
const triggered = (await scriptSdk(crypto.randomUUID()).workflow_trigger({
|
|
820
|
+
id: workflow.id,
|
|
821
|
+
triggerData: {},
|
|
822
|
+
wait: false,
|
|
823
|
+
})) as SdkTriggerResult;
|
|
824
|
+
const elapsedMs = performance.now() - started;
|
|
825
|
+
|
|
826
|
+
expect(triggered.success).toBe(true);
|
|
827
|
+
const runId = triggered.data.runId;
|
|
828
|
+
expect(runId).toBeString();
|
|
829
|
+
expect(elapsedMs).toBeLessThan(500);
|
|
830
|
+
expect((await getWorkflowRun(runId!))?.status).toBe("running");
|
|
831
|
+
|
|
832
|
+
const deadline = Date.now() + 10_000;
|
|
833
|
+
while ((await getWorkflowRun(runId!))?.status === "running" && Date.now() < deadline) {
|
|
834
|
+
await Bun.sleep(50);
|
|
835
|
+
}
|
|
836
|
+
expect((await getWorkflowRun(runId!))?.status).toBe("completed");
|
|
837
|
+
}, 15_000);
|
|
838
|
+
|
|
630
839
|
test("trigger-workflow with missing required field → structured TriggerSchemaError", async () => {
|
|
631
840
|
const triggerSchema: Record<string, unknown> = {
|
|
632
841
|
type: "object",
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `workflow.run.finished`: every run, top-level or child, emits it once after
|
|
3
|
+
* commit when it turns terminal, so an event-mode `wait` can resume when any
|
|
4
|
+
* run ends.
|
|
5
|
+
*/
|
|
6
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { unlink } from "node:fs/promises";
|
|
8
|
+
import { recordWorkflowPreflightFailure } from "../be/automation-preflight";
|
|
9
|
+
import * as db from "../be/db";
|
|
10
|
+
import {
|
|
11
|
+
closeDb,
|
|
12
|
+
createWorkflow,
|
|
13
|
+
createWorkflowRun,
|
|
14
|
+
getDbClient,
|
|
15
|
+
getWaitStateByStepId,
|
|
16
|
+
getWorkflowRun,
|
|
17
|
+
getWorkflowRunStepsByRunId,
|
|
18
|
+
initDb,
|
|
19
|
+
updateWorkflowRun,
|
|
20
|
+
} from "../be/db";
|
|
21
|
+
import { cancelLiveWorkflowTreeRuns } from "../be/db/workflow-runs";
|
|
22
|
+
import type { Workflow, WorkflowDefinition } from "../types";
|
|
23
|
+
import { startWorkflowExecution } from "../workflows/engine";
|
|
24
|
+
import { workflowEventBus } from "../workflows/event-bus";
|
|
25
|
+
import type { ExecutorDependencies } from "../workflows/executors/base";
|
|
26
|
+
import { createExecutorRegistry } from "../workflows/executors/registry";
|
|
27
|
+
import { _resetWaitBusSubscriptionsForTests, initWaitBusSubscriptions } from "../workflows/resume";
|
|
28
|
+
|
|
29
|
+
const TEST_DB_PATH = "./test-workflow-run-finished-event.sqlite";
|
|
30
|
+
const EVENT = "workflow.run.finished";
|
|
31
|
+
|
|
32
|
+
const deps: ExecutorDependencies = {
|
|
33
|
+
db: db as typeof import("../be/db"),
|
|
34
|
+
eventBus: workflowEventBus,
|
|
35
|
+
interpolate: (t: string) => t,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type FinishedPayload = { runId: string; workflowId: string; status: string };
|
|
39
|
+
|
|
40
|
+
const received: FinishedPayload[] = [];
|
|
41
|
+
const onFinished = (data: unknown) => received.push(data as FinishedPayload);
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
45
|
+
initDb(TEST_DB_PATH);
|
|
46
|
+
workflowEventBus.on(EVENT, onFinished);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterAll(async () => {
|
|
50
|
+
workflowEventBus.off(EVENT, onFinished);
|
|
51
|
+
closeDb();
|
|
52
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
53
|
+
await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => {});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
_resetWaitBusSubscriptionsForTests();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
async function makeWorkflow(name: string, def?: WorkflowDefinition): Promise<Workflow> {
|
|
62
|
+
return createWorkflow({
|
|
63
|
+
name: `${name}-${crypto.randomUUID()}`,
|
|
64
|
+
definition: def ?? {
|
|
65
|
+
nodes: [{ id: "n", type: "notify", config: { channel: "swarm", template: "x" } }],
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function newRun(workflowId: string): Promise<string> {
|
|
71
|
+
const id = crypto.randomUUID();
|
|
72
|
+
await createWorkflowRun({ id, workflowId });
|
|
73
|
+
return id;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const eventsFor = (runId: string) => received.filter((event) => event.runId === runId);
|
|
77
|
+
|
|
78
|
+
// The emit goes through afterCommit and a dynamic import, so let it settle.
|
|
79
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 50));
|
|
80
|
+
|
|
81
|
+
async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 2000) {
|
|
82
|
+
const t0 = Date.now();
|
|
83
|
+
while (!(await predicate())) {
|
|
84
|
+
if (Date.now() - t0 > timeoutMs) throw new Error("waitFor: timeout");
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("workflow.run.finished", () => {
|
|
90
|
+
test("fires once per terminal transition, with the run id as runId", async () => {
|
|
91
|
+
const wf = await makeWorkflow("run-finished-once");
|
|
92
|
+
const runId = await newRun(wf.id);
|
|
93
|
+
|
|
94
|
+
await updateWorkflowRun(runId, { status: "waiting" });
|
|
95
|
+
await settle();
|
|
96
|
+
expect(eventsFor(runId)).toEqual([]);
|
|
97
|
+
|
|
98
|
+
await updateWorkflowRun(runId, { status: "failed", error: "boom" });
|
|
99
|
+
await waitFor(() => eventsFor(runId).length === 1);
|
|
100
|
+
expect(eventsFor(runId)[0]).toEqual({ runId, workflowId: wf.id, status: "failed" });
|
|
101
|
+
expect(eventsFor(runId)[0]).not.toHaveProperty("workflowRunId");
|
|
102
|
+
expect(eventsFor(runId)[0]).not.toHaveProperty("_runId");
|
|
103
|
+
|
|
104
|
+
// Later writes to the terminal run, with or without the same status, stay silent.
|
|
105
|
+
await updateWorkflowRun(runId, { context: { late: true } });
|
|
106
|
+
await updateWorkflowRun(runId, { status: "failed", error: "boom again" });
|
|
107
|
+
await settle();
|
|
108
|
+
expect(eventsFor(runId)).toHaveLength(1);
|
|
109
|
+
|
|
110
|
+
// A retry reopens the run; its next end is a new transition.
|
|
111
|
+
await updateWorkflowRun(runId, { status: "running" });
|
|
112
|
+
await updateWorkflowRun(runId, { status: "completed" });
|
|
113
|
+
await waitFor(() => eventsFor(runId).length === 2);
|
|
114
|
+
expect(eventsFor(runId)[1]?.status).toBe("completed");
|
|
115
|
+
|
|
116
|
+
// A terminal run moved to another terminal status, with no reopen, stays silent.
|
|
117
|
+
const relabelled = await newRun(wf.id);
|
|
118
|
+
await updateWorkflowRun(relabelled, { status: "completed" });
|
|
119
|
+
await waitFor(() => eventsFor(relabelled).length === 1);
|
|
120
|
+
await updateWorkflowRun(relabelled, { status: "failed", error: "late failure" });
|
|
121
|
+
await settle();
|
|
122
|
+
expect(eventsFor(relabelled)).toEqual([
|
|
123
|
+
{ runId: relabelled, workflowId: wf.id, status: "completed" },
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("fires only after commit, and never for a rolled-back end", async () => {
|
|
128
|
+
const wf = await makeWorkflow("run-finished-commit");
|
|
129
|
+
const committed = await newRun(wf.id);
|
|
130
|
+
const rolledBack = await newRun(wf.id);
|
|
131
|
+
|
|
132
|
+
await getDbClient().transaction(async () => {
|
|
133
|
+
await updateWorkflowRun(committed, { status: "completed" });
|
|
134
|
+
await settle();
|
|
135
|
+
expect(eventsFor(committed)).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
await waitFor(() => eventsFor(committed).length === 1);
|
|
138
|
+
|
|
139
|
+
await expect(
|
|
140
|
+
getDbClient().transaction(async () => {
|
|
141
|
+
await updateWorkflowRun(rolledBack, { status: "skipped" });
|
|
142
|
+
throw new Error("roll back");
|
|
143
|
+
}),
|
|
144
|
+
).rejects.toThrow("roll back");
|
|
145
|
+
await settle();
|
|
146
|
+
expect(eventsFor(rolledBack)).toEqual([]);
|
|
147
|
+
expect((await getWorkflowRun(rolledBack))?.status).toBe("running");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("cancelLiveWorkflowTreeRuns fires for each run it cancels, not for terminal runs", async () => {
|
|
151
|
+
const wf = await makeWorkflow("run-finished-tree-cancel");
|
|
152
|
+
const live = await newRun(wf.id);
|
|
153
|
+
const waiting = await newRun(wf.id);
|
|
154
|
+
const done = await newRun(wf.id);
|
|
155
|
+
await updateWorkflowRun(waiting, { status: "waiting" });
|
|
156
|
+
await updateWorkflowRun(done, { status: "completed" });
|
|
157
|
+
await waitFor(() => eventsFor(done).length === 1);
|
|
158
|
+
|
|
159
|
+
const now = new Date().toISOString();
|
|
160
|
+
await getDbClient().transaction(() =>
|
|
161
|
+
cancelLiveWorkflowTreeRuns([live, waiting, done], "tree cancelled", now),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
await waitFor(() => eventsFor(live).length === 1 && eventsFor(waiting).length === 1);
|
|
165
|
+
expect(eventsFor(live)[0]).toEqual({ runId: live, workflowId: wf.id, status: "cancelled" });
|
|
166
|
+
expect(eventsFor(waiting)[0]?.status).toBe("cancelled");
|
|
167
|
+
await settle();
|
|
168
|
+
expect(eventsFor(done)).toHaveLength(1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a preflight failure run fires once, a deduplicated repeat does not", async () => {
|
|
172
|
+
const wf = await makeWorkflow("run-finished-preflight");
|
|
173
|
+
const input = {
|
|
174
|
+
workflowId: wf.id,
|
|
175
|
+
triggerType: "manual" as const,
|
|
176
|
+
triggerData: {},
|
|
177
|
+
failureReason: "needs_setup: slack",
|
|
178
|
+
};
|
|
179
|
+
const first = await recordWorkflowPreflightFailure(input);
|
|
180
|
+
await waitFor(() => eventsFor(first.runId).length === 1);
|
|
181
|
+
expect(eventsFor(first.runId)[0]?.status).toBe("failed");
|
|
182
|
+
|
|
183
|
+
const repeat = await recordWorkflowPreflightFailure(input);
|
|
184
|
+
expect(repeat.runId).toBe(first.runId);
|
|
185
|
+
await settle();
|
|
186
|
+
expect(eventsFor(first.runId)).toHaveLength(1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("a global wait filtered on a detached run's id resumes when that run ends", async () => {
|
|
190
|
+
const registry = createExecutorRegistry(deps);
|
|
191
|
+
|
|
192
|
+
// The detached run parks on a signal, so it stays live until the test ends it.
|
|
193
|
+
const target = await makeWorkflow("run-finished-target", {
|
|
194
|
+
nodes: [
|
|
195
|
+
{
|
|
196
|
+
id: "hold",
|
|
197
|
+
type: "wait",
|
|
198
|
+
config: { mode: "event", eventName: "test.release-target", scope: "global" },
|
|
199
|
+
next: { event: "end" },
|
|
200
|
+
},
|
|
201
|
+
{ id: "end", type: "notify", config: { channel: "swarm", template: "released" } },
|
|
202
|
+
],
|
|
203
|
+
});
|
|
204
|
+
const targetRunId = await startWorkflowExecution(target, {}, registry);
|
|
205
|
+
expect((await getWorkflowRun(targetRunId))?.status).toBe("waiting");
|
|
206
|
+
|
|
207
|
+
const waiter = await makeWorkflow("run-finished-waiter", {
|
|
208
|
+
nodes: [
|
|
209
|
+
{
|
|
210
|
+
id: "watch",
|
|
211
|
+
type: "wait",
|
|
212
|
+
config: {
|
|
213
|
+
mode: "event",
|
|
214
|
+
eventName: EVENT,
|
|
215
|
+
scope: "global",
|
|
216
|
+
filter: { runId: targetRunId },
|
|
217
|
+
},
|
|
218
|
+
next: { event: "after" },
|
|
219
|
+
},
|
|
220
|
+
{ id: "after", type: "notify", config: { channel: "swarm", template: "target ended" } },
|
|
221
|
+
],
|
|
222
|
+
});
|
|
223
|
+
const waiterRunId = await startWorkflowExecution(waiter, {}, registry);
|
|
224
|
+
// Control: a run-scoped wait on the same event must never match another run's end.
|
|
225
|
+
const scoped = await makeWorkflow("run-finished-run-scope", {
|
|
226
|
+
nodes: [
|
|
227
|
+
{
|
|
228
|
+
id: "watch",
|
|
229
|
+
type: "wait",
|
|
230
|
+
config: { mode: "event", eventName: EVENT, scope: "run" },
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
});
|
|
234
|
+
const scopedRunId = await startWorkflowExecution(scoped, {}, registry);
|
|
235
|
+
await initWaitBusSubscriptions(registry);
|
|
236
|
+
|
|
237
|
+
const watchStep = async (runId: string) =>
|
|
238
|
+
(await getWorkflowRunStepsByRunId(runId)).find((step) => step.nodeId === "watch");
|
|
239
|
+
const watch = await watchStep(waiterRunId);
|
|
240
|
+
const scopedWatch = await watchStep(scopedRunId);
|
|
241
|
+
expect((await getWaitStateByStepId(watch!.id))?.status).toBe("pending");
|
|
242
|
+
|
|
243
|
+
// Another run ending does not match the filter.
|
|
244
|
+
const other = await newRun(target.id);
|
|
245
|
+
await updateWorkflowRun(other, { status: "completed" });
|
|
246
|
+
await waitFor(() => eventsFor(other).length === 1);
|
|
247
|
+
await settle();
|
|
248
|
+
expect((await getWaitStateByStepId(watch!.id))?.status).toBe("pending");
|
|
249
|
+
|
|
250
|
+
workflowEventBus.emit("test.release-target", {});
|
|
251
|
+
await waitFor(async () => (await getWorkflowRun(targetRunId))?.status === "completed");
|
|
252
|
+
await waitFor(async () => (await getWaitStateByStepId(watch!.id))?.status === "fired");
|
|
253
|
+
|
|
254
|
+
const fired = await getWaitStateByStepId(watch!.id);
|
|
255
|
+
expect(fired?.firedPayload).toEqual({
|
|
256
|
+
runId: targetRunId,
|
|
257
|
+
workflowId: target.id,
|
|
258
|
+
status: "completed",
|
|
259
|
+
});
|
|
260
|
+
await waitFor(async () => (await getWorkflowRun(waiterRunId))?.status === "completed");
|
|
261
|
+
expect((await getWaitStateByStepId(scopedWatch!.id))?.status).toBe("pending");
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -760,6 +760,40 @@ describe("SwarmScriptExecutor", () => {
|
|
|
760
760
|
expect(result.output?.exitCode).not.toBe(0);
|
|
761
761
|
});
|
|
762
762
|
|
|
763
|
+
skip(
|
|
764
|
+
"meta.signal abort kills the script before its side effect",
|
|
765
|
+
async () => {
|
|
766
|
+
const marker = `/tmp/swarm-script-abort-${crypto.randomUUID()}`;
|
|
767
|
+
await saveScript(
|
|
768
|
+
"one-shot",
|
|
769
|
+
`export default async () => { await new Promise(r => setTimeout(r, 1500)); await Bun.write(${JSON.stringify(marker)}, "ran"); return { done: true }; };`,
|
|
770
|
+
);
|
|
771
|
+
const executor = new SwarmScriptExecutor(deps);
|
|
772
|
+
const wf = await makeWorkflow({ nodes: [] });
|
|
773
|
+
const controller = new AbortController();
|
|
774
|
+
setTimeout(() => controller.abort(new Error("Step timed out")), 300);
|
|
775
|
+
const result = await executor.run({
|
|
776
|
+
config: { scriptName: "one-shot", timeoutMs: 30_000 },
|
|
777
|
+
context: {},
|
|
778
|
+
meta: {
|
|
779
|
+
runId: crypto.randomUUID(),
|
|
780
|
+
stepId: crypto.randomUUID(),
|
|
781
|
+
nodeId: "script",
|
|
782
|
+
workflowId: wf.id,
|
|
783
|
+
dryRun: false,
|
|
784
|
+
signal: controller.signal,
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
expect(result.status).toBe("failed");
|
|
789
|
+
expect(result.error).toContain("killed");
|
|
790
|
+
// Past the point where the script would have written the marker.
|
|
791
|
+
await Bun.sleep(1_800);
|
|
792
|
+
expect(await Bun.file(marker).exists()).toBe(false);
|
|
793
|
+
},
|
|
794
|
+
CHILD_PROCESS_TEST_BUDGET_MS,
|
|
795
|
+
);
|
|
796
|
+
|
|
763
797
|
skip("Failure in the script surfaces as a workflow-node failure", async () => {
|
|
764
798
|
await saveScript("throws", `export default async () => { throw new Error("boom"); };`);
|
|
765
799
|
const executor = new SwarmScriptExecutor(deps);
|
|
@@ -16,6 +16,7 @@ export const SCRIPT_TRANSPORT_ERROR =
|
|
|
16
16
|
export const scriptNameSchema = z.string().min(1).max(200);
|
|
17
17
|
export const scriptScopeSchema = z.enum(["agent", "global"]);
|
|
18
18
|
export const scriptFsModeSchema = z.enum(["none", "workspace-rw"]);
|
|
19
|
+
export const scriptExecutorSchema = z.enum(["native", "quickjs"]);
|
|
19
20
|
|
|
20
21
|
export const scriptToolOutputSchema = swarmToolOutputSchema({
|
|
21
22
|
status: z.number().optional(),
|
package/src/tools/script-run.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as z from "zod";
|
|
|
3
3
|
import { createToolRegistrar } from "@/tools/utils";
|
|
4
4
|
import {
|
|
5
5
|
proxyScriptsApi,
|
|
6
|
+
scriptExecutorSchema,
|
|
6
7
|
scriptFsModeSchema,
|
|
7
8
|
scriptNameSchema,
|
|
8
9
|
scriptScopeSchema,
|
|
@@ -66,6 +67,12 @@ export const registerScriptRunTool = (server: McpServer) => {
|
|
|
66
67
|
fsMode: scriptFsModeSchema
|
|
67
68
|
.default("none")
|
|
68
69
|
.describe("Filesystem mode. v1 supports none only."),
|
|
70
|
+
executor: scriptExecutorSchema
|
|
71
|
+
.nullable()
|
|
72
|
+
.optional()
|
|
73
|
+
.describe(
|
|
74
|
+
'Optional executor override ("native" or "quickjs"). When omitted, named scripts use their saved executor or SCRIPT_EXECUTOR.',
|
|
75
|
+
),
|
|
69
76
|
idempotencyKey: z
|
|
70
77
|
.string()
|
|
71
78
|
.max(200)
|
|
@@ -3,6 +3,7 @@ import * as z from "zod";
|
|
|
3
3
|
import { createToolRegistrar } from "@/tools/utils";
|
|
4
4
|
import {
|
|
5
5
|
proxyScriptsApi,
|
|
6
|
+
scriptExecutorSchema,
|
|
6
7
|
scriptFsModeSchema,
|
|
7
8
|
scriptNameSchema,
|
|
8
9
|
scriptScopeSchema,
|
|
@@ -33,6 +34,12 @@ export const registerScriptUpsertTool = (server: McpServer) => {
|
|
|
33
34
|
fsMode: scriptFsModeSchema
|
|
34
35
|
.default("none")
|
|
35
36
|
.describe("Filesystem mode. v1 supports none only."),
|
|
37
|
+
executor: scriptExecutorSchema
|
|
38
|
+
.nullable()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe(
|
|
41
|
+
'Optional per-script executor override ("native" or "quickjs"). Null or omitted inherits SCRIPT_EXECUTOR.',
|
|
42
|
+
),
|
|
36
43
|
}),
|
|
37
44
|
outputSchema: scriptToolOutputSchema,
|
|
38
45
|
},
|