@desplega.ai/agent-swarm 2.5.2 → 2.5.4
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-csh0zz3b.js → acp-adapter-ek38cwsv.js} +5 -5
- package/dist/{actions-6xx079d1.js → actions-92mepq03.js} +19 -19
- package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-9gaxg015.js} +4 -4
- package/dist/{app-ft6r6v23.js → app-ctn8gsgx.js} +3 -3
- package/dist/{assistant-vt2cyfzd.js → assistant-zyr5kz47.js} +21 -21
- package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-dwzfttdr.js} +1 -1
- package/dist/{boot-reembed-r3e808ky.js → boot-reembed-w75maga2.js} +1 -1
- package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
- package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-x28v3fhb.js} +4 -4
- package/dist/{claude-adapter-s7j47k06.js → claude-adapter-f0afqnne.js} +5 -5
- package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-x81a7r65.js} +3 -3
- package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-bqzwjd9x.js} +2 -2
- package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
- package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
- package/dist/{cli-4qkg9968.js → cli-19202kew.js} +4 -4
- package/dist/{cli-cyfgxnvb.js → cli-1r5ad7j5.js} +19 -17
- package/dist/{cli-jmcjbjkt.js → cli-25n3mv5d.js} +9 -7
- package/dist/cli-2655k16q.js +140 -0
- package/dist/{cli-wadzapwb.js → cli-277c31dw.js} +1 -1
- package/dist/{cli-n6kmqngw.js → cli-4b2z6n0m.js} +29 -7
- package/dist/{cli-7ccf7akh.js → cli-5bnsc0e1.js} +9 -7
- package/dist/{cli-qrjv7t18.js → cli-621ce7kw.js} +1 -1
- package/dist/{cli-xzge46sj.js → cli-64mh9mf3.js} +20 -6
- package/dist/{cli-b6tnjdvx.js → cli-6a1xg1p7.js} +6 -2
- package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
- package/dist/{cli-e91vshb1.js → cli-7p4jxgtt.js} +5 -5
- package/dist/{cli-f3pfn4x3.js → cli-9j129brq.js} +9 -3
- package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
- package/dist/{cli-15xd0j7w.js → cli-abs8ndtr.js} +12 -12
- package/dist/{cli-h5d22pfs.js → cli-asq5ydm4.js} +219 -309
- package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
- package/dist/{cli-0vgy5wfw.js → cli-bvpgr9ny.js} +2 -2
- package/dist/{cli-3e9vs93g.js → cli-c61mbdds.js} +4 -4
- package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
- package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
- package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
- package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
- package/dist/{cli-b63d08rs.js → cli-eftzyh9y.js} +1 -1
- package/dist/{cli-fgn7yg5r.js → cli-ethgy7ha.js} +1 -1
- package/dist/{cli-86zpfje5.js → cli-ff1tm9n9.js} +1 -1
- package/dist/{cli-cf63dnxj.js → cli-g9vd9ece.js} +37 -12
- package/dist/{cli-j3dwd4kz.js → cli-gbfxp48k.js} +770 -126
- package/dist/{cli-n2918sed.js → cli-gn5af0x5.js} +21 -21
- package/dist/{cli-d172svcw.js → cli-gwhjmh8g.js} +4 -4
- package/dist/{cli-stxdrqs2.js → cli-hh5we352.js} +24 -5
- package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
- package/dist/{cli-csh8fnbg.js → cli-jb30dzvk.js} +473 -58
- package/dist/{cli-vaxw3bg0.js → cli-jbnn4vxb.js} +3 -3
- package/dist/{cli-ha22h0eg.js → cli-k4tjsdx9.js} +45 -40
- package/dist/{cli-f55b6ky0.js → cli-m4ecdxvz.js} +40 -13
- package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
- package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
- package/dist/{cli-3qv8naq8.js → cli-rr7zx19j.js} +1591 -353
- package/dist/{cli-b1tv22wr.js → cli-s682zgqz.js} +1 -1
- package/dist/{cli-bpbcpd8b.js → cli-sbwsxvtd.js} +1 -1
- package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
- package/dist/{cli-6y43v79h.js → cli-tsma7p8v.js} +1 -1
- package/dist/{cli-edtm741g.js → cli-wghjkr0e.js} +5 -5
- package/dist/{cli-aaqnqpyw.js → cli-wx2nj19m.js} +30 -11
- package/dist/{cli-y3dzwyy3.js → cli-x0f2v69c.js} +180 -60
- package/dist/{cli-kcnwn85g.js → cli-xtrz6bzs.js} +7 -7
- package/dist/{cli-14v1sxxy.js → cli-xzfq4at3.js} +7 -7
- package/dist/cli.js +20 -20
- package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-wch12q99.js} +3 -3
- package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
- package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-41pfcr95.js} +2 -2
- package/dist/{commands-1cnvseqx.js → commands-9849dkj6.js} +2 -2
- package/dist/{controller-kc7s9xhn.js → controller-ja0sd95p.js} +29 -3
- package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-xp05dj6x.js} +20 -20
- package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-623qxqew.js} +1 -2
- package/dist/{db-4vq88jkc.js → db-47vj40dy.js} +3 -3
- package/dist/{db-v5ytg5pw.js → db-w759qmx0.js} +2 -2
- package/dist/{dispatcher-trtcrqae.js → dispatcher-7b8e4z2j.js} +17 -17
- package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
- package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-2ezw3xea.js} +2 -3
- package/dist/{external-channel-n9bymc2n.js → external-channel-e2s028da.js} +2 -2
- package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-ys7gaqee.js} +6 -6
- package/dist/{handlers-ptsraqa8.js → handlers-wy1dr6ja.js} +20 -20
- package/dist/{heartbeat-60qtxtjf.js → heartbeat-w3mntghj.js} +20 -20
- package/dist/{hook-rbktcstg.js → hook-3jjywnpb.js} +9 -3
- package/dist/{hook-ngtvh729.js → hook-g7kvv6x9.js} +3 -2
- package/dist/{http-z8xy72gp.js → http-tkp7d4kb.js} +2984 -2483
- package/dist/{index-r70q0xjx.js → index-1ef1myq2.js} +17 -17
- package/dist/{index-a9nsavhx.js → index-3n8m9ea2.js} +1 -1
- package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
- package/dist/{index-s193j5fh.js → index-nmm79qgr.js} +20 -20
- package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
- package/dist/{index-06qkmcx6.js → index-q7m26kvs.js} +33 -23
- package/dist/{index-1cscynkb.js → index-xdaahthb.js} +17 -17
- package/dist/{keepalive-a9srcphj.js → keepalive-v4ta8wgg.js} +3 -3
- package/dist/{lead-3devneqy.js → lead-sawnyqxk.js} +35 -35
- package/dist/{maintenance-0er16h2y.js → maintenance-84n1ex21.js} +1 -1
- package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
- package/dist/{onboard-wqywz5py.js → onboard-sfmpe4np.js} +2 -2
- package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-jmpst4q4.js} +4 -4
- package/dist/{otel-impl-vk32s884.js → otel-impl-v6cc0zve.js} +1 -1
- package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
- package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-fsk1e1zc.js} +3 -3
- package/dist/{rbac-roles-452665y0.js → rbac-roles-prmkdnkw.js} +2 -2
- package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-zxmxqa39.js} +1 -1
- package/dist/{render-v2-0ttsk2j4.js → render-v2-pjwxvnkw.js} +3 -3
- package/dist/{resume-a26kvr41.js → resume-frdtgfa0.js} +9 -5
- package/dist/{session-access-dgh9nkwk.js → session-access-fpez4y5q.js} +4 -4
- package/dist/{setup-9vc9qahm.js → setup-mq6m3m8q.js} +1 -1
- package/dist/{work-objects-vn8nkxwn.js → work-objects-kfahhjsq.js} +20 -20
- package/dist/{worker-1x1h8vnm.js → worker-v5e5rxj3.js} +35 -35
- package/openapi.json +3078 -2424
- package/package.json +7 -7
- package/plugin/commands/create-pr.md +2 -2
- package/plugin/pi-skills/create-pr/SKILL.md +2 -2
- package/src/be/approval-config-question.ts +389 -0
- package/src/be/automation-preflight.ts +37 -3
- package/src/be/db/approvals.ts +48 -0
- package/src/be/db/session-tree.ts +73 -15
- package/src/be/db/skill-search.ts +87 -0
- package/src/be/db/tasks/read.ts +29 -2
- package/src/be/db/tasks/write.ts +15 -5
- package/src/be/db/workflow-idempotency.ts +183 -0
- package/src/be/db/workflow-runs.ts +366 -8
- package/src/be/db.ts +92 -22
- package/src/be/migrations/239_skills_fts.sql +40 -0
- package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
- package/src/be/migrations/241_skills_meta_fts.sql +39 -0
- package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
- package/src/be/sealed-json.ts +1 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
- package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
- package/src/be/seed-skills/bundled-files.generated.json +1 -1
- package/src/be/sql-connections.ts +7 -1
- package/src/be/swarm-config-guard.ts +25 -0
- package/src/be/users.ts +9 -0
- package/src/commands/controller.ts +33 -0
- package/src/commands/runner.ts +6 -2
- package/src/extensions/apply-task-create.ts +57 -11
- package/src/extensions/catalog.generated.json +4 -4
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/extensions/contract.ts +25 -0
- package/src/extensions/dispatcher.ts +4 -0
- package/src/extensions/post-bridge.ts +8 -0
- package/src/hooks/context-bound-note.ts +196 -0
- package/src/hooks/hook.ts +12 -0
- package/src/http/approval-requests.ts +209 -38
- package/src/http/config.ts +70 -53
- package/src/http/idempotency-key.ts +117 -0
- package/src/http/sessions.ts +27 -2
- package/src/http/workflows.ts +129 -24
- package/src/integrations/chat-sdk/inbound.ts +5 -4
- package/src/prompts/context-bound-note.ts +48 -0
- package/src/prompts/session-templates.ts +25 -1
- package/src/providers/amp-adapter.ts +30 -8
- package/src/providers/claude-adapter.ts +29 -4
- package/src/providers/claude-managed-adapter.ts +26 -0
- package/src/providers/claude-sdk-session.ts +10 -0
- package/src/providers/claude-session-events.ts +13 -0
- package/src/providers/codex-adapter.ts +12 -0
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/opencode-adapter.ts +18 -0
- package/src/providers/pi-mono-adapter.ts +35 -0
- package/src/runtime-providers/docker.ts +6 -2
- package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
- package/src/tests/amp-adapter.test.ts +97 -0
- package/src/tests/approval-config-question.test.ts +702 -0
- package/src/tests/approval-requests.test.ts +10 -7
- package/src/tests/approval-sweeps.test.ts +12 -6
- package/src/tests/base-prompt.test.ts +1 -1
- package/src/tests/chat-sdk-inbound.test.ts +20 -0
- package/src/tests/claude-hook-output.test.ts +79 -1
- package/src/tests/claude-managed-adapter.test.ts +275 -0
- package/src/tests/codex-adapter.test.ts +163 -0
- package/src/tests/context-bound-note.test.ts +269 -0
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
- package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
- package/src/tests/extensions-pre-task.test.ts +273 -0
- package/src/tests/fetch-resolved-env.test.ts +17 -0
- package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
- package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
- package/src/tests/fixtures/fake-amp.ts +22 -8
- package/src/tests/fixtures/held-template-render.ts +42 -0
- package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
- package/src/tests/list-endpoint-slimming.test.ts +33 -0
- package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
- package/src/tests/openapi-route-headers.test.ts +2 -0
- package/src/tests/opencode-adapter.test.ts +134 -0
- package/src/tests/pi-mono-adapter.test.ts +105 -0
- package/src/tests/prompt-template-session.test.ts +4 -3
- package/src/tests/runner-push-delivery.test.ts +25 -0
- package/src/tests/runtime-controller-liveness.test.ts +133 -0
- package/src/tests/runtime-providers-docker.test.ts +2 -0
- package/src/tests/script-connections.test.ts +9 -0
- package/src/tests/script-run-config-scope.test.ts +91 -3
- package/src/tests/session-logs.test.ts +70 -0
- package/src/tests/sessions-tree.test.ts +205 -1
- package/src/tests/skill-search-long-content.test.ts +193 -0
- package/src/tests/skill-search.test.ts +173 -22
- package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
- package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
- package/src/tests/workflow-idempotency.test.ts +915 -0
- package/src/tests/workflow-sub-workflow.test.ts +538 -1
- package/src/tools/request-human-input.ts +27 -5
- package/src/tools/workflows/cancel-workflow-run.ts +1 -1
- package/src/types.ts +7 -0
- package/src/utils/context-window.ts +8 -6
- package/src/workflows/approval-cancel.ts +15 -13
- package/src/workflows/approval-notifications.ts +15 -4
- package/src/workflows/checkpoint.ts +72 -35
- package/src/workflows/engine.ts +262 -93
- package/src/workflows/executors/base.ts +6 -0
- package/src/workflows/executors/foreach.ts +2 -2
- package/src/workflows/executors/human-in-the-loop.ts +25 -1
- package/src/workflows/executors/sub-workflow.ts +45 -16
- package/src/workflows/foreach-driver.ts +10 -3
- package/src/workflows/foreach-join.ts +29 -6
- package/src/workflows/recovery.ts +68 -1
- package/src/workflows/resume.ts +228 -77
- package/src/workflows/retry-poller.ts +74 -7
- package/src/workflows/task-step-routing.ts +29 -7
- package/templates/ai-toolbox.manifest.json +8 -8
- package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
- package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
- package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
- package/templates/extensions/comms-adherence-gate/README.md +38 -13
- package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
- package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
- package/templates/skills/agent-browser/SKILL.md +4 -4
- package/templates/skills/agent-browser/content.md +4 -4
- package/templates/skills/code-quality/config.json +1 -1
- package/templates/skills/code-quality/content.md +2 -2
- package/templates/skills/delegate-work/content.md +29 -25
- package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
- package/templates/skills/qa/content.md +2 -2
- package/templates/skills/researching/content.md +2 -0
- package/templates/skills/turso-interaction/content.md +1 -1
- package/templates/skills/work-on-task/SKILL.md +1 -1
- package/templates/skills/work-on-task/content.md +1 -1
- package/templates/skills/workflow-iterate/content.md +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desplega.ai/agent-swarm",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.4",
|
|
4
4
|
"description": "agent-swarm.dev — an open-source operating system for AI work: a lead agent breaks goals into tasks, routes them to specialized workers such as Claude Code or Codex, runs each worker in an isolated container, and preserves shared memory, tools, schedules, and review gates so delegated work compounds across sessions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent-swarm.dev",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"config": {
|
|
55
55
|
"name": "agent-swarm"
|
|
56
56
|
},
|
|
57
|
-
"packageManager": "bun@1.4.
|
|
57
|
+
"packageManager": "bun@1.4.4",
|
|
58
58
|
"workspaces": [
|
|
59
59
|
"apps/ui",
|
|
60
60
|
"apps/templates-ui",
|
|
@@ -180,8 +180,8 @@
|
|
|
180
180
|
"@desplega/model-catalog": "workspace:*",
|
|
181
181
|
"@desplega/model-routing": "workspace:*",
|
|
182
182
|
"@faker-js/faker": "^10.4.0",
|
|
183
|
-
"@opencode/plugin": "2.1.
|
|
184
|
-
"@opencode/sdk": "2.1.
|
|
183
|
+
"@opencode/plugin": "2.1.4",
|
|
184
|
+
"@opencode/sdk": "2.1.4",
|
|
185
185
|
"@oxlint/plugins": "1.78.0",
|
|
186
186
|
"@types/bun": "latest",
|
|
187
187
|
"dependency-cruiser": "^18.0.0",
|
|
@@ -199,10 +199,10 @@
|
|
|
199
199
|
"bun": ">=1.3.12"
|
|
200
200
|
},
|
|
201
201
|
"dependencies": {
|
|
202
|
-
"@agentclientprotocol/sdk": "2.0.
|
|
202
|
+
"@agentclientprotocol/sdk": "2.0.2",
|
|
203
203
|
"@ai-sdk/openai": "^3.0.41",
|
|
204
|
-
"@anthropic-ai/claude-agent-sdk": "0.3.
|
|
205
|
-
"@anthropic-ai/sdk": "^1.
|
|
204
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.310",
|
|
205
|
+
"@anthropic-ai/sdk": "^1.3.0",
|
|
206
206
|
"@asteasolutions/zod-to-openapi": "^8.0.0",
|
|
207
207
|
"@aws-sdk/client-bedrock": "3.1048.0",
|
|
208
208
|
"@chat-adapter/discord": "4.41.1",
|
|
@@ -31,8 +31,8 @@ You should be working in a repository cloned to `/workspace/personal/<repo-name>
|
|
|
31
31
|
- **Show the change, do not narrate it.** Aim for about 250 prose words; code blocks, mermaid, tables and `<details>` do not count. No file-by-file changelog: that belongs in your task output.
|
|
32
32
|
- **Review map**: build it from `git diff --stat <base>...HEAD`. One row per area, biggest risk first, with `(+N/-M)`, a depth (🔍 deep, 👀 skim, ⏭ skip for tests and generated files) and what could break.
|
|
33
33
|
- **Change outline**: 1 or 2 views of the shape of the change. Pseudocode with `+`/`-` lines for logic, mermaid for flow, a tree with paths on the nodes for structure.
|
|
34
|
-
- **Swarm provenance** (when you run in the swarm): fill it from your task context. Link the task (`$APP_URL/tasks/<taskId>`) and its tree (`$APP_URL/sessions/<rootTaskId>`), the Slack permalink of the ask if the task came from Slack, any workflow run, and the plan or research as
|
|
35
|
-
- **Media**: images and mp4s as GitHub user attachments (`github-attach` skill), docs as `agent-fs share-create`
|
|
34
|
+
- **Swarm provenance** (when you run in the swarm): fill it from your task context. Link the task (`$APP_URL/tasks/<taskId>`) and its tree (`$APP_URL/sessions/<rootTaskId>`), the Slack permalink of the ask if the task came from Slack, any workflow run, and the plan or research as its agent-fs live app link (`${AGENT_FS_LIVE_URL}/file/~/<org>/<drive>/<path>`). These auth-gated links go in this section only.
|
|
35
|
+
- **Media**: images and mp4s as GitHub user attachments (`github-attach` skill), docs as the agent-fs live app link under Swarm provenance (an `agent-fs share-create` link only when the requester asks for one or a reader has no agent-fs login). Never a presigned agent-fs URL for docs or videos.
|
|
36
36
|
6. **Create the PR/MR** using `gh pr create` or `glab mr create`. `gh pr create --body` does not apply the repo template, so write the filled description to a file and pass it with `--body-file <file>`.
|
|
37
37
|
7. **Check CI status** — After creating the PR, wait ~30 seconds, then check CI with `gh pr checks <pr-number>` (GitHub) or `glab mr view --json pipelines` (GitLab). If any check is failing, investigate the failure, fix it, push the fix, and re-check. Repeat until CI is green.
|
|
38
38
|
8. **Report** the PR/MR URL and CI status.
|
|
@@ -31,8 +31,8 @@ You should be working in a repository cloned to `/workspace/personal/<repo-name>
|
|
|
31
31
|
- **Show the change, do not narrate it.** Aim for about 250 prose words; code blocks, mermaid, tables and `<details>` do not count. No file-by-file changelog: that belongs in your task output.
|
|
32
32
|
- **Review map**: build it from `git diff --stat <base>...HEAD`. One row per area, biggest risk first, with `(+N/-M)`, a depth (🔍 deep, 👀 skim, ⏭ skip for tests and generated files) and what could break.
|
|
33
33
|
- **Change outline**: 1 or 2 views of the shape of the change. Pseudocode with `+`/`-` lines for logic, mermaid for flow, a tree with paths on the nodes for structure.
|
|
34
|
-
- **Swarm provenance** (when you run in the swarm): fill it from your task context. Link the task (`$APP_URL/tasks/<taskId>`) and its tree (`$APP_URL/sessions/<rootTaskId>`), the Slack permalink of the ask if the task came from Slack, any workflow run, and the plan or research as
|
|
35
|
-
- **Media**: images and mp4s as GitHub user attachments (`github-attach` skill), docs as `agent-fs share-create`
|
|
34
|
+
- **Swarm provenance** (when you run in the swarm): fill it from your task context. Link the task (`$APP_URL/tasks/<taskId>`) and its tree (`$APP_URL/sessions/<rootTaskId>`), the Slack permalink of the ask if the task came from Slack, any workflow run, and the plan or research as its agent-fs live app link (`${AGENT_FS_LIVE_URL}/file/~/<org>/<drive>/<path>`). These auth-gated links go in this section only.
|
|
35
|
+
- **Media**: images and mp4s as GitHub user attachments (`github-attach` skill), docs as the agent-fs live app link under Swarm provenance (an `agent-fs share-create` link only when the requester asks for one or a reader has no agent-fs login). Never a presigned agent-fs URL for docs or videos.
|
|
36
36
|
6. **Create the PR/MR** using `gh pr create` or `glab mr create`. `gh pr create --body` does not apply the repo template, so write the filled description to a file and pass it with `--body-file <file>`.
|
|
37
37
|
7. **Check CI status** — After creating the PR, wait ~30 seconds, then check CI with `gh pr checks <pr-number>` (GitHub) or `glab mr view --json pipelines` (GitLab). If any check is failing, investigate the failure, fix it, push the fix, and re-check. Repeat until CI is green.
|
|
38
38
|
8. **Report** the PR/MR URL and CI status.
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HITL `config` questions: ask a person to set one swarm config key (a secret
|
|
3
|
+
* or a plain value) from the dashboard. The answer is written to
|
|
4
|
+
* `swarm_config` by the respond handler, and only a {@link ConfigResult} is
|
|
5
|
+
* ever stored on the request or sent onward, so a secret never reaches the
|
|
6
|
+
* agent, the workflow run, the follow-up task or any notification.
|
|
7
|
+
*/
|
|
8
|
+
import * as z from "zod";
|
|
9
|
+
import { getDbClient } from "./db";
|
|
10
|
+
import { upsertSwarmConfigWithPolicyMirror } from "./multi-runtime";
|
|
11
|
+
import {
|
|
12
|
+
internalConfigKeyError,
|
|
13
|
+
isApiOnlyConfigKey,
|
|
14
|
+
isInternalConfigKey,
|
|
15
|
+
isReservedConfigKey,
|
|
16
|
+
reservedKeyError,
|
|
17
|
+
validateConfigEntry,
|
|
18
|
+
} from "./swarm-config-guard";
|
|
19
|
+
|
|
20
|
+
export const ConfigQuestionSettingsSchema = z.object({
|
|
21
|
+
key: z
|
|
22
|
+
.string()
|
|
23
|
+
.min(1)
|
|
24
|
+
.max(255)
|
|
25
|
+
.regex(/^\S+$/, "must not contain whitespace")
|
|
26
|
+
.describe("The swarm config key to set, e.g. BITBUCKET_TOKEN"),
|
|
27
|
+
scope: z.enum(["global", "agent", "repo"]).describe("Config scope the value is written to"),
|
|
28
|
+
scopeId: z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1)
|
|
31
|
+
.optional()
|
|
32
|
+
.describe(
|
|
33
|
+
"Agent or repo id for agent/repo scope. From request-human-input, agent scope defaults to the calling agent; pass it explicitly when asking on behalf of another agent.",
|
|
34
|
+
),
|
|
35
|
+
isSecret: z
|
|
36
|
+
.boolean()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe(
|
|
39
|
+
"Store encrypted and never show the value (default true). An existing secret row always stays secret.",
|
|
40
|
+
),
|
|
41
|
+
helpUrl: z
|
|
42
|
+
.string()
|
|
43
|
+
.url()
|
|
44
|
+
.refine((url) => /^https?:\/\//i.test(url), "must be an http(s) URL")
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("Link shown next to the field, e.g. where to create the token"),
|
|
47
|
+
options: z
|
|
48
|
+
.array(
|
|
49
|
+
z.object({
|
|
50
|
+
value: z.string(),
|
|
51
|
+
label: z.string(),
|
|
52
|
+
description: z.string().optional(),
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
.optional()
|
|
56
|
+
.describe("Allowed values for a non-secret key, rendered as a choice"),
|
|
57
|
+
defaultValue: z.string().optional().describe("Prefilled value for a non-secret key"),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export type ConfigQuestionSettings = z.infer<typeof ConfigQuestionSettingsSchema>;
|
|
61
|
+
|
|
62
|
+
/** Settings after create-time normalization: `isSecret` is always resolved. */
|
|
63
|
+
export type NormalizedConfigSettings = ConfigQuestionSettings & { isSecret: boolean };
|
|
64
|
+
|
|
65
|
+
/** What the dashboard sends for a config question (D6). */
|
|
66
|
+
export const ConfigAnswerSchema = z.discriminatedUnion("action", [
|
|
67
|
+
z.object({ action: z.literal("set"), value: z.string() }),
|
|
68
|
+
z.object({ action: z.literal("keep") }),
|
|
69
|
+
z.object({ action: z.literal("skip") }),
|
|
70
|
+
]);
|
|
71
|
+
export type ConfigAnswer = z.infer<typeof ConfigAnswerSchema>;
|
|
72
|
+
|
|
73
|
+
/** The only form of a config answer that is ever stored or sent (D7). */
|
|
74
|
+
export const ConfigResultSchema = z.object({
|
|
75
|
+
key: z.string(),
|
|
76
|
+
scope: z.enum(["global", "agent", "repo"]),
|
|
77
|
+
scopeId: z.string().nullable(),
|
|
78
|
+
isSecret: z.boolean(),
|
|
79
|
+
status: z.enum(["set", "kept", "skipped"]),
|
|
80
|
+
updatedAt: z.string().nullable(),
|
|
81
|
+
value: z.string().optional(),
|
|
82
|
+
});
|
|
83
|
+
export type ConfigResult = z.infer<typeof ConfigResultSchema>;
|
|
84
|
+
|
|
85
|
+
/** Live state of the key a config question targets (never persisted). */
|
|
86
|
+
export const ConfigStateSchema = z.object({
|
|
87
|
+
exists: z.boolean(),
|
|
88
|
+
isSecret: z.boolean(),
|
|
89
|
+
updatedAt: z.string().nullable(),
|
|
90
|
+
value: z.string().optional().describe("Current value; present only for non-secret rows"),
|
|
91
|
+
});
|
|
92
|
+
export type ConfigState = z.infer<typeof ConfigStateSchema>;
|
|
93
|
+
|
|
94
|
+
type ConfigQuestionInput = {
|
|
95
|
+
id: string;
|
|
96
|
+
type: string;
|
|
97
|
+
label?: string;
|
|
98
|
+
required?: boolean;
|
|
99
|
+
config?: ConfigQuestionSettings;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type NormalizedConfigQuestion<Q extends ConfigQuestionInput> = Omit<Q, "config"> & {
|
|
103
|
+
label: string;
|
|
104
|
+
required: boolean;
|
|
105
|
+
config: NormalizedConfigSettings;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type ApproversLike = { policy: unknown };
|
|
109
|
+
|
|
110
|
+
interface ConfigRowMeta {
|
|
111
|
+
isSecret: boolean;
|
|
112
|
+
updatedAt: string;
|
|
113
|
+
envPath: string | null;
|
|
114
|
+
description: string | null;
|
|
115
|
+
value?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The exact `(scope, scopeId, key)` row, without decrypting anything. */
|
|
119
|
+
async function readConfigRow(
|
|
120
|
+
settings: Pick<ConfigQuestionSettings, "scope" | "scopeId" | "key">,
|
|
121
|
+
): Promise<ConfigRowMeta | null> {
|
|
122
|
+
const scopeId = settings.scope === "global" ? null : (settings.scopeId ?? null);
|
|
123
|
+
const row = await getDbClient().get<{
|
|
124
|
+
isSecret: number;
|
|
125
|
+
lastUpdatedAt: string;
|
|
126
|
+
envPath: string | null;
|
|
127
|
+
description: string | null;
|
|
128
|
+
plainValue: string | null;
|
|
129
|
+
}>(
|
|
130
|
+
`SELECT isSecret, lastUpdatedAt, envPath, description,
|
|
131
|
+
CASE WHEN isSecret = 1 THEN NULL ELSE value END AS plainValue
|
|
132
|
+
FROM swarm_config
|
|
133
|
+
WHERE scope = ? AND ${scopeId === null ? "scopeId IS NULL" : "scopeId = ?"} AND key = ?`,
|
|
134
|
+
scopeId === null ? [settings.scope, settings.key] : [settings.scope, scopeId, settings.key],
|
|
135
|
+
);
|
|
136
|
+
if (!row) return null;
|
|
137
|
+
// API-only keys are never readable over HTTP, so they count as secret here.
|
|
138
|
+
const isSecret = row.isSecret === 1 || isApiOnlyConfigKey(settings.key);
|
|
139
|
+
return {
|
|
140
|
+
isSecret,
|
|
141
|
+
updatedAt: row.lastUpdatedAt,
|
|
142
|
+
envPath: row.envPath,
|
|
143
|
+
description: row.description,
|
|
144
|
+
...(isSecret || row.plainValue === null ? {} : { value: row.plainValue }),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function readConfigState(settings: ConfigQuestionSettings): Promise<ConfigState> {
|
|
149
|
+
const row = await readConfigRow(settings);
|
|
150
|
+
if (!row) return { exists: false, isSecret: settings.isSecret !== false, updatedAt: null };
|
|
151
|
+
const isSecret = row.isSecret || settings.isSecret !== false;
|
|
152
|
+
return {
|
|
153
|
+
exists: true,
|
|
154
|
+
isSecret,
|
|
155
|
+
updatedAt: row.updatedAt,
|
|
156
|
+
...(!isSecret && row.value !== undefined ? { value: row.value } : {}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create-time rules for one config question (D2, D3, D5). Defaults `label` to
|
|
162
|
+
* the key and `required` to true, resolves the effective `isSecret` against
|
|
163
|
+
* the live row, and rejects keys and shapes the config API would refuse.
|
|
164
|
+
*/
|
|
165
|
+
export async function normalizeConfigQuestion<Q extends ConfigQuestionInput>(
|
|
166
|
+
question: Q,
|
|
167
|
+
ctx: { approvers: ApproversLike; callerAgentId?: string },
|
|
168
|
+
): Promise<{ ok: true; question: NormalizedConfigQuestion<Q> } | { ok: false; error: string }> {
|
|
169
|
+
const fail = (reason: string) => ({
|
|
170
|
+
ok: false as const,
|
|
171
|
+
error: `config question "${question.id}": ${reason}`,
|
|
172
|
+
});
|
|
173
|
+
const parsed = ConfigQuestionSettingsSchema.safeParse(question.config);
|
|
174
|
+
if (!parsed.success) {
|
|
175
|
+
const issue = parsed.error.issues[0];
|
|
176
|
+
const path = issue?.path.length ? `config.${issue.path.map(String).join(".")}` : "config";
|
|
177
|
+
return fail(`${path}: ${issue?.message ?? "invalid"}`);
|
|
178
|
+
}
|
|
179
|
+
if (ctx.approvers.policy !== "any") {
|
|
180
|
+
return fail('a config question needs approvers.policy "any" (a config value has one author)');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const settings = { ...parsed.data };
|
|
184
|
+
if (settings.scope === "agent" && !settings.scopeId && ctx.callerAgentId) {
|
|
185
|
+
settings.scopeId = ctx.callerAgentId;
|
|
186
|
+
}
|
|
187
|
+
if (settings.scope === "global" && settings.scopeId) {
|
|
188
|
+
return fail("global scope must not have scopeId");
|
|
189
|
+
}
|
|
190
|
+
if (settings.scope !== "global" && !settings.scopeId) {
|
|
191
|
+
return fail(`${settings.scope} scope requires scopeId`);
|
|
192
|
+
}
|
|
193
|
+
if (isReservedConfigKey(settings.key)) return fail(reservedKeyError(settings.key).message);
|
|
194
|
+
if (isInternalConfigKey(settings.key)) return fail(internalConfigKeyError(settings.key).message);
|
|
195
|
+
|
|
196
|
+
const row = await readConfigRow(settings);
|
|
197
|
+
const isSecret =
|
|
198
|
+
settings.isSecret !== false || row?.isSecret === true || isApiOnlyConfigKey(settings.key);
|
|
199
|
+
if (isSecret && (settings.options || settings.defaultValue !== undefined)) {
|
|
200
|
+
return fail(
|
|
201
|
+
"options and defaultValue are only allowed when isSecret is false (and the key is not already stored as a secret)",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (settings.options?.length === 0) return fail("options must not be empty");
|
|
205
|
+
if (
|
|
206
|
+
settings.options &&
|
|
207
|
+
settings.defaultValue !== undefined &&
|
|
208
|
+
!settings.options.some((option) => option.value === settings.defaultValue)
|
|
209
|
+
) {
|
|
210
|
+
return fail("defaultValue must be one of options");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const label = question.label?.trim() ? question.label : settings.key;
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
question: {
|
|
217
|
+
...question,
|
|
218
|
+
label,
|
|
219
|
+
required: question.required ?? true,
|
|
220
|
+
config: { ...settings, isSecret },
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
type AnyQuestion = { id: string; type: string; label?: string; config?: ConfigQuestionSettings };
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Normalizes every config question in a list and checks that the others carry
|
|
229
|
+
* a label and no `config` block. Non-config questions pass through unchanged.
|
|
230
|
+
*/
|
|
231
|
+
export async function normalizeApprovalQuestions<Q extends AnyQuestion>(
|
|
232
|
+
questions: Q[],
|
|
233
|
+
ctx: { approvers: ApproversLike; callerAgentId?: string },
|
|
234
|
+
): Promise<
|
|
235
|
+
| { ok: true; questions: ((Q & { label: string }) | NormalizedConfigQuestion<Q>)[] }
|
|
236
|
+
| { ok: false; error: string }
|
|
237
|
+
> {
|
|
238
|
+
const out: ((Q & { label: string }) | NormalizedConfigQuestion<Q>)[] = [];
|
|
239
|
+
for (const question of questions) {
|
|
240
|
+
if (question.type !== "config") {
|
|
241
|
+
if (question.config !== undefined) {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
error: `question "${question.id}": the config block is only valid on type "config"`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (typeof question.label !== "string") {
|
|
248
|
+
return { ok: false, error: `question "${question.id}": label is required` };
|
|
249
|
+
}
|
|
250
|
+
out.push({ ...question, label: question.label });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const normalized = await normalizeConfigQuestion(question, ctx);
|
|
254
|
+
if (!normalized.ok) return normalized;
|
|
255
|
+
out.push(normalized.question);
|
|
256
|
+
}
|
|
257
|
+
return { ok: true, questions: out };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A stored config question, as read back from `approval_requests.questions`. */
|
|
261
|
+
export type StoredConfigQuestion = {
|
|
262
|
+
id: string;
|
|
263
|
+
type: "config";
|
|
264
|
+
label: string;
|
|
265
|
+
required?: boolean;
|
|
266
|
+
config: NormalizedConfigSettings;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export function isConfigQuestion(question: {
|
|
270
|
+
type: string;
|
|
271
|
+
config?: unknown;
|
|
272
|
+
}): question is StoredConfigQuestion {
|
|
273
|
+
return question.type === "config" && typeof question.config === "object" && !!question.config;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Validates one raw answer against D6 and the config API's rules. Errors name
|
|
278
|
+
* the question and the rule, never the value.
|
|
279
|
+
*/
|
|
280
|
+
export function parseConfigAnswer(
|
|
281
|
+
question: StoredConfigQuestion,
|
|
282
|
+
raw: unknown,
|
|
283
|
+
state: ConfigState,
|
|
284
|
+
): { ok: true; answer: ConfigAnswer } | { ok: false; error: string } {
|
|
285
|
+
const fail = (reason: string) => ({
|
|
286
|
+
ok: false as const,
|
|
287
|
+
error: `config question "${question.id}": ${reason}`,
|
|
288
|
+
});
|
|
289
|
+
const required = question.required !== false;
|
|
290
|
+
if (raw === undefined || raw === null) {
|
|
291
|
+
return required ? fail("an answer is required") : { ok: true, answer: { action: "skip" } };
|
|
292
|
+
}
|
|
293
|
+
const parsed = ConfigAnswerSchema.safeParse(raw);
|
|
294
|
+
if (!parsed.success) {
|
|
295
|
+
return fail(
|
|
296
|
+
'answer must be { action: "set", value } , { action: "keep" } or { action: "skip" }',
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const answer = parsed.data;
|
|
300
|
+
if (answer.action === "keep") {
|
|
301
|
+
return state.exists ? { ok: true, answer } : fail("cannot keep a key that is not set");
|
|
302
|
+
}
|
|
303
|
+
if (answer.action === "skip") {
|
|
304
|
+
return required ? fail("a required question cannot be skipped") : { ok: true, answer };
|
|
305
|
+
}
|
|
306
|
+
if (answer.value.trim().length === 0) return fail("value must not be empty");
|
|
307
|
+
const { config } = question;
|
|
308
|
+
const isSecret = config.isSecret || state.isSecret;
|
|
309
|
+
if (!isSecret && config.options && !config.options.some((o) => o.value === answer.value)) {
|
|
310
|
+
return fail("value must be one of options");
|
|
311
|
+
}
|
|
312
|
+
const error = validateConfigEntry({
|
|
313
|
+
scope: config.scope,
|
|
314
|
+
scopeId: config.scopeId ?? null,
|
|
315
|
+
key: config.key,
|
|
316
|
+
value: answer.value,
|
|
317
|
+
});
|
|
318
|
+
if (error) return fail(isSecret ? `value rejected by ${config.key} validation` : error);
|
|
319
|
+
return { ok: true, answer };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function result(
|
|
323
|
+
question: StoredConfigQuestion,
|
|
324
|
+
status: ConfigResult["status"],
|
|
325
|
+
isSecret: boolean,
|
|
326
|
+
updatedAt: string | null,
|
|
327
|
+
value?: string,
|
|
328
|
+
): ConfigResult {
|
|
329
|
+
const { key, scope, scopeId } = question.config;
|
|
330
|
+
return {
|
|
331
|
+
key,
|
|
332
|
+
scope,
|
|
333
|
+
scopeId: scope === "global" ? null : (scopeId ?? null),
|
|
334
|
+
isSecret,
|
|
335
|
+
status,
|
|
336
|
+
updatedAt,
|
|
337
|
+
...(!isSecret && value !== undefined ? { value } : {}),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function skippedConfigResult(question: StoredConfigQuestion): ConfigResult {
|
|
342
|
+
return result(question, "skipped", question.config.isSecret, null);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** `keep`: nothing is written. Null when the row disappeared since validation. */
|
|
346
|
+
export async function keptConfigResult(
|
|
347
|
+
question: StoredConfigQuestion,
|
|
348
|
+
): Promise<ConfigResult | null> {
|
|
349
|
+
const row = await readConfigRow(question.config);
|
|
350
|
+
if (!row) return null;
|
|
351
|
+
const isSecret = question.config.isSecret || row.isSecret;
|
|
352
|
+
return result(question, "kept", isSecret, row.updatedAt, row.value);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* `set`: writes the value inside the caller's transaction. Carries the row's
|
|
357
|
+
* `envPath` and `description` forward (the upsert would null them) and keeps
|
|
358
|
+
* an existing secret secret. The caller runs the HTTP side effects after
|
|
359
|
+
* commit.
|
|
360
|
+
*/
|
|
361
|
+
export async function applyConfigAnswer(
|
|
362
|
+
question: StoredConfigQuestion,
|
|
363
|
+
value: string,
|
|
364
|
+
): Promise<ConfigResult> {
|
|
365
|
+
const { config } = question;
|
|
366
|
+
const existing = await readConfigRow(config);
|
|
367
|
+
const isSecret = config.isSecret || existing?.isSecret === true;
|
|
368
|
+
const written = await upsertSwarmConfigWithPolicyMirror({
|
|
369
|
+
scope: config.scope,
|
|
370
|
+
scopeId: config.scope === "global" ? null : (config.scopeId ?? null),
|
|
371
|
+
key: config.key,
|
|
372
|
+
value,
|
|
373
|
+
isSecret,
|
|
374
|
+
envPath: existing?.envPath ?? null,
|
|
375
|
+
description: existing?.description ?? null,
|
|
376
|
+
});
|
|
377
|
+
return result(question, "set", isSecret, written.lastUpdatedAt, value);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** One line for the hitl-follow-up task text. Never carries a secret. */
|
|
381
|
+
export function formatConfigResult(value: unknown): string | null {
|
|
382
|
+
const parsed = ConfigResultSchema.safeParse(value);
|
|
383
|
+
if (!parsed.success) return null;
|
|
384
|
+
const r = parsed.data;
|
|
385
|
+
const where = `${r.scope}${r.scopeId ? `:${r.scopeId}` : ""}`;
|
|
386
|
+
const updated = r.updatedAt ? `, updated ${r.updatedAt}` : "";
|
|
387
|
+
const shown = !r.isSecret && r.value !== undefined ? ` = ${r.value}` : "";
|
|
388
|
+
return `${r.key} (${where}, ${r.isSecret ? "secret" : "plain"}): ${r.status}${updated}${shown}`;
|
|
389
|
+
}
|
|
@@ -345,6 +345,29 @@ function utcDayBounds(now: Date): { start: string; end: string } {
|
|
|
345
345
|
return { start: start.toISOString(), end: new Date(start.getTime() + 86_400_000).toISOString() };
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
/**
|
|
349
|
+
* True when `runId` is the workflow's only setup-failure run so far this UTC
|
|
350
|
+
* day, top-level or child. Call it in the transaction that inserted `runId`:
|
|
351
|
+
* transactions serialize on the write lock, so of several concurrent failures
|
|
352
|
+
* exactly one sees no other and alerts.
|
|
353
|
+
*/
|
|
354
|
+
export async function isFirstWorkflowPreflightFailureToday(
|
|
355
|
+
workflowId: string,
|
|
356
|
+
runId: string,
|
|
357
|
+
now: Date = new Date(),
|
|
358
|
+
): Promise<boolean> {
|
|
359
|
+
const bounds = utcDayBounds(now);
|
|
360
|
+
const row = await getDbClient().get<{ found: number }>(
|
|
361
|
+
`SELECT 1 AS found FROM workflow_runs
|
|
362
|
+
WHERE workflowId = ? AND id != ? AND status = 'failed'
|
|
363
|
+
AND error LIKE 'needs_setup:%'
|
|
364
|
+
AND startedAt >= ? AND startedAt < ?
|
|
365
|
+
LIMIT 1`,
|
|
366
|
+
[workflowId, runId, bounds.start, bounds.end],
|
|
367
|
+
);
|
|
368
|
+
return row == null;
|
|
369
|
+
}
|
|
370
|
+
|
|
348
371
|
/** Record at most one copy of the same setup failure on a schedule per UTC day. */
|
|
349
372
|
export async function recordSchedulePreflightFailure(
|
|
350
373
|
scheduleId: string,
|
|
@@ -376,7 +399,11 @@ export async function recordSchedulePreflightFailure(
|
|
|
376
399
|
});
|
|
377
400
|
}
|
|
378
401
|
|
|
379
|
-
/**
|
|
402
|
+
/**
|
|
403
|
+
* Create and finish at most one failed top-level preflight run per workflow
|
|
404
|
+
* and UTC day. `recorded` is true when the caller should alert: this call
|
|
405
|
+
* inserted the run and no child failure run of the workflow came first today.
|
|
406
|
+
*/
|
|
380
407
|
export async function recordWorkflowPreflightFailure(input: {
|
|
381
408
|
workflowId: string;
|
|
382
409
|
triggerType: "schedule" | "manual" | "event" | "api";
|
|
@@ -388,9 +415,11 @@ export async function recordWorkflowPreflightFailure(input: {
|
|
|
388
415
|
const now = input.now ?? new Date();
|
|
389
416
|
const bounds = utcDayBounds(now);
|
|
390
417
|
return await getDbClient().transaction(async (tx) => {
|
|
418
|
+
// Child runs record their own linked failure (`startWorkflowExecution`);
|
|
419
|
+
// a top-level trigger never reuses one.
|
|
391
420
|
const existing = await tx.get<{ id: string }>(
|
|
392
421
|
`SELECT id FROM workflow_runs
|
|
393
|
-
WHERE workflowId = ? AND status = 'failed'
|
|
422
|
+
WHERE workflowId = ? AND status = 'failed' AND parentStepId IS NULL
|
|
394
423
|
AND error LIKE 'needs_setup:%'
|
|
395
424
|
AND startedAt >= ? AND startedAt < ?
|
|
396
425
|
ORDER BY startedAt ASC LIMIT 1`,
|
|
@@ -417,6 +446,11 @@ export async function recordWorkflowPreflightFailure(input: {
|
|
|
417
446
|
timestamp,
|
|
418
447
|
],
|
|
419
448
|
);
|
|
420
|
-
|
|
449
|
+
// Claimed in this transaction, which also sees every child failure run
|
|
450
|
+
// committed before it: concurrent failures alert exactly once a day.
|
|
451
|
+
return {
|
|
452
|
+
runId,
|
|
453
|
+
recorded: await isFirstWorkflowPreflightFailureToday(input.workflowId, runId, now),
|
|
454
|
+
};
|
|
421
455
|
});
|
|
422
456
|
}
|
package/src/be/db/approvals.ts
CHANGED
|
@@ -162,3 +162,51 @@ export async function getPendingApprovalsForTasks(taskIds: string[]): Promise<
|
|
|
162
162
|
taskIds,
|
|
163
163
|
);
|
|
164
164
|
}
|
|
165
|
+
|
|
166
|
+
/** The claim key of one Slack thread's cancellation post: `<channel>:<messageTs>`. */
|
|
167
|
+
export function approvalCancellationNotificationKey(target: string, messageTs: string): string {
|
|
168
|
+
return `${target}:${messageTs}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Ids of the cancelled requests of the given runs with a Slack cancellation
|
|
173
|
+
* post not yet delivered (never claimed, or a claim a failed post released).
|
|
174
|
+
* A delivered post never comes back, so a repeat cancel of a settled tree
|
|
175
|
+
* retries only what failed. One query per 500 runs.
|
|
176
|
+
*/
|
|
177
|
+
export async function listUndeliveredCancelledApprovalRequestIds(
|
|
178
|
+
runIds: string[],
|
|
179
|
+
): Promise<string[]> {
|
|
180
|
+
const ids: string[] = [];
|
|
181
|
+
for (let i = 0; i < runIds.length; i += 500) {
|
|
182
|
+
const chunk = runIds.slice(i, i + 500);
|
|
183
|
+
const rows = await getDbClient().query<{
|
|
184
|
+
id: string;
|
|
185
|
+
notificationChannels: string;
|
|
186
|
+
claims: string | null;
|
|
187
|
+
}>(
|
|
188
|
+
`SELECT id, notificationChannels, cancellationNotificationClaims AS claims
|
|
189
|
+
FROM approval_requests
|
|
190
|
+
WHERE workflowRunId IN (${chunk.map(() => "?").join(", ")})
|
|
191
|
+
AND status = 'cancelled' AND notificationChannels IS NOT NULL`,
|
|
192
|
+
chunk,
|
|
193
|
+
);
|
|
194
|
+
for (const row of rows) {
|
|
195
|
+
const claims = row.claims ? (JSON.parse(row.claims) as Record<string, unknown>) : {};
|
|
196
|
+
const channels = JSON.parse(row.notificationChannels) as Array<{
|
|
197
|
+
channel?: string;
|
|
198
|
+
target?: string;
|
|
199
|
+
messageTs?: string;
|
|
200
|
+
}>;
|
|
201
|
+
const undelivered = channels.some(
|
|
202
|
+
(n) =>
|
|
203
|
+
n.channel === "slack" &&
|
|
204
|
+
n.target &&
|
|
205
|
+
n.messageTs &&
|
|
206
|
+
claims[approvalCancellationNotificationKey(n.target, n.messageTs)] !== "delivered",
|
|
207
|
+
);
|
|
208
|
+
if (undelivered) ids.push(row.id);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return ids;
|
|
212
|
+
}
|