@desplega.ai/agent-swarm 2.5.2 → 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 (143) hide show
  1. package/dist/{acp-adapter-csh0zz3b.js → acp-adapter-gr2fcyyj.js} +3 -3
  2. package/dist/{actions-6xx079d1.js → actions-wv766ghf.js} +18 -18
  3. package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-speb906t.js} +3 -3
  4. package/dist/{app-ft6r6v23.js → app-ag29n14f.js} +3 -3
  5. package/dist/{assistant-vt2cyfzd.js → assistant-3x8bz3eg.js} +19 -19
  6. package/dist/{boot-reembed-r3e808ky.js → boot-reembed-5b3d2eea.js} +1 -1
  7. package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-jsmfrpzf.js} +1 -1
  8. package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-s73gerxy.js} +2 -2
  9. package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-5zmax2wr.js} +4 -4
  10. package/dist/{claude-adapter-s7j47k06.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-3hzrgrnk.js → claude-managed-setup-3k8m3hhc.js} +2 -2
  13. package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-fkbyk66x.js} +2 -2
  14. package/dist/{cli-d172svcw.js → cli-1fc85mvt.js} +4 -4
  15. package/dist/{cli-3e9vs93g.js → cli-210xd1f4.js} +4 -4
  16. package/dist/{cli-vaxw3bg0.js → cli-25j01dpj.js} +3 -3
  17. package/dist/{cli-n6kmqngw.js → cli-35gtk1p6.js} +1 -1
  18. package/dist/{cli-fgn7yg5r.js → cli-3ngdk5te.js} +1 -1
  19. package/dist/{cli-7ccf7akh.js → cli-40jsnrjm.js} +2 -2
  20. package/dist/{cli-b1tv22wr.js → cli-502eqv93.js} +1 -1
  21. package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
  22. package/dist/{cli-f55b6ky0.js → cli-8s5wx3nz.js} +2 -2
  23. package/dist/{cli-86zpfje5.js → cli-99xgw26r.js} +1 -1
  24. package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
  25. package/dist/{cli-h5d22pfs.js → cli-aecdcr77.js} +119 -282
  26. package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
  27. package/dist/{cli-15xd0j7w.js → cli-ce0ak9ms.js} +11 -11
  28. package/dist/{cli-y3dzwyy3.js → cli-e2ar4bp1.js} +71 -20
  29. package/dist/{cli-b6tnjdvx.js → cli-e8jyq7d8.js} +1 -1
  30. package/dist/{cli-6y43v79h.js → cli-egjggehm.js} +1 -1
  31. package/dist/{cli-cf63dnxj.js → cli-ewm3pwbq.js} +2 -2
  32. package/dist/{cli-bpbcpd8b.js → cli-fsdprwrg.js} +1 -1
  33. package/dist/{cli-qrjv7t18.js → cli-fzcn3d87.js} +1 -1
  34. package/dist/{cli-14v1sxxy.js → cli-gpsz7g88.js} +6 -6
  35. package/dist/{cli-wadzapwb.js → cli-hcpj2xqz.js} +1 -1
  36. package/dist/{cli-jmcjbjkt.js → cli-hhxjnjzs.js} +1 -1
  37. package/dist/{cli-j3dwd4kz.js → cli-jjznk3f7.js} +718 -108
  38. package/dist/{cli-csh8fnbg.js → cli-kxawjrxg.js} +147 -28
  39. package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
  40. package/dist/{cli-0vgy5wfw.js → cli-n45mr7zh.js} +2 -2
  41. package/dist/{cli-aaqnqpyw.js → cli-p0rzjkya.js} +2 -2
  42. package/dist/{cli-3qv8naq8.js → cli-p6zs9h4y.js} +827 -107
  43. package/dist/{cli-e91vshb1.js → cli-p9rafdrb.js} +4 -4
  44. package/dist/{cli-edtm741g.js → cli-qgf8emrn.js} +5 -5
  45. package/dist/{cli-ha22h0eg.js → cli-qzme5949.js} +26 -26
  46. package/dist/{cli-xzge46sj.js → cli-s49hpn37.js} +2 -2
  47. package/dist/{cli-b63d08rs.js → cli-s53vp709.js} +1 -1
  48. package/dist/{cli-cyfgxnvb.js → cli-svrj71pg.js} +18 -16
  49. package/dist/{cli-4qkg9968.js → cli-vwn81jbs.js} +3 -3
  50. package/dist/{cli-f3pfn4x3.js → cli-w1zwctj2.js} +1 -1
  51. package/dist/{cli-kcnwn85g.js → cli-y561h5a9.js} +4 -4
  52. package/dist/{cli-n2918sed.js → cli-z21whrrg.js} +15 -15
  53. package/dist/cli.js +16 -16
  54. package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-n8ka66vf.js} +2 -2
  55. package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-fsebts37.js} +1 -1
  56. package/dist/{commands-1cnvseqx.js → commands-t04rt2pc.js} +2 -2
  57. package/dist/{controller-kc7s9xhn.js → controller-rgna04r2.js} +2 -2
  58. package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-bqm1rtft.js} +20 -20
  59. package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-swr2k95y.js} +1 -1
  60. package/dist/{db-v5ytg5pw.js → db-24ck09xy.js} +1 -1
  61. package/dist/{db-4vq88jkc.js → db-a4m7haw0.js} +3 -3
  62. package/dist/{dispatcher-trtcrqae.js → dispatcher-nwf9ss0q.js} +17 -17
  63. package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-kpdnhjst.js} +2 -2
  64. package/dist/{external-channel-n9bymc2n.js → external-channel-j1e2p34p.js} +2 -2
  65. package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-qtjz0whk.js} +4 -4
  66. package/dist/{handlers-ptsraqa8.js → handlers-19xr6dnz.js} +20 -20
  67. package/dist/{heartbeat-60qtxtjf.js → heartbeat-vtesda5d.js} +20 -20
  68. package/dist/{hook-rbktcstg.js → hook-8wqaszda.js} +1 -1
  69. package/dist/{hook-ngtvh729.js → hook-b2hhj6rd.js} +1 -1
  70. package/dist/{http-z8xy72gp.js → http-mxcqhhcs.js} +2757 -2448
  71. package/dist/{index-a9nsavhx.js → index-1r8wfpzs.js} +1 -1
  72. package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
  73. package/dist/{index-s193j5fh.js → index-kw2mmat7.js} +20 -20
  74. package/dist/{index-06qkmcx6.js → index-n1vbvta9.js} +17 -9
  75. package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
  76. package/dist/{index-1cscynkb.js → index-spwxjgay.js} +17 -17
  77. package/dist/{index-r70q0xjx.js → index-srg522m6.js} +17 -17
  78. package/dist/{keepalive-a9srcphj.js → keepalive-dsd8rtgx.js} +3 -3
  79. package/dist/{lead-3devneqy.js → lead-zyerk1x2.js} +30 -30
  80. package/dist/{maintenance-0er16h2y.js → maintenance-jqmqwnet.js} +1 -1
  81. package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-q2c3bnvm.js} +2 -2
  82. package/dist/{onboard-wqywz5py.js → onboard-w4yd7s8t.js} +2 -2
  83. package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-9tppvxar.js} +2 -2
  84. package/dist/{otel-impl-vk32s884.js → otel-impl-f36byzbd.js} +1 -1
  85. package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-xvak5p4v.js} +3 -3
  86. package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-682k2mt5.js} +1 -1
  87. package/dist/{rbac-roles-452665y0.js → rbac-roles-gfn30fb8.js} +2 -2
  88. package/dist/{render-v2-0ttsk2j4.js → render-v2-hgytz8nn.js} +3 -3
  89. package/dist/{resume-a26kvr41.js → resume-2dvq348g.js} +7 -5
  90. package/dist/{session-access-dgh9nkwk.js → session-access-7f21ekq9.js} +4 -4
  91. package/dist/{setup-9vc9qahm.js → setup-epffxtbk.js} +1 -1
  92. package/dist/{work-objects-vn8nkxwn.js → work-objects-xz1a5rqk.js} +20 -20
  93. package/dist/{worker-1x1h8vnm.js → worker-42t8emjt.js} +30 -30
  94. package/openapi.json +3045 -2456
  95. package/package.json +4 -4
  96. package/src/be/approval-config-question.ts +389 -0
  97. package/src/be/automation-preflight.ts +37 -3
  98. package/src/be/db/session-tree.ts +73 -15
  99. package/src/be/db/skill-search.ts +87 -0
  100. package/src/be/db/tasks/read.ts +29 -2
  101. package/src/be/db/workflow-runs.ts +54 -7
  102. package/src/be/db.ts +25 -5
  103. package/src/be/migrations/239_skills_fts.sql +40 -0
  104. package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
  105. package/src/be/migrations/241_skills_meta_fts.sql +39 -0
  106. package/src/be/swarm-config-guard.ts +23 -0
  107. package/src/extensions/catalog.generated.json +4 -4
  108. package/src/http/approval-requests.ts +209 -38
  109. package/src/http/config.ts +22 -37
  110. package/src/http/sessions.ts +27 -2
  111. package/src/integrations/chat-sdk/inbound.ts +5 -4
  112. package/src/providers/amp-adapter.ts +1 -1
  113. package/src/providers/dsh-adapter.ts +1 -1
  114. package/src/tests/approval-config-question.test.ts +702 -0
  115. package/src/tests/chat-sdk-inbound.test.ts +20 -0
  116. package/src/tests/dsh-adapter.test.ts +1 -1
  117. package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
  118. package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
  119. package/src/tests/list-endpoint-slimming.test.ts +33 -0
  120. package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
  121. package/src/tests/script-connections.test.ts +6 -0
  122. package/src/tests/sessions-tree.test.ts +168 -1
  123. package/src/tests/skill-search-long-content.test.ts +193 -0
  124. package/src/tests/skill-search.test.ts +173 -22
  125. package/src/tests/workflow-sub-workflow.test.ts +538 -1
  126. package/src/tools/request-human-input.ts +27 -5
  127. package/src/types.ts +7 -0
  128. package/src/workflows/approval-notifications.ts +10 -3
  129. package/src/workflows/checkpoint.ts +69 -35
  130. package/src/workflows/engine.ts +234 -87
  131. package/src/workflows/executors/base.ts +6 -0
  132. package/src/workflows/executors/human-in-the-loop.ts +25 -1
  133. package/src/workflows/executors/sub-workflow.ts +45 -16
  134. package/src/workflows/recovery.ts +63 -1
  135. package/src/workflows/resume.ts +60 -21
  136. package/src/workflows/retry-poller.ts +25 -4
  137. package/templates/extensions/comms-adherence-gate/README.md +38 -13
  138. package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
  139. package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
  140. package/templates/skills/turso-interaction/content.md +1 -1
  141. package/templates/skills/workflow-iterate/content.md +8 -1
  142. /package/dist/{cli-stxdrqs2.js → cli-avdvgcay.js} +0 -0
  143. /package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-tqsge1ay.js} +0 -0
