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