@desplega.ai/agent-swarm 2.4.0 → 2.4.2
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 +1 -1
- package/dist/{acp-adapter-kedstxx6.js → acp-adapter-dv7hmx4r.js} +9 -9
- package/dist/{actions-vget6r1k.js → actions-51nc1gny.js} +21 -21
- package/dist/{amp-adapter-xeb5mktv.js → amp-adapter-abp5tdx8.js} +4 -4
- package/dist/{app-mqap8t8m.js → app-9h14qvr6.js} +3 -3
- package/dist/{assistant-h0gv97mv.js → assistant-wv1gz3mt.js} +22 -22
- package/dist/{boot-reembed-qr8a894z.js → boot-reembed-6yn53ms9.js} +1 -1
- package/dist/{boot-reembed-rj85jjhw.js → boot-reembed-7fcka4z5.js} +1 -1
- package/dist/{boot-scrub-sweep-eyfvn6v9.js → boot-scrub-sweep-pm1580ax.js} +4 -4
- package/dist/{boot-seeds-xs9a8dx1.js → boot-seeds-g5hfc1x8.js} +4 -4
- package/dist/{claude-adapter-bm23cye7.js → claude-adapter-gf3kxhf0.js} +9 -9
- package/dist/{claude-managed-adapter-1mwrsm9d.js → claude-managed-adapter-h2y889ge.js} +2 -2
- package/dist/{claude-managed-setup-gkfgks4j.js → claude-managed-setup-h00hsgtz.js} +2 -2
- package/dist/{claude-sdk-session-apb4ypz3.js → claude-sdk-session-s2k1qd8y.js} +13 -13
- package/dist/{cli-tj4j2ts4.js → cli-02qm7src.js} +8 -8
- package/dist/{cli-1cevz46c.js → cli-0qvtffe9.js} +6 -6
- package/dist/{cli-avmqhatn.js → cli-10hhg0km.js} +8 -8
- package/dist/{cli-7gh7020n.js → cli-1dmcq2eh.js} +1 -1
- package/dist/{cli-kz116kvw.js → cli-239yjk27.js} +7 -7
- package/dist/{cli-7ayprxp7.js → cli-2dfbv8qr.js} +8 -0
- package/dist/{cli-a49vpbf3.js → cli-5kqnemen.js} +1 -1
- package/dist/{cli-x719j3vs.js → cli-5rk36sjf.js} +442 -205
- package/dist/{cli-fb6t92v8.js → cli-6x9ma5w5.js} +1 -1
- package/dist/{cli-c8fwpgqb.js → cli-7225gwj7.js} +2 -2
- package/dist/{cli-5s5hx57y.js → cli-7awf1fp8.js} +1 -1
- package/dist/{cli-bn26xbcs.js → cli-8nyhf7za.js} +1 -1
- package/dist/{cli-ywyrffff.js → cli-8sxqsbbq.js} +1 -1
- package/dist/{cli-rsd7ga7v.js → cli-8zwe9mpk.js} +11 -11
- package/dist/{cli-y83zk5sb.js → cli-a0kcty51.js} +4 -4
- package/dist/{cli-8b9wvjps.js → cli-a4gvwttf.js} +4 -4
- package/dist/{cli-jhp7w1fy.js → cli-a5ebc95n.js} +2 -2
- package/dist/{cli-mvg2hf8p.js → cli-ahtpy9kf.js} +14 -7
- package/dist/{cli-x1p5dhq2.js → cli-bcj753sq.js} +3 -3
- package/dist/{cli-p22zdvnt.js → cli-bn0rewsy.js} +3 -3
- package/dist/{cli-p1m2b6gc.js → cli-d2mb5s8a.js} +7 -7
- package/dist/{cli-rg7ntypm.js → cli-d3qqdy4c.js} +456 -94
- package/dist/{cli-c5sbmw1v.js → cli-d82btycs.js} +94 -15
- package/dist/{cli-02vhhssk.js → cli-efaqv1c7.js} +5 -5
- package/dist/{cli-vjfpshn3.js → cli-ejs3m1sx.js} +1 -1
- package/dist/{cli-kk2r80n5.js → cli-faypxz7q.js} +4 -4
- package/dist/{cli-a1esmmee.js → cli-fpmsqq59.js} +7 -2
- package/dist/{cli-wrv09v7q.js → cli-ftyypbg5.js} +1 -1
- package/dist/{cli-zvze9rbm.js → cli-fyzjmseb.js} +3 -3
- package/dist/{cli-8rwvksbc.js → cli-g0hgtz4p.js} +5 -5
- package/dist/{cli-nrrg0fvy.js → cli-h38rjv87.js} +133 -12
- package/dist/{cli-tg9ksbn8.js → cli-h93c9vb3.js} +2 -2
- package/dist/{cli-eqgfs202.js → cli-hqhzg1qq.js} +2 -2
- package/dist/{cli-bhwa8rzc.js → cli-hyfw2j1e.js} +13 -12
- package/dist/{cli-3wjs22x3.js → cli-j4rqdf2n.js} +2 -2
- package/dist/{cli-6ftrrwm1.js → cli-n85dqg6d.js} +5 -5
- package/dist/{cli-8cxkw1f5.js → cli-nmqdc2t3.js} +2 -2
- package/dist/{cli-4fhyq711.js → cli-nn47d7e8.js} +13 -13
- package/dist/{cli-x3en6ct3.js → cli-nwyw3zhg.js} +1 -1
- package/dist/{cli-3avfh5az.js → cli-pemywhfq.js} +2 -2
- package/dist/{cli-xfe63zx3.js → cli-pkht5hap.js} +2 -2
- package/dist/{cli-sppc0cmw.js → cli-pnp2j5zg.js} +2 -2
- package/dist/{cli-wk8ccrz6.js → cli-pw7v69vp.js} +2 -2
- package/dist/{cli-tgvpmnwz.js → cli-r99kc9yq.js} +5 -5
- package/dist/{cli-bfc3k1x5.js → cli-rdyw3mct.js} +9 -9
- package/dist/{cli-b80a1100.js → cli-s6vr7r2d.js} +2 -2
- package/dist/{cli-zv4vy2b0.js → cli-s95dknxh.js} +1 -1
- package/dist/{cli-ttaz6c7m.js → cli-saw9a6f6.js} +6 -5
- package/dist/{cli-mz1ezzzh.js → cli-shvfedvm.js} +3 -3
- package/dist/{cli-rdr166ht.js → cli-smvcw9z5.js} +1 -1
- package/dist/{cli-2rwet3r7.js → cli-sv5gb0yb.js} +1 -1
- package/dist/{cli-5ff2sjyt.js → cli-t30yj95q.js} +2 -2
- package/dist/{cli-ptkpaqfy.js → cli-wt645v7q.js} +1 -1
- package/dist/{cli-f3nx1h8z.js → cli-wxbn3yfx.js} +2 -2
- package/dist/{cli-wyrr38xm.js → cli-x5mq1fyq.js} +1 -1
- package/dist/{cli-ccn0eybf.js → cli-xbzvet96.js} +6 -6
- package/dist/{cli-4mkvwgzr.js → cli-xf234ws8.js} +1 -1
- package/dist/{cli-nme7wery.js → cli-y2x52gs2.js} +46 -13
- package/dist/{cli-ffbrkh1r.js → cli-y8n5k515.js} +46 -38
- package/dist/{cli-ecshf504.js → cli-z4wd2ftv.js} +7 -7
- package/dist/{cli-dsgj3e7a.js → cli-zfg9enh9.js} +2 -2
- package/dist/{cli-yxqag76b.js → cli-zw88zjba.js} +1 -1
- package/dist/cli.js +20 -20
- package/dist/{codex-adapter-pp2gq8gr.js → codex-adapter-ttygw382.js} +6 -6
- package/dist/{codex-hook-qwzccd64.js → codex-hook-x7nejyrr.js} +7 -4
- package/dist/{codex-login-35p4sk85.js → codex-login-m819c41r.js} +1 -1
- package/dist/{codex-session-runner-0vzn09t8.js → codex-session-runner-60b58vv3.js} +7 -7
- package/dist/{commands-psqhkttb.js → commands-edmdwa4x.js} +2 -2
- package/dist/{controller-t20vz2k8.js → controller-m263fg43.js} +4 -4
- package/dist/{controllers-connect-gkxmqw9d.js → controllers-connect-5fym8sw1.js} +23 -23
- package/dist/{cursor-adapter-3v542qe5.js → cursor-adapter-cz6nm6mj.js} +3 -3
- package/dist/{db-nc1rd869.js → db-2y0mgstc.js} +3 -3
- package/dist/{db-2xbyenjr.js → db-nn0hcmx5.js} +6 -2
- package/dist/{devin-adapter-7egzmt0k.js → devin-adapter-zgqadvnm.js} +1 -1
- package/dist/{dispatcher-ewceb8ts.js → dispatcher-0a9wwjcx.js} +20 -20
- package/dist/{docker-njavd3fb.js → docker-fkgtr75t.js} +2 -2
- package/dist/{dsh-adapter-75xwh78t.js → dsh-adapter-3n3net33.js} +3 -3
- package/dist/{e2b-5r6a5jeb.js → e2b-7am6tmq6.js} +2 -2
- package/dist/{e2b-pw1nvtmh.js → e2b-thdcxtjd.js} +2 -2
- package/dist/{easter-egg-3d-xsc47jwf.js → easter-egg-3d-5cgynaja.js} +1 -1
- package/dist/{event-bus-101fenkh.js → event-bus-9vpk8gmq.js} +1 -1
- package/dist/{execute-gzmw1mnw.js → execute-t4e6m3rw.js} +1 -1
- package/dist/{external-channel-a5dsmxn4.js → external-channel-zhvt95n3.js} +2 -2
- package/dist/{grok-adapter-vj88xr23.js → grok-adapter-qnr1hp2y.js} +10 -10
- package/dist/handlers-dkcarkd0.js +39 -0
- package/dist/{heartbeat-t2m9r3wa.js → heartbeat-gz97r7ac.js} +23 -23
- package/dist/{hook-xa677f8r.js → hook-1mmdgjqs.js} +16 -9
- package/dist/{hook-8femx1bg.js → hook-x8rthca4.js} +6 -6
- package/dist/{http-xpmmq028.js → http-r8f85xr9.js} +274 -160
- package/dist/index-5jecgp3n.js +41 -0
- package/dist/index-aex0eba9.js +40 -0
- package/dist/{index-syztqdt6.js → index-g6tbb6ve.js} +1 -1
- package/dist/{index-cdzyfrsp.js → index-smxyk04s.js} +1 -1
- package/dist/{index-dg2tmj99.js → index-vzvfqzfj.js} +20 -20
- package/dist/{index-s7ckdajq.js → index-y7rbcrc1.js} +10 -10
- package/dist/{keepalive-3nr4st3r.js → keepalive-64c0f34c.js} +4 -4
- package/dist/lead-b1da0f97.js +78 -0
- package/dist/{maintenance-verwpe2c.js → maintenance-mnxpar12.js} +1 -1
- package/dist/{oauth-refresh-sweep-9q2t0y3q.js → oauth-refresh-sweep-36hs2v4h.js} +3 -3
- package/dist/{onboard-aytqedmq.js → onboard-xes8de61.js} +3 -3
- package/dist/{opencode-adapter-f4hryjny.js → opencode-adapter-3s7ax02e.js} +7 -7
- package/dist/{otel-impl-hckw4wt1.js → otel-impl-fzc57kcq.js} +2 -2
- package/dist/{pi-mono-adapter-hxby0ce1.js → pi-mono-adapter-ytp7gz8b.js} +22 -11
- package/dist/{pricing-refresh-5nvme3sm.js → pricing-refresh-1nek2w7b.js} +3 -3
- package/dist/{rbac-roles-vbpy21mg.js → rbac-roles-sqefb5hw.js} +2 -2
- package/dist/{rbac-roles-pd8c26m1.js → rbac-roles-ttyfaep2.js} +1 -1
- package/dist/{render-v2-7nqgvk2q.js → render-v2-vb527jwn.js} +3 -3
- package/dist/{resume-b6djcj3c.js → resume-jcdzg1bn.js} +10 -8
- package/dist/{runtime-txrxc9za.js → runtime-xe3aqy4x.js} +1 -1
- package/dist/{session-access-fc3f9y12.js → session-access-zsp4wsp0.js} +4 -4
- package/dist/{setup-n39szm6b.js → setup-1nb2zqd3.js} +1 -1
- package/dist/{virtual-modules-wyp2k6z3.js → virtual-modules-4r3zkc20.js} +2 -2
- package/dist/{work-objects-jem2fre0.js → work-objects-61w08fwr.js} +23 -23
- package/dist/worker-c1e68hpa.js +78 -0
- package/dist/{x-8ewt8gxn.js → x-y45mthwn.js} +2 -2
- package/openapi.json +491 -52
- package/package.json +3 -3
- package/src/apps/action-runner.ts +146 -0
- package/src/apps/catalog.generated.json +3 -2
- package/src/apps/definition.ts +152 -4
- package/src/apps/row-store.ts +23 -9
- package/src/apps/schema-migrate.ts +14 -6
- package/src/apps/script-types.ts +2 -1
- package/src/apps/sync.ts +20 -19
- package/src/be/db/tasks/read.ts +112 -0
- package/src/be/db.ts +16 -4
- package/src/be/fleet-benchmark-stats.ts +10 -5
- package/src/be/models-catalog.ts +4 -0
- package/src/be/onboarding.ts +6 -0
- package/src/be/scripts/typecheck.ts +13 -0
- package/src/be/swarm-config-guard.ts +25 -1
- package/src/be/telemetry-enable.ts +1 -1
- package/src/commands/provider-credentials.ts +12 -2
- package/src/commands/runner.ts +3 -0
- package/src/heartbeat/heartbeat.ts +2 -2
- package/src/hooks/codex-hook.ts +6 -2
- package/src/hooks/hook.ts +7 -0
- package/src/hooks/pr-body-guard.ts +38 -11
- package/src/hooks/pr-room-guard.ts +88 -0
- package/src/http/agents.ts +1 -0
- package/src/http/apps.ts +99 -183
- package/src/http/fleet-benchmark.ts +79 -30
- package/src/http/global-config.ts +7 -0
- package/src/http/ranking.ts +24 -40
- package/src/http/status.ts +49 -43
- package/src/http/tasks.ts +49 -1
- package/src/http/telemetry-ui.ts +1 -1
- package/src/http/used-by.ts +55 -19
- package/src/http/users.ts +2 -2
- package/src/providers/acp-targets.ts +1 -1
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/pi-mono-adapter.ts +15 -0
- package/src/providers/reasoning-effort.ts +2 -1
- package/src/providers/types.ts +2 -0
- package/src/ranking/telemetry-ranking.ts +5 -33
- package/src/scripts-runtime/sdk-allowlist.ts +7 -0
- package/src/scripts-runtime/types/stdlib.d.ts +31 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +31 -0
- package/src/server.ts +4 -0
- package/src/telemetry-context.ts +19 -1
- package/src/telemetry-contract/EVENTS.md +42 -8
- package/src/telemetry-contract/SOURCE.json +6 -5
- package/src/telemetry-contract/catalog.json +225 -5
- package/src/telemetry-contract/ranking/fixtures/valid/no-claim.json +10 -0
- package/src/telemetry-contract/ranking/listing.v1.schema.json +2 -2
- package/src/telemetry-contract/types.gen.ts +225 -27
- package/src/telemetry-identity.ts +47 -9
- package/src/telemetry-snapshot.ts +9 -1
- package/src/telemetry-ui-events.ts +9 -2
- package/src/telemetry.ts +29 -6
- package/src/tests/app-action-run-tool.test.ts +178 -0
- package/src/tests/app-row-tools.test.ts +385 -0
- package/src/tests/apps-json-column.test.ts +611 -0
- package/src/tests/apps-script-types.test.ts +2 -0
- package/src/tests/apps-sync-engine.test.ts +42 -0
- package/src/tests/check-pr-body.test.ts +0 -23
- package/src/tests/credential-check.test.ts +38 -0
- package/src/tests/db-task-read-contract.test.ts +4 -0
- package/src/tests/db-task-write-contract.test.ts +2 -0
- package/src/tests/docs-index-footprint.test.js +2 -2
- package/src/tests/fleet-benchmark.test.ts +122 -2
- package/src/tests/http-users.test.ts +62 -1
- package/src/tests/model-catalog-table.test.ts +63 -0
- package/src/tests/model-profiles-api.test.ts +19 -7
- package/src/tests/models-catalog.test.ts +50 -0
- package/src/tests/onboarding.test.ts +21 -0
- package/src/tests/org-website.test.ts +62 -0
- package/src/tests/pi-mono-adapter.test.ts +23 -0
- package/src/tests/pr-body-leaks.test.ts +4 -0
- package/src/tests/pr-room-guard.test.ts +242 -0
- package/src/tests/ranking-listing.test.ts +8 -23
- package/src/tests/ranking-route.test.ts +49 -47
- package/src/tests/reasoning-effort.test.ts +39 -3
- package/src/tests/runner-harness-telemetry-env.test.ts +62 -1
- package/src/tests/status.test.ts +158 -9
- package/src/tests/swarm-tool-result-gate.test.ts +2 -4
- package/src/tests/task-lineage.test.ts +180 -0
- package/src/tests/telemetry-context.test.ts +128 -2
- package/src/tests/telemetry-init.test.ts +62 -5
- package/src/tests/telemetry-ui-route.test.ts +123 -0
- package/src/tests/tool-annotations.test.ts +3 -2
- package/src/tests/used-by-route.test.ts +144 -10
- package/src/tests/user-token-rest-auth.test.ts +38 -0
- package/src/tests/workflow-agent-task-retry.test.ts +130 -4
- package/src/tests/workflow-wait-recovery.test.ts +116 -4
- package/src/tools/app-action-run.ts +63 -0
- package/src/tools/app-rows.ts +239 -0
- package/src/tools/model-catalog/index.ts +1 -1
- package/src/tools/tool-config.ts +8 -0
- package/src/tools/utils.ts +1 -1
- package/src/tools/workflows/retry-workflow-run.ts +1 -1
- package/src/types.ts +17 -0
- package/src/utils/company-profile.ts +24 -0
- package/src/utils/org-website.ts +58 -21
- package/src/workflows/recovery.ts +10 -22
- package/src/workflows/resume.ts +16 -0
- package/src/workflows/task-step-routing.ts +47 -7
- package/templates/skills/apps/SKILL.md +44 -4
- package/templates/skills/apps/content.md +44 -4
- package/dist/handlers-zbs90bny.js +0 -39
- package/dist/index-8pwc1jc8.js +0 -40
- package/dist/index-nxa6me0q.js +0 -41
- package/dist/lead-td8h2sfn.js +0 -78
- package/dist/worker-wr6xgfr2.js +0 -78
- package/src/tests/check-ranking-entry.test.ts +0 -146
|
@@ -5,6 +5,7 @@ import { closeDb, createAgent, getSwarmConfigs, initDb, upsertSwarmConfig } from
|
|
|
5
5
|
import { fillOrgLogoFromWebsite } from "../be/org-profile";
|
|
6
6
|
import { handleUsedBy } from "../http/used-by";
|
|
7
7
|
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
8
|
+
import { _resetTelemetryStateForTests, initTelemetry } from "../telemetry";
|
|
8
9
|
import type { HttpRequestAuth } from "../utils/request-auth-context";
|
|
9
10
|
import { setRequestAuth } from "../utils/request-auth-context";
|
|
10
11
|
|
|
@@ -94,26 +95,155 @@ describe("POST /api/used-by", () => {
|
|
|
94
95
|
});
|
|
95
96
|
|
|
96
97
|
test("a non-lead agent gets 403 and nothing reaches the proxy", async () => {
|
|
97
|
-
const result = await post({ granted: true }, null, WORKER_ID);
|
|
98
|
+
const result = await post({ granted: true, contactEmail: "admin@acme.com" }, null, WORKER_ID);
|
|
98
99
|
expect(result.statusCode).toBe(403);
|
|
99
100
|
expect(proxied).toEqual([]);
|
|
100
101
|
});
|
|
101
102
|
|
|
103
|
+
test("consent telemetry reports quote and function presence without their contents", async () => {
|
|
104
|
+
const savedFlag = process.env.ANONYMIZED_TELEMETRY;
|
|
105
|
+
try {
|
|
106
|
+
process.env.ANONYMIZED_TELEMETRY = "true";
|
|
107
|
+
_resetTelemetryStateForTests();
|
|
108
|
+
await initTelemetry("api-server", configValue, async () => {}, { generateIfMissing: true });
|
|
109
|
+
await post(
|
|
110
|
+
{
|
|
111
|
+
granted: true,
|
|
112
|
+
contactEmail: "admin@acme.com",
|
|
113
|
+
contactFunction: "founder",
|
|
114
|
+
quote: "Private quote",
|
|
115
|
+
},
|
|
116
|
+
OPERATOR,
|
|
117
|
+
);
|
|
118
|
+
await post({ granted: false }, OPERATOR);
|
|
119
|
+
await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
|
|
120
|
+
const events = proxied.filter((request) => request.body.event === "used_by.consent_changed");
|
|
121
|
+
expect(events).toHaveLength(3);
|
|
122
|
+
expect(events.map((event) => event.body.properties)).toMatchObject([
|
|
123
|
+
{ granted: true, has_quote: true, has_function: true },
|
|
124
|
+
{ granted: false, has_quote: false, has_function: false },
|
|
125
|
+
{ granted: true, has_quote: false, has_function: false },
|
|
126
|
+
]);
|
|
127
|
+
for (const event of events) {
|
|
128
|
+
expect(JSON.stringify(event.body)).not.toContain("Private quote");
|
|
129
|
+
expect(JSON.stringify(event.body)).not.toContain("admin@acme.com");
|
|
130
|
+
}
|
|
131
|
+
} finally {
|
|
132
|
+
if (savedFlag === undefined) delete process.env.ANONYMIZED_TELEMETRY;
|
|
133
|
+
else process.env.ANONYMIZED_TELEMETRY = savedFlag;
|
|
134
|
+
_resetTelemetryStateForTests();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
102
138
|
test("grant and revoke both reach the proxy and are stored", async () => {
|
|
103
|
-
const grant = await post(
|
|
139
|
+
const grant = await post(
|
|
140
|
+
{
|
|
141
|
+
granted: true,
|
|
142
|
+
contactEmail: "admin@acme.com",
|
|
143
|
+
contactFunction: "founder",
|
|
144
|
+
quote: "Our swarm ships features.",
|
|
145
|
+
},
|
|
146
|
+
OPERATOR,
|
|
147
|
+
);
|
|
104
148
|
expect(grant.statusCode).toBe(200);
|
|
105
149
|
expect(proxied[0]?.url).toBe("https://proxy.desplega.sh/v1/used-by");
|
|
106
150
|
expect(proxied[0]?.body).toMatchObject({
|
|
107
151
|
org_name: "Acme",
|
|
108
152
|
org_website: "acme.com",
|
|
109
153
|
granted: true,
|
|
154
|
+
contact_email: "admin@acme.com",
|
|
155
|
+
contact_function: "founder",
|
|
156
|
+
quote: "Our swarm ships features.",
|
|
110
157
|
});
|
|
111
158
|
expect(await configValue("SWARM_USED_BY_CONSENT")).toBe("true");
|
|
159
|
+
expect(await configValue("SWARM_USED_BY_CONTACT_EMAIL")).toBe("admin@acme.com");
|
|
160
|
+
expect(await configValue("SWARM_USED_BY_CONTACT_FUNCTION")).toBe("founder");
|
|
161
|
+
expect(await configValue("SWARM_USED_BY_QUOTE")).toBe("Our swarm ships features.");
|
|
162
|
+
// Contact details never reach agent env, and readers see the email masked.
|
|
163
|
+
const rows = await getSwarmConfigs({ scope: "global" });
|
|
164
|
+
const row = (key: string) => rows.find((entry) => entry.key === key);
|
|
165
|
+
for (const key of [
|
|
166
|
+
"SWARM_USED_BY_CONTACT_EMAIL",
|
|
167
|
+
"SWARM_USED_BY_CONTACT_FUNCTION",
|
|
168
|
+
"SWARM_USED_BY_QUOTE",
|
|
169
|
+
]) {
|
|
170
|
+
expect(row(key)?.exportToEnv).toBe(false);
|
|
171
|
+
}
|
|
172
|
+
expect(row("SWARM_USED_BY_CONTACT_EMAIL")?.isSecret).toBe(true);
|
|
112
173
|
|
|
113
174
|
const revoke = await post({ granted: false }, OPERATOR);
|
|
114
175
|
expect(revoke.statusCode).toBe(200);
|
|
115
176
|
expect(proxied[1]?.body).toMatchObject({ granted: false });
|
|
116
177
|
expect(await configValue("SWARM_USED_BY_CONSENT")).toBe("false");
|
|
178
|
+
expect(await configValue("SWARM_USED_BY_CONTACT_EMAIL")).toBe("");
|
|
179
|
+
expect(await configValue("SWARM_USED_BY_CONTACT_FUNCTION")).toBe("");
|
|
180
|
+
expect(await configValue("SWARM_USED_BY_QUOTE")).toBe("");
|
|
181
|
+
expect(proxied[1]?.body).not.toHaveProperty("contact_email");
|
|
182
|
+
expect(proxied[1]?.body).not.toHaveProperty("contact_function");
|
|
183
|
+
expect(proxied[1]?.body).not.toHaveProperty("quote");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a grant requires valid contact details and a quote of at most 280 characters", async () => {
|
|
187
|
+
for (const details of [
|
|
188
|
+
{},
|
|
189
|
+
{ contactEmail: "" },
|
|
190
|
+
{ contactEmail: "invalid" },
|
|
191
|
+
{ contactEmail: "admin@acme.com", contactFunction: "CEO" },
|
|
192
|
+
{ contactEmail: "admin@acme.com", quote: "x".repeat(281) },
|
|
193
|
+
]) {
|
|
194
|
+
const result = await post({ granted: true, ...details }, OPERATOR);
|
|
195
|
+
expect(result.statusCode).toBe(400);
|
|
196
|
+
expect(result.body).toContain("Validation error");
|
|
197
|
+
}
|
|
198
|
+
expect(proxied).toEqual([]);
|
|
199
|
+
expect((await post({ granted: false, contactEmail: "invalid" }, OPERATOR)).statusCode).toBe(
|
|
200
|
+
400,
|
|
201
|
+
);
|
|
202
|
+
expect(proxied).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("contact email accepts 320 characters and rejects longer values", async () => {
|
|
206
|
+
const contactEmail = `${"a".repeat(64)}@${Array(4).fill("b".repeat(63)).join(".")}`;
|
|
207
|
+
expect(contactEmail).toHaveLength(320);
|
|
208
|
+
expect((await post({ granted: true, contactEmail }, OPERATOR)).statusCode).toBe(200);
|
|
209
|
+
proxied = [];
|
|
210
|
+
const result = await post({ granted: true, contactEmail: `a${contactEmail}` }, OPERATOR);
|
|
211
|
+
expect(result.statusCode).toBe(400);
|
|
212
|
+
expect(result.body).toContain("Contact email must be at most 320 characters");
|
|
213
|
+
expect(proxied).toEqual([]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("an empty stored name overrides the deployment name", async () => {
|
|
217
|
+
process.env.SWARM_ORG_NAME = "Deployment name";
|
|
218
|
+
try {
|
|
219
|
+
await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_NAME", value: "" });
|
|
220
|
+
const result = await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
|
|
221
|
+
expect(result.statusCode).toBe(400);
|
|
222
|
+
expect(result.body).toContain("Set the organization name and website first");
|
|
223
|
+
expect(proxied).toEqual([]);
|
|
224
|
+
} finally {
|
|
225
|
+
delete process.env.SWARM_ORG_NAME;
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("a new grant clears omitted optional fields from the previous grant", async () => {
|
|
230
|
+
const result = await post(
|
|
231
|
+
{
|
|
232
|
+
granted: true,
|
|
233
|
+
contactEmail: "admin@acme.com",
|
|
234
|
+
contactFunction: "engineer",
|
|
235
|
+
quote: "x".repeat(280),
|
|
236
|
+
},
|
|
237
|
+
OPERATOR,
|
|
238
|
+
);
|
|
239
|
+
expect(result.statusCode).toBe(200);
|
|
240
|
+
expect(await configValue("SWARM_USED_BY_QUOTE")).toHaveLength(280);
|
|
241
|
+
expect((await post({ granted: true, contactEmail: "new@acme.com" }, OPERATOR)).statusCode).toBe(
|
|
242
|
+
200,
|
|
243
|
+
);
|
|
244
|
+
expect(await configValue("SWARM_USED_BY_CONTACT_EMAIL")).toBe("new@acme.com");
|
|
245
|
+
expect(await configValue("SWARM_USED_BY_CONTACT_FUNCTION")).toBe("");
|
|
246
|
+
expect(await configValue("SWARM_USED_BY_QUOTE")).toBe("");
|
|
117
247
|
});
|
|
118
248
|
|
|
119
249
|
test("with telemetry off the install ID stays home", async () => {
|
|
@@ -124,9 +254,9 @@ describe("POST /api/used-by", () => {
|
|
|
124
254
|
});
|
|
125
255
|
const saved = process.env.ANONYMIZED_TELEMETRY;
|
|
126
256
|
process.env.ANONYMIZED_TELEMETRY = "true";
|
|
127
|
-
await post({ granted: true }, OPERATOR);
|
|
257
|
+
await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
|
|
128
258
|
process.env.ANONYMIZED_TELEMETRY = "false";
|
|
129
|
-
await post({ granted: true }, OPERATOR);
|
|
259
|
+
await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
|
|
130
260
|
if (saved === undefined) delete process.env.ANONYMIZED_TELEMETRY;
|
|
131
261
|
else process.env.ANONYMIZED_TELEMETRY = saved;
|
|
132
262
|
expect(proxied[0]?.body.install_id).toBe("install_0123456789abcdef");
|
|
@@ -139,7 +269,7 @@ describe("POST /api/used-by", () => {
|
|
|
139
269
|
key: "feedback_endpoint",
|
|
140
270
|
value: "http://stub.test/v1/feedback",
|
|
141
271
|
});
|
|
142
|
-
await post({ granted: true }, OPERATOR);
|
|
272
|
+
await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
|
|
143
273
|
expect(proxied[0]?.url).toBe("http://stub.test/v1/used-by");
|
|
144
274
|
await upsertSwarmConfig({
|
|
145
275
|
scope: "global",
|
|
@@ -151,13 +281,15 @@ describe("POST /api/used-by", () => {
|
|
|
151
281
|
test("a proxy failure is a 502 and leaves the stored consent alone", async () => {
|
|
152
282
|
await upsertSwarmConfig({ scope: "global", key: "SWARM_USED_BY_CONSENT", value: "false" });
|
|
153
283
|
proxyStatus = 500;
|
|
154
|
-
const result = await post({ granted: true }, OPERATOR);
|
|
284
|
+
const result = await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
|
|
155
285
|
expect(result.statusCode).toBe(502);
|
|
156
286
|
expect(await configValue("SWARM_USED_BY_CONSENT")).toBe("false");
|
|
157
287
|
});
|
|
158
288
|
|
|
159
289
|
test("a revoke after the website is cleared or changed uses the granted identity", async () => {
|
|
160
|
-
expect(
|
|
290
|
+
expect(
|
|
291
|
+
(await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR)).statusCode,
|
|
292
|
+
).toBe(200);
|
|
161
293
|
await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_WEBSITE", value: "" });
|
|
162
294
|
const revoke = await post({ granted: false }, OPERATOR);
|
|
163
295
|
expect(revoke.statusCode).toBe(200);
|
|
@@ -173,7 +305,9 @@ describe("POST /api/used-by", () => {
|
|
|
173
305
|
expect(proxied[2]?.body).toMatchObject({ org_name: "Acme", org_website: "acme.com" });
|
|
174
306
|
|
|
175
307
|
// A new grant sends, and from then on revokes with, the current identity.
|
|
176
|
-
expect(
|
|
308
|
+
expect(
|
|
309
|
+
(await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR)).statusCode,
|
|
310
|
+
).toBe(200);
|
|
177
311
|
expect(proxied[3]?.body).toMatchObject({ org_name: "Acme Labs", org_website: "acme.io" });
|
|
178
312
|
await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_WEBSITE", value: "" });
|
|
179
313
|
await post({ granted: false }, OPERATOR);
|
|
@@ -182,7 +316,7 @@ describe("POST /api/used-by", () => {
|
|
|
182
316
|
|
|
183
317
|
test("without a website it is a 400", async () => {
|
|
184
318
|
await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_WEBSITE", value: "" });
|
|
185
|
-
const result = await post({ granted: true }, OPERATOR);
|
|
319
|
+
const result = await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
|
|
186
320
|
expect(result.statusCode).toBe(400);
|
|
187
321
|
expect(JSON.parse(result.body)).toEqual({
|
|
188
322
|
error: "Set the organization name and website first",
|
|
@@ -192,7 +326,7 @@ describe("POST /api/used-by", () => {
|
|
|
192
326
|
|
|
193
327
|
test("a placeholder name still grants: the ranking's placeholder rule does not apply", async () => {
|
|
194
328
|
await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_NAME", value: "Swarm" });
|
|
195
|
-
const result = await post({ granted: true }, OPERATOR);
|
|
329
|
+
const result = await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
|
|
196
330
|
expect(result.statusCode).toBe(200);
|
|
197
331
|
expect(proxied[0]?.body).toMatchObject({ org_name: "Swarm", org_website: "acme.com" });
|
|
198
332
|
});
|
|
@@ -110,6 +110,44 @@ describe("normal REST API user-bound token auth", () => {
|
|
|
110
110
|
expect(body.requestedByUserId).toBeUndefined();
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
for (const caller of ["user", "operator", "agent"] as const) {
|
|
114
|
+
for (const explicit of [false, true]) {
|
|
115
|
+
test(`REST ${caller} follow-up ${explicit ? "keeps an explicit schema" : caller === "agent" ? "inherits the parent schema" : "does not inherit the parent schema"}`, async () => {
|
|
116
|
+
const user = await createUser({ name: "Schema follow-up user" });
|
|
117
|
+
const agent = await createAgent({ name: "Schema worker", status: "idle" });
|
|
118
|
+
const parentSchema = { type: "object", properties: { options: { type: "array" } } };
|
|
119
|
+
const explicitSchema = { type: "object", properties: { answer: { type: "string" } } };
|
|
120
|
+
const parent = await createTaskExtended("schema-carrying worker task", {
|
|
121
|
+
agentId: agent.id,
|
|
122
|
+
requestedByUserId: user.id,
|
|
123
|
+
outputSchema: parentSchema,
|
|
124
|
+
});
|
|
125
|
+
const token =
|
|
126
|
+
caller === "user" ? (await mintToken(user.id, "rest", ACTOR)).plaintext : API_KEY;
|
|
127
|
+
const res = await fetch(`http://localhost:${port}/api/tasks`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: {
|
|
130
|
+
Authorization: `Bearer ${token}`,
|
|
131
|
+
"Content-Type": "application/json",
|
|
132
|
+
...(caller === "agent" ? { "X-Agent-ID": agent.id } : {}),
|
|
133
|
+
},
|
|
134
|
+
body: JSON.stringify({
|
|
135
|
+
task: "Continue the session",
|
|
136
|
+
parentTaskId: parent.id,
|
|
137
|
+
source: "ui",
|
|
138
|
+
...(explicit ? { outputSchema: explicitSchema } : {}),
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(res.status).toBe(201);
|
|
143
|
+
const body = (await res.json()) as { id: string; outputSchema?: unknown };
|
|
144
|
+
expect(body.outputSchema).toEqual(
|
|
145
|
+
explicit ? explicitSchema : caller === "agent" ? parentSchema : undefined,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
113
151
|
test("global API key caller cannot spoof requestedByUserId via body — falls back to owned task context", async () => {
|
|
114
152
|
const legitRequester = await createUser({ name: "Legit Requester" });
|
|
115
153
|
const attacker = await createUser({ name: "Attacker" });
|
|
@@ -13,8 +13,14 @@ import {
|
|
|
13
13
|
getWorkflowRun,
|
|
14
14
|
getWorkflowRunStepsByRunId,
|
|
15
15
|
initDb,
|
|
16
|
+
updateWorkflowRunStep,
|
|
16
17
|
} from "../be/db";
|
|
17
|
-
import
|
|
18
|
+
import {
|
|
19
|
+
type AgentTask,
|
|
20
|
+
SUPERSEDED_WORKFLOW_TASK_REASON,
|
|
21
|
+
type WorkflowDefinition,
|
|
22
|
+
type WorkflowRunStep,
|
|
23
|
+
} from "../types";
|
|
18
24
|
import { startWorkflowExecution } from "../workflows/engine";
|
|
19
25
|
import { workflowEventBus } from "../workflows/event-bus";
|
|
20
26
|
import { AgentTaskExecutor } from "../workflows/executors/agent-task";
|
|
@@ -27,6 +33,7 @@ import { ExecutorRegistry } from "../workflows/executors/registry";
|
|
|
27
33
|
import { recoverIncompleteRuns } from "../workflows/recovery";
|
|
28
34
|
import { setupWorkflowResumeListener } from "../workflows/resume";
|
|
29
35
|
import { startRetryPoller, stopRetryPoller } from "../workflows/retry-poller";
|
|
36
|
+
import { SUPERSEDE_RETRY_POLICY } from "../workflows/task-step-routing";
|
|
30
37
|
import { interpolate } from "../workflows/template";
|
|
31
38
|
|
|
32
39
|
const TEST_DB_PATH = "./test-workflow-agent-task-retry.sqlite";
|
|
@@ -217,18 +224,18 @@ describe("agent-task node.retry on task failure", () => {
|
|
|
217
224
|
expect((await getTaskByWorkflowRunStepId(step.id))!.id).toBe(second.id);
|
|
218
225
|
});
|
|
219
226
|
|
|
220
|
-
test("a node without retry fails the run exactly as before", async () => {
|
|
227
|
+
test("a node without retry fails the run exactly as before on a non-supersede failure", async () => {
|
|
221
228
|
const runId = await startRun({});
|
|
222
229
|
const step = await synthesizeStep(runId);
|
|
223
230
|
const first = (await getTaskByWorkflowRunStepId(step.id))!;
|
|
224
231
|
|
|
225
|
-
await failTask(first.id, "
|
|
232
|
+
await failTask(first.id, "agent crashed");
|
|
226
233
|
|
|
227
234
|
const run = await waitFor(async () => {
|
|
228
235
|
const r = await getWorkflowRun(runId);
|
|
229
236
|
return r?.status === "failed" ? r : null;
|
|
230
237
|
}, "run failed");
|
|
231
|
-
expect(run.error).toContain("
|
|
238
|
+
expect(run.error).toContain("agent crashed");
|
|
232
239
|
const failedStep = await synthesizeStep(runId);
|
|
233
240
|
expect(failedStep.status).toBe("failed");
|
|
234
241
|
expect(failedStep.retryCount).toBe(0);
|
|
@@ -281,6 +288,125 @@ describe("agent-task node.retry on task failure", () => {
|
|
|
281
288
|
});
|
|
282
289
|
});
|
|
283
290
|
|
|
291
|
+
/** Wait for the step to be queued for the poller, then make it due now. */
|
|
292
|
+
async function waitForQueuedRetry(runId: string): Promise<WorkflowRunStep> {
|
|
293
|
+
const queued = await waitFor(async () => {
|
|
294
|
+
const s = await synthesizeStep(runId);
|
|
295
|
+
return s.status === "failed" && s.nextRetryAt != null ? s : null;
|
|
296
|
+
}, "step queued for retry");
|
|
297
|
+
await updateWorkflowRunStep(queued.id, { nextRetryAt: new Date(Date.now() - 1).toISOString() });
|
|
298
|
+
return queued;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
describe("implicit retry for a superseded task on a node without retry", () => {
|
|
302
|
+
test("a cut is redriven once after the static delay; a second cut follows onNodeFailure", async () => {
|
|
303
|
+
const runId = await startRun({});
|
|
304
|
+
const step = await synthesizeStep(runId);
|
|
305
|
+
const first = (await getTaskByWorkflowRunStepId(step.id))!;
|
|
306
|
+
|
|
307
|
+
const cutAt = Date.now();
|
|
308
|
+
await failTask(first.id, SUPERSEDED_WORKFLOW_TASK_REASON);
|
|
309
|
+
|
|
310
|
+
const queued = await waitFor(async () => {
|
|
311
|
+
const s = await synthesizeStep(runId);
|
|
312
|
+
return s.status === "failed" && s.nextRetryAt != null ? s : null;
|
|
313
|
+
}, "step queued for retry");
|
|
314
|
+
expect(queued.retryCount).toBe(1);
|
|
315
|
+
expect(queued.maxRetries).toBe(1);
|
|
316
|
+
expect(queued.error).toBe(SUPERSEDED_WORKFLOW_TASK_REASON);
|
|
317
|
+
const delayMs = new Date(queued.nextRetryAt!).getTime() - cutAt;
|
|
318
|
+
expect(delayMs).toBeGreaterThanOrEqual(SUPERSEDE_RETRY_POLICY.baseDelayMs - 1000);
|
|
319
|
+
expect(delayMs).toBeLessThanOrEqual(SUPERSEDE_RETRY_POLICY.baseDelayMs + 5000);
|
|
320
|
+
expect((await getWorkflowRun(runId))!.status).toBe("waiting");
|
|
321
|
+
expect((await getTaskById(first.id))!.workflowRunStepId).toBeFalsy();
|
|
322
|
+
|
|
323
|
+
await waitForQueuedRetry(runId);
|
|
324
|
+
startRetryPoller(registry, 10);
|
|
325
|
+
const second = await waitForRedrive(step.id, first);
|
|
326
|
+
await waitFor(async () => {
|
|
327
|
+
const s = await synthesizeStep(runId);
|
|
328
|
+
return s.status === "waiting" ? s : null;
|
|
329
|
+
}, "step waiting on redriven task");
|
|
330
|
+
stopRetryPoller();
|
|
331
|
+
|
|
332
|
+
await failTask(second.id, SUPERSEDED_WORKFLOW_TASK_REASON);
|
|
333
|
+
const run = await waitFor(async () => {
|
|
334
|
+
const r = await getWorkflowRun(runId);
|
|
335
|
+
return r?.status === "failed" ? r : null;
|
|
336
|
+
}, "run failed via onNodeFailure:fail");
|
|
337
|
+
expect(run.error).toContain(SUPERSEDED_WORKFLOW_TASK_REASON);
|
|
338
|
+
const finalStep = await synthesizeStep(runId);
|
|
339
|
+
expect(finalStep.status).toBe("failed");
|
|
340
|
+
expect(finalStep.retryCount).toBe(1);
|
|
341
|
+
expect(finalStep.nextRetryAt).toBeFalsy();
|
|
342
|
+
expect((await getTaskByWorkflowRunStepId(step.id))!.id).toBe(second.id);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("the redriven task completing continues the run", async () => {
|
|
346
|
+
const runId = await startRun({ onNodeFailure: "continue" });
|
|
347
|
+
const step = await synthesizeStep(runId);
|
|
348
|
+
const first = (await getTaskByWorkflowRunStepId(step.id))!;
|
|
349
|
+
|
|
350
|
+
await failTask(first.id, SUPERSEDED_WORKFLOW_TASK_REASON);
|
|
351
|
+
await waitForQueuedRetry(runId);
|
|
352
|
+
startRetryPoller(registry, 10);
|
|
353
|
+
const second = await waitForRedrive(step.id, first);
|
|
354
|
+
await waitFor(async () => {
|
|
355
|
+
const s = await synthesizeStep(runId);
|
|
356
|
+
return s.status === "waiting" ? s : null;
|
|
357
|
+
}, "step waiting on redriven task");
|
|
358
|
+
stopRetryPoller();
|
|
359
|
+
|
|
360
|
+
await completeTask(second.id, "synthesis done");
|
|
361
|
+
const run = await waitFor(async () => {
|
|
362
|
+
const r = await getWorkflowRun(runId);
|
|
363
|
+
return r?.status === "completed" ? r : null;
|
|
364
|
+
}, "run completed");
|
|
365
|
+
expect(run.status).toBe("completed");
|
|
366
|
+
const finalStep = await synthesizeStep(runId);
|
|
367
|
+
expect(JSON.stringify(finalStep.output)).not.toContain("[FAILED:");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("explicit retry {maxRetries:0} opts out: the cut fails the run", async () => {
|
|
371
|
+
const runId = await startRun({ retry: { ...RETRY_ONCE, maxRetries: 0 } });
|
|
372
|
+
const step = await synthesizeStep(runId);
|
|
373
|
+
const first = (await getTaskByWorkflowRunStepId(step.id))!;
|
|
374
|
+
|
|
375
|
+
await failTask(first.id, SUPERSEDED_WORKFLOW_TASK_REASON);
|
|
376
|
+
|
|
377
|
+
const run = await waitFor(async () => {
|
|
378
|
+
const r = await getWorkflowRun(runId);
|
|
379
|
+
return r?.status === "failed" ? r : null;
|
|
380
|
+
}, "run failed");
|
|
381
|
+
expect(run.error).toContain(SUPERSEDED_WORKFLOW_TASK_REASON);
|
|
382
|
+
const failedStep = await synthesizeStep(runId);
|
|
383
|
+
expect(failedStep.retryCount).toBe(0);
|
|
384
|
+
expect(failedStep.nextRetryAt).toBeFalsy();
|
|
385
|
+
expect((await getTaskByWorkflowRunStepId(step.id))!.id).toBe(first.id);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("the recovery sweep reads the cut from the task row", async () => {
|
|
389
|
+
const runId = await startRun({});
|
|
390
|
+
const step = await synthesizeStep(runId);
|
|
391
|
+
const first = (await getTaskByWorkflowRunStepId(step.id))!;
|
|
392
|
+
|
|
393
|
+
teardownResumeListener?.();
|
|
394
|
+
try {
|
|
395
|
+
await failTask(first.id, SUPERSEDED_WORKFLOW_TASK_REASON);
|
|
396
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
397
|
+
await recoverIncompleteRuns(registry);
|
|
398
|
+
} finally {
|
|
399
|
+
teardownResumeListener = setupWorkflowResumeListener(workflowEventBus, registry);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const queued = await synthesizeStep(runId);
|
|
403
|
+
expect(queued.status).toBe("failed");
|
|
404
|
+
expect(queued.nextRetryAt).toBeTruthy();
|
|
405
|
+
expect(queued.retryCount).toBe(1);
|
|
406
|
+
expect((await getWorkflowRun(runId))!.status).toBe("waiting");
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
284
410
|
describe("recovery sweep holding a stale failed-task snapshot", () => {
|
|
285
411
|
/**
|
|
286
412
|
* Recovery snapshots task A as failed, then pauses. Meanwhile the live
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { unlink } from "node:fs/promises";
|
|
3
3
|
import * as db from "../be/db";
|
|
4
4
|
import {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getWorkflowRunStep,
|
|
14
14
|
getWorkflowRunStepsByRunId,
|
|
15
15
|
initDb,
|
|
16
|
+
resolveWaitState,
|
|
16
17
|
updateWorkflowRun,
|
|
17
18
|
updateWorkflowRunStep,
|
|
18
19
|
} from "../be/db";
|
|
@@ -23,6 +24,10 @@ import { createExecutorRegistry } from "../workflows/executors/registry";
|
|
|
23
24
|
import { recoverIncompleteRuns } from "../workflows/recovery";
|
|
24
25
|
|
|
25
26
|
const TEST_DB_PATH = "./test-workflow-wait-recovery.sqlite";
|
|
27
|
+
const templateGlobals = globalThis as typeof globalThis & {
|
|
28
|
+
__testMigrationTemplate?: Uint8Array;
|
|
29
|
+
};
|
|
30
|
+
let savedTemplate: Uint8Array | undefined;
|
|
26
31
|
|
|
27
32
|
const eventBus = new InProcessEventBus();
|
|
28
33
|
const deps: ExecutorDependencies = {
|
|
@@ -42,7 +47,52 @@ async function makeWorkflow(name: string, def: WorkflowDefinition): Promise<Work
|
|
|
42
47
|
return wf;
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
async function makeResolvedWait(
|
|
51
|
+
mode: "time" | "event",
|
|
52
|
+
status: "fired" | "timeout",
|
|
53
|
+
nextPort: string,
|
|
54
|
+
) {
|
|
55
|
+
const wf = await makeWorkflow("wait-recovery-resolved", {
|
|
56
|
+
nodes: [
|
|
57
|
+
{
|
|
58
|
+
id: "w1",
|
|
59
|
+
type: "wait",
|
|
60
|
+
config:
|
|
61
|
+
mode === "time"
|
|
62
|
+
? { mode, durationMs: 60_000 }
|
|
63
|
+
: { mode, eventName: "saved.signal", timeoutMs: 60_000 },
|
|
64
|
+
next: { [nextPort]: "done" },
|
|
65
|
+
},
|
|
66
|
+
{ id: "done", type: "notify", config: { channel: "swarm", template: "recovered" } },
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
const run = await createWorkflowRun({ id: crypto.randomUUID(), workflowId: wf.id });
|
|
70
|
+
await updateWorkflowRun(run.id, { status: "waiting" });
|
|
71
|
+
const step = await createWorkflowRunStep({
|
|
72
|
+
id: crypto.randomUUID(),
|
|
73
|
+
runId: run.id,
|
|
74
|
+
nodeId: "w1",
|
|
75
|
+
nodeType: "wait",
|
|
76
|
+
});
|
|
77
|
+
await updateWorkflowRunStep(step.id, { status: "waiting" });
|
|
78
|
+
const future = new Date(Date.now() + 60_000).toISOString();
|
|
79
|
+
const wait = await createWaitState({
|
|
80
|
+
id: crypto.randomUUID(),
|
|
81
|
+
workflowRunId: run.id,
|
|
82
|
+
workflowRunStepId: step.id,
|
|
83
|
+
mode,
|
|
84
|
+
...(mode === "time" ? { wakeUpAt: future } : { eventName: "saved.signal", expiresAt: future }),
|
|
85
|
+
});
|
|
86
|
+
const payload = mode === "event" && status === "fired" ? { signal: "saved", ok: true } : null;
|
|
87
|
+
const resolution = await resolveWaitState(wait.id, { status, firedPayload: payload });
|
|
88
|
+
expect(resolution.updated).toBe(true);
|
|
89
|
+
return { run, step, wait, payload, resolution };
|
|
90
|
+
}
|
|
91
|
+
|
|
45
92
|
beforeAll(async () => {
|
|
93
|
+
// Use a file-backed DB so restart cases retain their committed wait resolutions.
|
|
94
|
+
savedTemplate = templateGlobals.__testMigrationTemplate;
|
|
95
|
+
templateGlobals.__testMigrationTemplate = undefined;
|
|
46
96
|
try {
|
|
47
97
|
await unlink(TEST_DB_PATH);
|
|
48
98
|
} catch {
|
|
@@ -60,12 +110,76 @@ afterAll(async () => {
|
|
|
60
110
|
}
|
|
61
111
|
}
|
|
62
112
|
closeDb();
|
|
113
|
+
templateGlobals.__testMigrationTemplate = savedTemplate;
|
|
63
114
|
for (const suffix of ["", "-wal", "-shm"]) {
|
|
64
115
|
await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => {});
|
|
65
116
|
}
|
|
66
117
|
});
|
|
67
118
|
|
|
68
119
|
describe("WaitExecutor — recovery on startup", () => {
|
|
120
|
+
test.each([
|
|
121
|
+
["time", "fired", "default"],
|
|
122
|
+
["event", "fired", "event"],
|
|
123
|
+
["event", "timeout", "timeout"],
|
|
124
|
+
] as const)("recovers a persisted %s/%s resolution after restart via '%s' exactly once", async (mode, status, nextPort) => {
|
|
125
|
+
const registry = createExecutorRegistry(deps);
|
|
126
|
+
const { run, step, wait, payload, resolution } = await makeResolvedWait(mode, status, nextPort);
|
|
127
|
+
|
|
128
|
+
// Reopen after the resolution commits, before the waiting step is checkpointed.
|
|
129
|
+
closeDb();
|
|
130
|
+
initDb(TEST_DB_PATH);
|
|
131
|
+
expect((await getWorkflowRunStep(step.id))?.status).toBe("waiting");
|
|
132
|
+
expect((await getWaitStateByStepId(step.id))?.status).toBe(status);
|
|
133
|
+
await Promise.all([recoverIncompleteRuns(registry), recoverIncompleteRuns(registry)]);
|
|
134
|
+
|
|
135
|
+
const recoveredStep = await getWorkflowRunStep(step.id);
|
|
136
|
+
expect(recoveredStep?.status).toBe("completed");
|
|
137
|
+
expect(recoveredStep?.nextPort).toBe(nextPort);
|
|
138
|
+
expect(recoveredStep?.output).toEqual({
|
|
139
|
+
waitId: wait.id,
|
|
140
|
+
mode,
|
|
141
|
+
firedAt: resolution.row!.resolvedAt,
|
|
142
|
+
payload,
|
|
143
|
+
});
|
|
144
|
+
expect(await getWaitStateByStepId(step.id)).toEqual(resolution.row);
|
|
145
|
+
expect((await getWorkflowRun(run.id))?.status).toBe("completed");
|
|
146
|
+
const doneSteps = (await getWorkflowRunStepsByRunId(run.id)).filter((s) => s.nodeId === "done");
|
|
147
|
+
expect(doneSteps).toHaveLength(1);
|
|
148
|
+
expect(doneSteps[0]?.status).toBe("completed");
|
|
149
|
+
|
|
150
|
+
await recoverIncompleteRuns(registry);
|
|
151
|
+
expect((await getWorkflowRunStepsByRunId(run.id)).filter((s) => s.nodeId === "done")).toEqual(
|
|
152
|
+
doneSteps,
|
|
153
|
+
);
|
|
154
|
+
expect((await getWorkflowRun(run.id))?.status).toBe("completed");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test.each([
|
|
158
|
+
"completed",
|
|
159
|
+
"failed",
|
|
160
|
+
"cancelled",
|
|
161
|
+
] as const)("does not reopen a %s run that becomes terminal during wait recovery", async (status) => {
|
|
162
|
+
const registry = createExecutorRegistry(deps);
|
|
163
|
+
const { run, step } = await makeResolvedWait("event", "fired", "event");
|
|
164
|
+
const getWorkflow = db.getWorkflow;
|
|
165
|
+
const snapshot = spyOn(db, "getWorkflow").mockImplementationOnce(async (id) => {
|
|
166
|
+
const workflow = await getWorkflow(id);
|
|
167
|
+
expect((await getWorkflowRun(run.id))?.status).toBe("waiting");
|
|
168
|
+
// A sibling can fail the run after the resume read, leaving this step waiting.
|
|
169
|
+
await updateWorkflowRun(run.id, { status });
|
|
170
|
+
return workflow;
|
|
171
|
+
});
|
|
172
|
+
try {
|
|
173
|
+
await recoverIncompleteRuns(registry);
|
|
174
|
+
expect(snapshot).toHaveBeenCalledTimes(1);
|
|
175
|
+
expect((await getWorkflowRun(run.id))?.status).toBe(status);
|
|
176
|
+
expect((await getWorkflowRunStep(step.id))?.status).toBe("waiting");
|
|
177
|
+
expect(await getWorkflowRunStepsByRunId(run.id)).toHaveLength(1);
|
|
178
|
+
} finally {
|
|
179
|
+
snapshot.mockRestore();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
69
183
|
test("server died while waiting: overdue time-mode wait recovers via 'default' port", async () => {
|
|
70
184
|
const registry = createExecutorRegistry(deps);
|
|
71
185
|
|
|
@@ -170,9 +284,7 @@ describe("WaitExecutor — recovery on startup", () => {
|
|
|
170
284
|
const doneCount1 = stepsAfter1.filter((s) => s.nodeId === "done").length;
|
|
171
285
|
expect(doneCount1).toBe(1);
|
|
172
286
|
|
|
173
|
-
//
|
|
174
|
-
// recovery query). resumeWaitState's atomic update returns updated=false,
|
|
175
|
-
// so the step shouldn't re-run.
|
|
287
|
+
// Completed runs and steps are no longer eligible for recovery.
|
|
176
288
|
await recoverIncompleteRuns(registry);
|
|
177
289
|
const stepsAfter2 = await getWorkflowRunStepsByRunId(run.id);
|
|
178
290
|
expect(stepsAfter2.filter((s) => s.nodeId === "done").length).toBe(1);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { runAppAction } from "../apps/action-runner";
|
|
4
|
+
import { AppNameSchema } from "../apps/definition";
|
|
5
|
+
import { appDefinitionNeedsRepair, getApp } from "../apps/store";
|
|
6
|
+
import { getAgentById } from "../be/db";
|
|
7
|
+
import { can } from "../rbac";
|
|
8
|
+
import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "./utils";
|
|
9
|
+
|
|
10
|
+
export function registerAppActionRunTool(server: McpServer) {
|
|
11
|
+
createToolRegistrar(server)(
|
|
12
|
+
"app-action-run",
|
|
13
|
+
{
|
|
14
|
+
title: "Run an app action",
|
|
15
|
+
description:
|
|
16
|
+
"Run a declared app action as an agent. Scripts and extensions cannot invoke this tool.",
|
|
17
|
+
// An action runs a script, a sync, or a task: it can write rows and reach external sources.
|
|
18
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
19
|
+
rbac: { permission: "app.use" },
|
|
20
|
+
inputSchema: z.object({
|
|
21
|
+
appId: z.string().min(1),
|
|
22
|
+
action: AppNameSchema,
|
|
23
|
+
input: z.record(z.string(), z.unknown()).optional(),
|
|
24
|
+
}),
|
|
25
|
+
outputSchema: swarmToolOutputSchema({
|
|
26
|
+
ok: z.boolean().optional(),
|
|
27
|
+
result: z.unknown().optional(),
|
|
28
|
+
stdout: z.string().optional(),
|
|
29
|
+
error: z.string().optional(),
|
|
30
|
+
durationMs: z.number().optional(),
|
|
31
|
+
taskId: z.string().optional(),
|
|
32
|
+
status: z.string().optional(),
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
async ({ appId, action, input }, info) => {
|
|
36
|
+
if (info.callOrigin === "script-sdk" || info.callOrigin === "extension")
|
|
37
|
+
return toolErr("Scripts and extensions cannot run app actions.");
|
|
38
|
+
if (!info.agentId) return toolErr('Agent ID not found. Set the "X-Agent-ID" header.');
|
|
39
|
+
const agent = await getAgentById(info.agentId);
|
|
40
|
+
const decision = can({
|
|
41
|
+
principal: { kind: "agent", agentId: info.agentId, isLead: agent?.isLead ?? false },
|
|
42
|
+
verb: "app.use",
|
|
43
|
+
resource: { kind: "app", appId },
|
|
44
|
+
source: "mcp",
|
|
45
|
+
});
|
|
46
|
+
if (!decision.allow) return toolErr(decision.reason);
|
|
47
|
+
const app = await getApp(appId);
|
|
48
|
+
if (!app) return toolErr("App not found.");
|
|
49
|
+
if (appDefinitionNeedsRepair(app))
|
|
50
|
+
return toolErr("App definition needs repair.", { data: { issues: app.definitionError } });
|
|
51
|
+
const output = await runAppAction({
|
|
52
|
+
app,
|
|
53
|
+
name: action,
|
|
54
|
+
input,
|
|
55
|
+
actor: `agent:${info.agentId}`,
|
|
56
|
+
caller: { kind: "agent", id: info.agentId, ...(agent?.name ? { name: agent.name } : {}) },
|
|
57
|
+
});
|
|
58
|
+
if (output.status !== 200)
|
|
59
|
+
return toolErr(output.body.error ?? "Action failed.", { data: output.body });
|
|
60
|
+
return toolOk("Action invoked.", { data: output.body });
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
}
|