@@ -1,5 +1,20 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
+ import {
4
+ applyConfigAnswer,
5
+ type ConfigAnswer,
6
+ ConfigQuestionSettingsSchema,
7
+ type ConfigResult,
8
+ type ConfigState,
9
+ ConfigStateSchema,
10
+ isConfigQuestion,
11
+ keptConfigResult,
12
+ normalizeApprovalQuestions,
13
+ parseConfigAnswer,
14
+ readConfigState,
15
+ type StoredConfigQuestion,
16
+ skippedConfigResult,
17
+ } from "../be/approval-config-question";
3
18
  import { resolveTaskAuditUserId } from "../be/audit-user";
4
19
  import {
5
20
  type ApprovalRequest,
@@ -16,8 +31,9 @@ import {
16
31
  recordApprovalVotes,
17
32
  resolveApprovalRequest,
18
33
  } from "../be/db";
34
+ import { getUserGrant } from "../be/rbac-roles";
19
35
  import { findUserById } from "../be/users";
20
- import { can, type RbacPrincipal } from "../rbac";
36
+ import { can, isRbacEnabled, type RbacPrincipal } from "../rbac";
21
37
  import type { User } from "../types";
22
38
  import { getRequestAuth } from "../utils/request-auth-context";
23
39
  import { scrubSecrets } from "../utils/secret-scrubber";
@@ -27,6 +43,7 @@ import {
27
43
  dispatchApprovalNotifications,
28
44
  } from "../workflows/approval-notifications";
29
45
  import { workflowEventBus } from "../workflows/event-bus";
46
+ import { ensureConfigAdmin, runConfigWriteSideEffects } from "./config";
30
47
  import { bodyTask, route } from "./route-def";
31
48
  import { jsonError } from "./utils";
32
49
 
@@ -34,7 +51,7 @@ import { jsonError } from "./utils";
34
51
 
35
52
  const QuestionSchema = z.object({
36
53
  id: z.string(),
37
- type: z.enum(["approval", "text", "single-select", "multi-select", "boolean"]),
54
+ type: z.enum(["approval", "text", "single-select", "multi-select", "boolean", "config"]),
38
55
  label: z.string(),
39
56
  required: z.boolean().optional(),
40
57
  description: z.string().optional(),
@@ -52,8 +69,14 @@ const QuestionSchema = z.object({
52
69
  minSelections: z.number().int().min(0).optional(),
53
70
  maxSelections: z.number().int().min(1).optional(),
54
71
  defaultValue: z.boolean().optional(),
72
+ config: ConfigQuestionSettingsSchema.optional().describe(
73
+ "Config question settings. Answers are written to swarm_config and replaced by a result that never holds a secret value.",
74
+ ),
55
75
  });
56
76
 
77
+ /** Create input: `label` may be omitted on a config question (it defaults to the key). */
78
+ const QuestionInputSchema = QuestionSchema.extend({ label: z.string().optional() });
79
+
57
80
  export type ApprovalQuestion = z.infer<typeof QuestionSchema>;
58
81
 
59
82
  // ─── Response Schemas ────────────────────────────────────────────────────────
@@ -133,6 +156,12 @@ const ApprovalRequestSchema = z.object({
133
156
  createdBy: z.string().optional(),
134
157
  createdAt: z.string(),
135
158
  updatedAt: z.string(),
159
+ configState: z
160
+ .record(z.string(), ConfigStateSchema)
161
+ .optional()
162
+ .describe(
163
+ "Live state of each config question's key, by question id. Read at request time and never stored. `value` appears only for non-secret keys.",
164
+ ),
136
165
  });
137
166
 
138
167
  /** Slim list row (`?fields=slim`) — see `ApprovalRequestSummary`. */
@@ -143,6 +172,7 @@ const ApprovalRequestSummarySchema = ApprovalRequestSchema.omit({
143
172
  approvals: true,
144
173
  resolutionReason: true,
145
174
  notificationChannels: true,
175
+ configState: true,
146
176
  }).extend({ questionCount: z.number().int() });
147
177
 
148
178
  /**
@@ -152,11 +182,16 @@ const ApprovalRequestSummarySchema = ApprovalRequestSchema.omit({
152
182
  */
153
183
  async function toApprovalRequestResponse(
154
184
  request: ApprovalRequest,
185
+ options: { withConfigState?: boolean } = {},
155
186
  ): Promise<z.infer<typeof ApprovalRequestSchema>> {
156
187
  const approvers = request.approvers as ApproversShape;
157
188
  const approvals = request.approvals ?? null;
189
+ const configState = options.withConfigState
190
+ ? await readConfigStates(request.questions as ApprovalQuestion[])
191
+ : undefined;
158
192
  return {
159
193
  ...request,
194
+ ...(configState ? { configState } : {}),
160
195
  questions: request.questions as ApprovalQuestion[],
161
196
  approvers,
162
197
  responses: request.responses as Record<string, unknown> | null,
@@ -188,6 +223,28 @@ async function withApprovalProgress(
188
223
  );
189
224
  }
190
225
 
226
+ /** Live state per config question id, or undefined when there are none. */
227
+ async function readConfigStates(
228
+ questions: ApprovalQuestion[],
229
+ ): Promise<Record<string, ConfigState> | undefined> {
230
+ const configQuestions = questions.filter(isConfigQuestion);
231
+ if (configQuestions.length === 0) return undefined;
232
+ const entries = await Promise.all(
233
+ configQuestions.map(async (q) => [q.id, await readConfigState(q.config)] as const),
234
+ );
235
+ return Object.fromEntries(entries);
236
+ }
237
+
238
+ /** Thrown inside the respond transaction to roll back config writes. */
239
+ class ConfigRespondAbort extends Error {
240
+ constructor(
241
+ readonly kind: "unavailable" | "conflict",
242
+ message: string = kind,
243
+ ) {
244
+ super(message);
245
+ }
246
+ }
247
+
191
248
  export async function getWorkflowApprovalUnavailableReason(
192
249
  request: ApprovalRequest,
193
250
  ): Promise<string | null> {
@@ -241,6 +298,15 @@ function hasRequiredResponse(question: ApprovalQuestion, response: unknown): boo
241
298
  }
242
299
  case "boolean":
243
300
  return typeof response === "boolean";
301
+ case "config": {
302
+ // Shape only. The respond handler checks `keep` against the live row.
303
+ if (typeof response !== "object" || response === null) return false;
304
+ const answer = response as { action?: unknown; value?: unknown };
305
+ if (answer.action === "keep") return true;
306
+ return (
307
+ answer.action === "set" && typeof answer.value === "string" && answer.value.trim() !== ""
308
+ );
309
+ }
244
310
  }
245
311
  }
246
312
 
@@ -264,7 +330,12 @@ const createRoute = route({
264
330
  tags: ["ApprovalRequests"],
265
331
  body: z.object({
266
332
  title: z.string().min(1),
267
- questions: z.array(QuestionSchema).min(1),
333
+ questions: z
334
+ .array(QuestionInputSchema)
335
+ .min(1)
336
+ .describe(
337
+ 'A request with a `config` question needs `approvers.policy: "any"`. The answer is written to swarm_config; the request stores only `{ key, scope, scopeId, isSecret, status, updatedAt }` (plus `value` for non-secret keys).',
338
+ ),
268
339
  approvers: z.object({
269
340
  users: z.array(z.string()).optional(),
270
341
  roles: z.array(z.string()).optional(),
@@ -323,6 +394,8 @@ const getByIdRoute = route({
323
394
  404: { description: "Not found" },
324
395
  },
325
396
  auth: { apiKey: true },
397
+ description:
398
+ "Includes `configState` when the request has `config` questions: the live state of each target key, read now.",
326
399
  });
327
400
 
328
401
  const respondRoute = route({
@@ -331,7 +404,7 @@ const respondRoute = route({
331
404
  pattern: ["api", "approval-requests", null, "respond"],
332
405
  summary: "Submit a response to an approval request",
333
406
  description:
334
- "Only a person may answer: a user token, a page session signed for a user, or the shared key with no agent identity (recorded as `operator`). The responder is taken from the credential. When the request lists `approvers.users` or `approvers.roles`, a user must match one of them. A rejection resolves the request at once; approvals resolve it when the `any`, `all` or `{ min: N }` policy is met, and until then it stays pending with the answer recorded in `approvals`.",
407
+ 'Only a person may answer: a user token, a page session signed for a user, or the shared key with no agent identity (recorded as `operator`). The responder is taken from the credential. When the request lists `approvers.users` or `approvers.roles`, a user must match one of them. A rejection resolves the request at once; approvals resolve it when the `any`, `all` or `{ min: N }` policy is met, and until then it stays pending with the answer recorded in `approvals`. A `config` question takes `{ action: "set", value }`, `{ action: "keep" }` (key must exist) or `{ action: "skip" }` (optional questions only); `set` also needs the config write permission. The value is written only when the request is approved, and the stored answer is the result, never a secret value.',
335
408
  tags: ["ApprovalRequests"],
336
409
  params: z.object({ id: z.string().uuid() }),
337
410
  body: z.object({
@@ -487,11 +560,47 @@ export async function handleApprovalRequests(
487
560
  return true;
488
561
  }
489
562
 
563
+ // Config answers (D6) are checked here and replaced by their results (D7)
564
+ // before the vote exists, so no plaintext reaches any stored or sent copy.
565
+ const configQuestions = questions.filter(isConfigQuestion);
566
+ const configAnswers = new Map<
567
+ string,
568
+ { question: StoredConfigQuestion; answer: ConfigAnswer }
569
+ >();
570
+ for (const question of configQuestions) {
571
+ const state = await readConfigState(question.config);
572
+ const answer = parseConfigAnswer(question, parsed.body.responses[question.id], state);
573
+ if (!answer.ok) {
574
+ jsonError(res, answer.error, 400);
575
+ return true;
576
+ }
577
+ configAnswers.set(question.id, { question, answer: answer.answer });
578
+ }
579
+ if (configAnswers.size > 0 && approvers.policy !== "any") {
580
+ jsonError(res, 'Config questions need approvers.policy "any"', 409);
581
+ return true;
582
+ }
583
+ if ([...configAnswers.values()].some(({ answer }) => answer.action === "set")) {
584
+ if (!(await ensureConfigAdmin(req, res, "config.write.any"))) return true;
585
+ // Admission only checked `approval.respond`; `ensureConfigAdmin` passes
586
+ // every user, so check the config write grant here as PUT /api/config would.
587
+ const auth = getRequestAuth(req);
588
+ if (auth?.kind === "user" && isRbacEnabled()) {
589
+ const grant = await getUserGrant(auth.userId);
590
+ if (!grant.grantsAll && !grant.verbs.has("config.write.any")) {
591
+ jsonError(res, "Forbidden: setting a config value needs 'config.write.any'", 403);
592
+ return true;
593
+ }
594
+ }
595
+ }
596
+ const safeResponses: Record<string, unknown> = { ...parsed.body.responses };
597
+ for (const id of configAnswers.keys()) delete safeResponses[id];
598
+
490
599
  // Determine status from responses: if any approval question has approved: false → rejected
491
600
  let status: "approved" | "rejected" = "approved";
492
601
  for (const q of questions) {
493
602
  if (q.type === "approval") {
494
- const answer = parsed.body.responses[q.id] as { approved?: boolean } | undefined;
603
+ const answer = safeResponses[q.id] as { approved?: boolean } | undefined;
495
604
  if (answer && answer.approved === false) {
496
605
  status = "rejected";
497
606
  break;
@@ -500,38 +609,89 @@ export async function handleApprovalRequests(
500
609
  }
501
610
 
502
611
  const responderId = responderKey(responder.responder);
503
- const vote: ApprovalVote = {
504
- responder: responderId,
505
- approved: status === "approved",
506
- responses: parsed.body.responses,
507
- ...(parsed.body.respondedBy ? { claimedRespondedBy: parsed.body.respondedBy } : {}),
508
- respondedAt: new Date().toISOString(),
509
- };
612
+ const respondedAt = new Date().toISOString();
613
+ const configWrites: { result: ConfigResult; value: string }[] = [];
510
614
  // One write transaction (BEGIN IMMEDIATE) so concurrent answers to an
511
615
  // `all` / `{ min: N }` request append in turn and never drop each other.
512
- const outcome = await getDbClient().transaction(async () => {
513
- const current = await getApprovalRequestById(parsed.params.id);
514
- if (!current || current.status !== "pending") return { kind: "unavailable" as const };
515
- const prior = current.approvals ?? [];
516
- if (prior.some((v) => v.responder === responderId)) return { kind: "duplicate" as const };
517
- const votes = [...prior, vote];
518
- if (status === "rejected" || (await approvalQuorumMet(approvers, votes))) {
519
- const resolved = await resolveApprovalRequest(
520
- parsed.params.id,
521
- { status, responses: parsed.body.responses, approvals: votes, resolvedBy: responderId },
522
- { requireActionableWorkflow: true },
523
- );
524
- return resolved
525
- ? { kind: "resolved" as const, request: resolved }
616
+ // Config writes share it, so a lost race writes nothing.
617
+ const outcome = await getDbClient()
618
+ .transaction(async () => {
619
+ const current = await getApprovalRequestById(parsed.params.id);
620
+ if (!current || current.status !== "pending") return { kind: "unavailable" as const };
621
+ const prior = current.approvals ?? [];
622
+ if (prior.some((v) => v.responder === responderId)) return { kind: "duplicate" as const };
623
+
624
+ // Config values are written only when the request resolves approved (D8).
625
+ const responses = { ...safeResponses };
626
+ for (const [id, { question, answer }] of configAnswers) {
627
+ if (status === "rejected" || answer.action === "skip") {
628
+ responses[id] = skippedConfigResult(question);
629
+ } else if (answer.action === "keep") {
630
+ const kept = await keptConfigResult(question);
631
+ if (!kept) {
632
+ throw new ConfigRespondAbort(
633
+ "conflict",
634
+ `config question "${id}": the key was removed before the answer was saved`,
635
+ );
636
+ }
637
+ responses[id] = kept;
638
+ } else {
639
+ const written = await applyConfigAnswer(question, answer.value);
640
+ configWrites.push({ result: written, value: answer.value });
641
+ responses[id] = written;
642
+ }
643
+ }
644
+
645
+ const vote: ApprovalVote = {
646
+ responder: responderId,
647
+ approved: status === "approved",
648
+ responses,
649
+ ...(parsed.body.respondedBy ? { claimedRespondedBy: parsed.body.respondedBy } : {}),
650
+ respondedAt,
651
+ };
652
+ const votes = [...prior, vote];
653
+ if (status === "rejected" || (await approvalQuorumMet(approvers, votes))) {
654
+ const resolved = await resolveApprovalRequest(
655
+ parsed.params.id,
656
+ { status, responses, approvals: votes, resolvedBy: responderId },
657
+ { requireActionableWorkflow: true },
658
+ );
659
+ if (!resolved && configWrites.length > 0) throw new ConfigRespondAbort("unavailable");
660
+ return resolved
661
+ ? { kind: "resolved" as const, request: resolved }
662
+ : { kind: "unavailable" as const };
663
+ }
664
+ const recorded = (await recordApprovalVotes(parsed.params.id, votes))
665
+ ? await getApprovalRequestById(parsed.params.id)
666
+ : null;
667
+ return recorded
668
+ ? { kind: "recorded" as const, request: recorded }
526
669
  : { kind: "unavailable" as const };
670
+ })
671
+ .catch((error: unknown) => {
672
+ if (!(error instanceof ConfigRespondAbort)) throw error;
673
+ // Rolled back: nothing was written.
674
+ configWrites.length = 0;
675
+ return error.kind === "conflict"
676
+ ? { kind: "conflict" as const, message: error.message }
677
+ : { kind: "unavailable" as const };
678
+ });
679
+
680
+ if (outcome.kind === "conflict") {
681
+ jsonError(res, outcome.message, 409);
682
+ return true;
683
+ }
684
+ // The transaction has committed: run what PUT /api/config runs after a write.
685
+ for (const { result, value } of configWrites) {
686
+ try {
687
+ await runConfigWriteSideEffects(result.scope, result.key, value);
688
+ } catch (err) {
689
+ console.error(
690
+ "[approval-requests] Config side effects failed:",
691
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
692
+ );
527
693
  }
528
- const recorded = (await recordApprovalVotes(parsed.params.id, votes))
529
- ? await getApprovalRequestById(parsed.params.id)
530
- : null;
531
- return recorded
532
- ? { kind: "recorded" as const, request: recorded }
533
- : { kind: "unavailable" as const };
534
- });
694
+ }
535
695
 
536
696
  if (outcome.kind === "duplicate") {
537
697
  jsonError(res, "You already answered this approval request", 409);
@@ -576,7 +736,7 @@ export async function handleApprovalRequests(
576
736
  await createApprovalFollowUpTask(updated, "hitl.follow_up");
577
737
 
578
738
  respondRoute.respond(res, 200, {
579
- approvalRequest: await toApprovalRequestResponse(updated),
739
+ approvalRequest: await toApprovalRequestResponse(updated, { withConfigState: true }),
580
740
  });
581
741
  return true;
582
742
  }
@@ -617,7 +777,7 @@ export async function handleApprovalRequests(
617
777
  }
618
778
 
619
779
  getByIdRoute.respond(res, 200, {
620
- approvalRequest: await toApprovalRequestResponse(request),
780
+ approvalRequest: await toApprovalRequestResponse(request, { withConfigState: true }),
621
781
  });
622
782
  return true;
623
783
  }
@@ -638,6 +798,15 @@ export async function handleApprovalRequests(
638
798
  }
639
799
  }
640
800
 
801
+ const normalized = await normalizeApprovalQuestions(parsed.body.questions, {
802
+ approvers: parsed.body.approvers,
803
+ });
804
+ if (!normalized.ok) {
805
+ jsonError(res, normalized.error, 400);
806
+ return true;
807
+ }
808
+ const questions = normalized.questions;
809
+
641
810
  const id = crypto.randomUUID();
642
811
  // Prefer a trusted authenticated user (never client-controlled); else fall
643
812
  // back to the ownership-validated sourceTaskId the request body carries.
@@ -651,7 +820,7 @@ export async function handleApprovalRequests(
651
820
  const request = await createApprovalRequest({
652
821
  id,
653
822
  title: parsed.body.title,
654
- questions: parsed.body.questions,
823
+ questions,
655
824
  approvers: parsed.body.approvers,
656
825
  workflowRunId: parsed.body.workflowRunId,
657
826
  workflowRunStepId: parsed.body.workflowRunStepId,
@@ -668,7 +837,7 @@ export async function handleApprovalRequests(
668
837
  dispatchApprovalNotifications({
669
838
  requestId: request.id,
670
839
  title: request.title,
671
- questions: parsed.body.questions,
840
+ questions,
672
841
  timeoutSeconds: parsed.body.timeoutSeconds,
673
842
  notifications,
674
843
  }).catch((err) => {
@@ -705,7 +874,9 @@ export async function handleApprovalRequests(
705
874
 
706
875
  const requests = await listApprovalRequests(filters);
707
876
  listRoute.respond(res, 200, {
708
- approvalRequests: await Promise.all(requests.map(toApprovalRequestResponse)),
877
+ approvalRequests: await Promise.all(
878
+ requests.map((request) => toApprovalRequestResponse(request)),
879
+ ),
709
880
  });
710
881
  return true;
711
882
  }
@@ -21,10 +21,7 @@ import {
21
21
  internalConfigKeyError,
22
22
  isApiOnlyConfigKey,
23
23
  isInternalConfigKey,
24
- isReservedConfigKey,
25
- reservedKeyError,
26
- validateConfigValue,
27
- validateExportToEnv,
24
+ validateConfigEntry,
28
25
  } from "../be/swarm-config-guard";
29
26
  import { can, isRbacEnabled, type PermissionVerb } from "../rbac";
30
27
  import { SwarmConfigSchema } from "../types";
@@ -145,6 +142,25 @@ export async function ensureConfigAdmin(
145
142
  );
146
143
  }
147
144
 
145
+ /**
146
+ * What a committed config write triggers beyond the row itself. Shared by
147
+ * `PUT /api/config` and HITL `config` answers.
148
+ */
149
+ export async function runConfigWriteSideEffects(
150
+ scope: "global" | "agent" | "repo",
151
+ key: string,
152
+ value: string,
153
+ ): Promise<void> {
154
+ if (scope !== "global") return;
155
+ if (key === "SWARM_ORG_WEBSITE") await fillOrgLogoFromWebsite(value);
156
+ await syncRankingAfterConfigChange(key, value);
157
+ // Auto-reload integrations when a global-scoped config changes so callers
158
+ // (CLI, automation, dashboard) don't have to remember to hit /reload.
159
+ // Debounced to coalesce sequential single-key upserts from the dashboard
160
+ // batch save (no bulk endpoint exists).
161
+ scheduleIntegrationsReload();
162
+ }
163
+
148
164
  /**
149
165
  * The general form of `ensureConfigAdmin`: operators and users pass, guests
150
166
  * get a plain 403, and an agent principal (session identity first, then the
@@ -484,28 +500,7 @@ export async function handleConfig(
484
500
  if (!(await ensureConfigAdmin(req, res, "config.write.any"))) return true;
485
501
  const { scope, scopeId, key, value, isSecret, envPath, description, exportToEnv } = parsed.body;
486
502
 
487
- if (scope === "global" && scopeId) {
488
- jsonError(res, "Global scope must not have scopeId", 400);
489
- return true;
490
- }
491
-
492
- if ((scope === "agent" || scope === "repo") && !scopeId) {
493
- jsonError(res, "Agent/repo scope requires scopeId", 400);
494
- return true;
495
- }
496
-
497
- if (isReservedConfigKey(key)) {
498
- jsonError(res, reservedKeyError(key).message, 400);
499
- return true;
500
- }
501
-
502
- if (isInternalConfigKey(key)) {
503
- jsonError(res, internalConfigKeyError(key).message, 400);
504
- return true;
505
- }
506
-
507
- const validationError =
508
- validateConfigValue(key, value) ?? validateExportToEnv(key, exportToEnv);
503
+ const validationError = validateConfigEntry({ scope, scopeId, key, value, exportToEnv });
509
504
  if (validationError) {
510
505
  jsonError(res, validationError, 400);
511
506
  return true;
@@ -523,17 +518,7 @@ export async function handleConfig(
523
518
  description: description || null,
524
519
  exportToEnv,
525
520
  });
526
- // Auto-reload integrations when a global-scoped config changes so callers
527
- // (CLI, automation, dashboard) don't have to remember to hit /reload.
528
- // Debounced to coalesce sequential single-key upserts from the dashboard
529
- // batch save (no bulk endpoint exists).
530
- if (scope === "global" && key === "SWARM_ORG_WEBSITE") {
531
- await fillOrgLogoFromWebsite(String(value));
532
- }
533
- if (scope === "global") await syncRankingAfterConfigChange(key, String(value));
534
- if (scope === "global") {
535
- scheduleIntegrationsReload();
536
- }
521
+ await runConfigWriteSideEffects(scope, key, String(value));
537
522
  const result = includeSecrets || !config.isSecret ? config : maskSecrets([config])[0]!;
538
523
  upsertConfig.respond(res, 200, result);
539
524
  } catch (error) {
@@ -502,7 +502,22 @@ const SessionHideSourceSchema = z
502
502
  .filter(Boolean),
503
503
  )
504
504
  .describe(
505
- "Comma-separated root sources to leave out (the sessions v2 source filter), e.g. `slack,chat`.",
505
+ "Comma-separated root sources to leave out, e.g. `slack,chat`. Older sessions v2 clients send this; `showSource` replaces it.",
506
+ );
507
+
508
+ const SessionShowSourceSchema = z
509
+ .string()
510
+ // The same cap as the `showSource` body field of `POST /api/session-triage`.
511
+ .refine((value) => value.split(",").length <= 32, "showSource takes at most 32 sources.")
512
+ .optional()
513
+ .transform((value) =>
514
+ value
515
+ ?.split(",")
516
+ .map((source) => source.trim())
517
+ .filter(Boolean),
518
+ )
519
+ .describe(
520
+ "Comma-separated root sources to keep (the sessions v2 source filter), e.g. `ui,slack`. Present and empty keeps none; omit to keep every source.",
506
521
  );
507
522
 
508
523
  const listSessions = route({
@@ -568,6 +583,8 @@ const listSessions = route({
568
583
  show: SessionShowSchema,
569
584
  /** Root sources to leave out. Must match the tree's `hideSource`. */
570
585
  hideSource: SessionHideSourceSchema,
586
+ /** Root sources to keep. Must match the tree's `showSource`. */
587
+ showSource: SessionShowSourceSchema,
571
588
  }),
572
589
  responses: {
573
590
  200: {
@@ -590,7 +607,11 @@ const sessionTree = route({
590
607
  pattern: ["api", "session-tree"],
591
608
  summary: "Get session groups and triage counts",
592
609
  tags: ["Sessions"],
593
- query: z.object({ show: SessionShowSchema, hideSource: SessionHideSourceSchema }),
610
+ query: z.object({
611
+ show: SessionShowSchema,
612
+ hideSource: SessionHideSourceSchema,
613
+ showSource: SessionShowSourceSchema,
614
+ }),
594
615
  responses: {
595
616
  200: { description: "Visible groups and section counts", schema: SessionTreeResponseSchema },
596
617
  401: { description: "Unauthorized" },
@@ -615,6 +636,8 @@ const triageSessionListRoute = route({
615
636
  groupKey: SessionKeyFilterSchema.optional(),
616
637
  show: SessionShowSchema,
617
638
  hideSource: z.array(z.string().min(1)).max(32).optional(),
639
+ /** Root sources to keep; an empty array keeps none. */
640
+ showSource: z.array(z.string().min(1)).max(32).optional(),
618
641
  })
619
642
  .refine((body) => [body.triage, body.noGroup, body.groupKey].filter(Boolean).length === 1, {
620
643
  message: "Name exactly one list: triage, noGroup, or groupKey",
@@ -941,6 +964,7 @@ export async function handleSessions(
941
964
  const filterOpts = {
942
965
  source: sources,
943
966
  hideSource: parsed.query.hideSource,
967
+ showSource: parsed.query.showSource,
944
968
  q: parsed.query.q,
945
969
  requestedByUserId: parsed.query.requestedByUserId,
946
970
  contextKeyPrefix: parsed.query.contextKeyPrefix,
@@ -994,6 +1018,7 @@ export async function handleSessions(
994
1018
  await getSessionTree(actor, undefined, {
995
1019
  mine: parsed.query.show !== "all",
996
1020
  hideSource: parsed.query.hideSource,
1021
+ showSource: parsed.query.showSource,
997
1022
  }),
998
1023
  );
999
1024
  return true;
@@ -188,9 +188,10 @@ export async function handleChatInbound(
188
188
  }
189
189
 
190
190
  if (text || attachments.length > 0) {
191
- // Subscribed-thread messages may be dropped by the router at flush time;
192
- // they get typing and a reaction once a task exists (see flushChatThread).
193
- const acknowledge = kind !== "subscribed";
191
+ // An unaddressed subscribed-thread message may be dropped by the router at
192
+ // flush time; it gets typing and a reaction once a task exists (see
193
+ // flushChatThread). A mention, reply to the bot, or DM is acknowledged now.
194
+ const acknowledge = kind !== "subscribed" || isDirectMessage || mentioned || facts.replyToBot;
194
195
  bufferChatMessage(thread.id, {
195
196
  messageId: message.id,
196
197
  channelId: thread.channelId,
@@ -580,7 +581,7 @@ export async function flushChatThread(
580
581
  if (created > 0) {
581
582
  await rememberChatThreadRequester(threadId, requester.userId);
582
583
  await subscribe(thread);
583
- // Subscribed-thread messages start typing only here, once a task exists.
584
+ // Unaddressed subscribed-thread messages start typing only here, once a task exists.
584
585
  startTypingKeepalive(adapter, threadId);
585
586
  await clearChatMessageReactions(
586
587
  adapter,
@@ -44,7 +44,7 @@ import type {
44
44
  * default; the image installs exactly this version and the per-task settings
45
45
  * set `amp.updates.mode: "disabled"`. Bump it deliberately, with a live run.
46
46
  */
47
- export const AMP_PACKAGE = "@ampcode/cli@0.0.1792728062-g40cf3f";
47
+ export const AMP_PACKAGE = "@ampcode/cli@0.0.1792756861-g6187a2";
48
48
 
49
49
  /** MCP server name. Amp exposes its tools as `agent_swarm` / `store_progress` (dashes become underscores). */
50
50
  const AMP_MCP_SERVER_NAME = "agent-swarm";
@@ -37,7 +37,7 @@ import type {
37
37
  } from "./types";
38
38
 
39
39
  // npm's `latest` is older and lacks the JSON/stdin contract used here.
40
- export const DSH_PACKAGE = "@deepseek-ai/dsh@0.2.1-rc.2";
40
+ export const DSH_PACKAGE = "@deepseek-ai/dsh@0.2.1-rc.3";
41
41
 
42
42
  export function checkDshCredentials(env: Record<string, string | undefined>): CredStatus {
43
43
  return env.DEEPSEEK_API_KEY?.trim() || env.OPENROUTER_API_KEY?.trim()