@desplega.ai/agent-swarm 2.5.1 → 2.5.3

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.
Files changed (197) hide show
  1. package/dist/{acp-adapter-7g6bpwbk.js → acp-adapter-gr2fcyyj.js} +3 -3
  2. package/dist/{actions-w8xd014k.js → actions-wv766ghf.js} +18 -18
  3. package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-speb906t.js} +3 -3
  4. package/dist/{app-nndcvftq.js → app-ag29n14f.js} +3 -3
  5. package/dist/{assistant-hjmqyscx.js → assistant-3x8bz3eg.js} +19 -19
  6. package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-5b3d2eea.js} +1 -1
  7. package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-jsmfrpzf.js} +1 -1
  8. package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-s73gerxy.js} +2 -2
  9. package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-5zmax2wr.js} +4 -4
  10. package/dist/{claude-adapter-2f8b85df.js → claude-adapter-hswdnm3t.js} +3 -3
  11. package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-cvbzj1h6.js} +2 -2
  12. package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3k8m3hhc.js} +2 -2
  13. package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-fkbyk66x.js} +6905 -6855
  14. package/dist/{cli-y3zyaee5.js → cli-1fc85mvt.js} +5 -5
  15. package/dist/{cli-v2g93k4v.js → cli-210xd1f4.js} +4 -4
  16. package/dist/{cli-sn1vbtg2.js → cli-25j01dpj.js} +3 -3
  17. package/dist/{cli-n6kmqngw.js → cli-35gtk1p6.js} +1 -1
  18. package/dist/{cli-r9fy0ddc.js → cli-3ngdk5te.js} +1 -1
  19. package/dist/{cli-z63a269p.js → cli-40jsnrjm.js} +2 -2
  20. package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
  21. package/dist/{cli-qfm6my86.js → cli-502eqv93.js} +1 -1
  22. package/dist/{cli-xd4eqtr2.js → cli-6n8axjv1.js} +1 -1
  23. package/dist/{cli-hadv0725.js → cli-8s5wx3nz.js} +2 -2
  24. package/dist/{cli-z2dv25mf.js → cli-99xgw26r.js} +116 -5
  25. package/dist/{cli-jpxsrxcb.js → cli-9s05422w.js} +1 -1
  26. package/dist/{cli-kcsd1ez8.js → cli-aecdcr77.js} +343 -325
  27. package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
  28. package/dist/{cli-e9btqw4y.js → cli-ce0ak9ms.js} +18 -13
  29. package/dist/{cli-nj12tn2c.js → cli-e2ar4bp1.js} +71 -20
  30. package/dist/{cli-gwcf52gz.js → cli-e8jyq7d8.js} +11 -7
  31. package/dist/{cli-rngde2jv.js → cli-egjggehm.js} +1 -1
  32. package/dist/{cli-qfjm4gvt.js → cli-ewm3pwbq.js} +2 -2
  33. package/dist/{cli-9mm5ade4.js → cli-fsdprwrg.js} +1 -1
  34. package/dist/{cli-3ee5z0q0.js → cli-fzcn3d87.js} +1 -1
  35. package/dist/{cli-eym3jg7w.js → cli-gpsz7g88.js} +6 -6
  36. package/dist/{cli-cwyafhw1.js → cli-hcpj2xqz.js} +1 -1
  37. package/dist/{cli-j1eghqfm.js → cli-hhxjnjzs.js} +1 -1
  38. package/dist/{cli-pyy4c9ch.js → cli-jjznk3f7.js} +881 -232
  39. package/dist/{cli-q9pzwxvk.js → cli-kxawjrxg.js} +222 -37
  40. package/dist/{cli-rh5smt8t.js → cli-mz9afxxf.js} +14 -3
  41. package/dist/{cli-mz2ygeyq.js → cli-n45mr7zh.js} +2 -2
  42. package/dist/{cli-fes51ya2.js → cli-p0rzjkya.js} +2 -2
  43. package/dist/{cli-3qv8naq8.js → cli-p6zs9h4y.js} +827 -107
  44. package/dist/{cli-mq7kg1b1.js → cli-p9rafdrb.js} +41 -6
  45. package/dist/{cli-cah5detr.js → cli-qgf8emrn.js} +5 -5
  46. package/dist/{cli-2wba1ac9.js → cli-qzme5949.js} +26 -26
  47. package/dist/{cli-znvq0k3t.js → cli-s49hpn37.js} +2 -2
  48. package/dist/{cli-xc65x52q.js → cli-s53vp709.js} +1 -1
  49. package/dist/{cli-0jy85zz4.js → cli-svrj71pg.js} +18 -16
  50. package/dist/{cli-1dp1zj04.js → cli-vwn81jbs.js} +3 -3
  51. package/dist/{cli-ajqhxtnz.js → cli-w1zwctj2.js} +1 -1
  52. package/dist/{cli-22rmcjkg.js → cli-y561h5a9.js} +7 -6
  53. package/dist/{cli-2jg61wab.js → cli-z21whrrg.js} +15 -15
  54. package/dist/cli.js +19 -18
  55. package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-n8ka66vf.js} +2 -2
  56. package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-fsebts37.js} +1 -1
  57. package/dist/{commands-mbvhbrcy.js → commands-t04rt2pc.js} +2 -2
  58. package/dist/{controller-gnq1k1mf.js → controller-rgna04r2.js} +2 -2
  59. package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-bqm1rtft.js} +20 -20
  60. package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-swr2k95y.js} +1 -1
  61. package/dist/{db-88wcymxt.js → db-24ck09xy.js} +1 -1
  62. package/dist/{db-af9y8nkf.js → db-a4m7haw0.js} +3 -3
  63. package/dist/{dispatcher-606ydx5v.js → dispatcher-nwf9ss0q.js} +17 -17
  64. package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-kpdnhjst.js} +2 -2
  65. package/dist/{external-channel-y8q5z8z2.js → external-channel-j1e2p34p.js} +2 -2
  66. package/dist/{grok-adapter-39aha52t.js → grok-adapter-qtjz0whk.js} +4 -4
  67. package/dist/{handlers-rk9dqech.js → handlers-19xr6dnz.js} +20 -20
  68. package/dist/{heartbeat-jfe9zfjr.js → heartbeat-vtesda5d.js} +20 -20
  69. package/dist/{hook-b1mez1m8.js → hook-8wqaszda.js} +1 -1
  70. package/dist/{hook-ffevzed1.js → hook-b2hhj6rd.js} +1 -1
  71. package/dist/{http-d5b44f1r.js → http-mxcqhhcs.js} +3020 -2463
  72. package/dist/{index-5en23s5z.js → index-1r8wfpzs.js} +1 -1
  73. package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
  74. package/dist/{index-mj46rk9k.js → index-kw2mmat7.js} +20 -20
  75. package/dist/{index-nq8hhv28.js → index-n1vbvta9.js} +19 -10
  76. package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
  77. package/dist/{index-d2qeem97.js → index-spwxjgay.js} +17 -17
  78. package/dist/{index-4q3vj1yr.js → index-srg522m6.js} +17 -17
  79. package/dist/{keepalive-wsby1btq.js → keepalive-dsd8rtgx.js} +3 -3
  80. package/dist/{lead-paa141j6.js → lead-zyerk1x2.js} +30 -30
  81. package/dist/{maintenance-vnshcyv9.js → maintenance-jqmqwnet.js} +1 -1
  82. package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-q2c3bnvm.js} +2 -2
  83. package/dist/{onboard-nr4xhpzy.js → onboard-w4yd7s8t.js} +2 -2
  84. package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-9tppvxar.js} +2 -2
  85. package/dist/{otel-impl-ytn9wyv9.js → otel-impl-f36byzbd.js} +1 -1
  86. package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-xvak5p4v.js} +3 -3
  87. package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-682k2mt5.js} +12 -2
  88. package/dist/rbac-roles-gfn30fb8.js +26 -0
  89. package/dist/{render-v2-pyfsg9zk.js → render-v2-hgytz8nn.js} +3 -3
  90. package/dist/{resume-phj8xhqr.js → resume-2dvq348g.js} +7 -5
  91. package/dist/{session-access-jzx2z9h5.js → session-access-7f21ekq9.js} +4 -4
  92. package/dist/{setup-rfg0f0sg.js → setup-epffxtbk.js} +1 -1
  93. package/dist/{work-objects-44z2yjg8.js → work-objects-xz1a5rqk.js} +20 -20
  94. package/dist/{worker-zsfqzxhm.js → worker-42t8emjt.js} +30 -30
  95. package/openapi.json +3163 -2114
  96. package/package.json +7 -6
  97. package/src/be/approval-config-question.ts +389 -0
  98. package/src/be/automation-preflight.ts +37 -3
  99. package/src/be/db/session-tree.ts +158 -13
  100. package/src/be/db/session-visibility.ts +31 -0
  101. package/src/be/db/skill-search.ts +87 -0
  102. package/src/be/db/tasks/read.ts +47 -7
  103. package/src/be/db/workflow-runs.ts +54 -7
  104. package/src/be/db.ts +27 -5
  105. package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
  106. package/src/be/migrations/235_chat_relay_defer.sql +86 -0
  107. package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
  108. package/src/be/migrations/239_skills_fts.sql +40 -0
  109. package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
  110. package/src/be/migrations/241_skills_meta_fts.sql +39 -0
  111. package/src/be/rbac-roles.ts +138 -0
  112. package/src/be/scripts/run-credential.ts +64 -0
  113. package/src/be/scripts/run-saved.ts +3 -2
  114. package/src/be/swarm-config-guard.ts +23 -0
  115. package/src/be/users.ts +28 -4
  116. package/src/extensions/catalog.generated.json +4 -4
  117. package/src/http/all-routes.ts +1 -0
  118. package/src/http/approval-requests.ts +254 -43
  119. package/src/http/config.ts +22 -37
  120. package/src/http/index.ts +2 -0
  121. package/src/http/schedules.ts +26 -0
  122. package/src/http/scripts.ts +2 -2
  123. package/src/http/sessions.ts +118 -2
  124. package/src/http/user-roles.ts +173 -0
  125. package/src/http/users.ts +9 -0
  126. package/src/http/x.ts +2 -2
  127. package/src/integrations/chat-sdk/commands.ts +14 -1
  128. package/src/integrations/chat-sdk/inbound.ts +42 -6
  129. package/src/integrations/chat-sdk/outbound.ts +35 -10
  130. package/src/integrations/chat-sdk/reactions.ts +107 -0
  131. package/src/integrations/chat-sdk/relay.ts +75 -7
  132. package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
  133. package/src/integrations/reaction-events.ts +20 -0
  134. package/src/providers/amp-adapter.ts +1 -1
  135. package/src/providers/dsh-adapter.ts +1 -1
  136. package/src/rbac/legacy-policy.ts +3 -0
  137. package/src/rbac/permissions.ts +8 -0
  138. package/src/scheduler/schedule-task.ts +44 -3
  139. package/src/scheduler/scheduler.ts +4 -4
  140. package/src/scripts-runtime/loader.ts +8 -1
  141. package/src/slack/ack.ts +2 -1
  142. package/src/slack/reaction-shortcode.ts +4 -7
  143. package/src/tasks/worker-follow-up.ts +30 -0
  144. package/src/tests/approval-config-question.test.ts +702 -0
  145. package/src/tests/approval-requests-notifications.test.ts +189 -0
  146. package/src/tests/chat-sdk-commands.test.ts +12 -0
  147. package/src/tests/chat-sdk-inbound.test.ts +183 -9
  148. package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
  149. package/src/tests/chat-sdk-relay.test.ts +361 -10
  150. package/src/tests/chat-sdk-tools.test.ts +104 -0
  151. package/src/tests/docs-index-footprint.test.js +3 -3
  152. package/src/tests/dsh-adapter.test.ts +1 -1
  153. package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
  154. package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
  155. package/src/tests/http-users.test.ts +38 -0
  156. package/src/tests/list-endpoint-slimming.test.ts +33 -0
  157. package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
  158. package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
  159. package/src/tests/rbac-engine.test.ts +2 -0
  160. package/src/tests/rbac-roles.test.ts +10 -0
  161. package/src/tests/schedule-http-triage-tools.test.ts +44 -0
  162. package/src/tests/script-api-credential.test.ts +173 -0
  163. package/src/tests/script-connections.test.ts +10 -0
  164. package/src/tests/script-run-config-scope.test.ts +120 -0
  165. package/src/tests/script-run-token.test.ts +75 -0
  166. package/src/tests/scripts-http.test.ts +19 -0
  167. package/src/tests/sessions-tree.test.ts +410 -1
  168. package/src/tests/skill-search-long-content.test.ts +193 -0
  169. package/src/tests/skill-search.test.ts +173 -22
  170. package/src/tests/user-roles-http.test.ts +204 -0
  171. package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
  172. package/src/tests/workflow-sub-workflow.test.ts +538 -1
  173. package/src/tools/chat-reply.ts +2 -0
  174. package/src/tools/request-human-input.ts +27 -5
  175. package/src/tools/schedules/patch-schedule.ts +18 -0
  176. package/src/tools/schedules/update-schedule.ts +18 -0
  177. package/src/types.ts +23 -0
  178. package/src/workflows/approval-notifications.ts +221 -0
  179. package/src/workflows/checkpoint.ts +69 -35
  180. package/src/workflows/engine.ts +234 -87
  181. package/src/workflows/executors/base.ts +6 -0
  182. package/src/workflows/executors/human-in-the-loop.ts +36 -173
  183. package/src/workflows/executors/sub-workflow.ts +45 -16
  184. package/src/workflows/executors/swarm-script.ts +2 -2
  185. package/src/workflows/recovery.ts +63 -1
  186. package/src/workflows/resume.ts +60 -21
  187. package/src/workflows/retry-poller.ts +25 -4
  188. package/templates/extensions/comms-adherence-gate/README.md +38 -13
  189. package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
  190. package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
  191. package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
  192. package/templates/skills/code-quality/content.md +1 -1
  193. package/templates/skills/turso-interaction/content.md +1 -1
  194. package/templates/skills/workflow-iterate/content.md +8 -1
  195. package/dist/rbac-roles-ff1ffmec.js +0 -12
  196. /package/dist/{cli-stxdrqs2.js → cli-avdvgcay.js} +0 -0
  197. /package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-tqsge1ay.js} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "2.5.1",
3
+ "version": "2.5.3",
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",
@@ -113,6 +113,7 @@
113
113
  "tsc:check": "bun check",
114
114
  "tsc:check:tsc": "bun tsc --noEmit",
115
115
  "check:db-boundary": "bash scripts/check-db-boundary.sh",
116
+ "check:codex-default-model": "bash scripts/check-codex-default-model.sh",
116
117
  "check:test-spawn-sync": "bash scripts/check-test-spawn-sync.sh",
117
118
  "check:dep-graph": "depcruise src packages/model-routing --config .dependency-cruiser.cjs",
118
119
  "check:api-key-boundary": "bash scripts/check-api-key-boundary.sh",
@@ -179,8 +180,8 @@
179
180
  "@desplega/model-catalog": "workspace:*",
180
181
  "@desplega/model-routing": "workspace:*",
181
182
  "@faker-js/faker": "^10.4.0",
182
- "@opencode/plugin": "2.1.3",
183
- "@opencode/sdk": "2.1.3",
183
+ "@opencode/plugin": "2.1.4",
184
+ "@opencode/sdk": "2.1.4",
184
185
  "@oxlint/plugins": "1.78.0",
185
186
  "@types/bun": "latest",
186
187
  "dependency-cruiser": "^18.0.0",
@@ -200,8 +201,8 @@
200
201
  "dependencies": {
201
202
  "@agentclientprotocol/sdk": "2.0.1",
202
203
  "@ai-sdk/openai": "^3.0.41",
203
- "@anthropic-ai/claude-agent-sdk": "0.3.308",
204
- "@anthropic-ai/sdk": "^1.1.0",
204
+ "@anthropic-ai/claude-agent-sdk": "0.3.309",
205
+ "@anthropic-ai/sdk": "^1.2.0",
205
206
  "@asteasolutions/zod-to-openapi": "^8.0.0",
206
207
  "@aws-sdk/client-bedrock": "3.1048.0",
207
208
  "@chat-adapter/discord": "4.41.1",
@@ -218,7 +219,7 @@
218
219
  "@jitl/quickjs-singlefile-mjs-release-sync": "0.32.0",
219
220
  "@linear/sdk": "^77.0.0",
220
221
  "@modelcontextprotocol/sdk": "1.32.1",
221
- "@openai/codex": "0.163.0",
222
+ "@openai/codex": "0.164.0",
222
223
  "@openfort/openfort-node": "^0.9.1",
223
224
  "@opentelemetry/api": "^1.9.1",
224
225
  "@opentelemetry/api-logs": "^0.218.0",
@@ -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
- /** Create and finish at most one failed preflight run per workflow and UTC day. */
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
- return { runId, recorded: true };
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
  }