@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
@@ -15,6 +15,7 @@ import {
15
15
  import { explicitModelErrorForAgent } from "@/be/model-validation";
16
16
  import { mergeScheduleTiming, validateRecurringTiming } from "@/be/schedules/validate";
17
17
  import { getScript } from "@/be/scripts/db";
18
+ import { can } from "@/rbac";
18
19
  import { calculateNextRun } from "@/scheduler";
19
20
  import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "@/tools/utils";
20
21
  import {
@@ -173,6 +174,23 @@ export const registerUpdateScheduleTool = (server: McpServer) => {
173
174
  return toolErr("Agent not found.");
174
175
  }
175
176
 
177
+ // A deferral continues its owner's task, in that task's chat thread when
178
+ // it has one (src/scheduler/schedule-task.ts). Only the owner or a lead
179
+ // may change it.
180
+ if (
181
+ schedule.taskType === "deferred" &&
182
+ !can({
183
+ principal: { kind: "agent", agentId: caller.id, isLead: caller.isLead },
184
+ verb: "schedule.deferral.update.any",
185
+ resource: { kind: "owned", ownerAgentId: schedule.createdByAgentId },
186
+ source: "mcp",
187
+ }).allow
188
+ ) {
189
+ return toolErr(
190
+ `Schedule "${schedule.name}" defers another agent's task. Only that agent or a lead can change it.`,
191
+ );
192
+ }
193
+
176
194
  // Reject updates on completed one-time schedules
177
195
  if (schedule.scheduleType === "one_time" && !schedule.enabled && schedule.lastRunAt) {
178
196
  return toolErr(
package/src/types.ts CHANGED
@@ -1076,6 +1076,22 @@ export const UserSchema = z
1076
1076
 
1077
1077
  export type User = z.infer<typeof UserSchema>;
1078
1078
 
1079
+ export const RbacRoleSchema = z
1080
+ .object({
1081
+ id: z.string(),
1082
+ name: z.string(),
1083
+ description: z.string().nullable(),
1084
+ isBuiltin: z.boolean(),
1085
+ grantsAll: z.boolean(),
1086
+ primary: z.boolean(),
1087
+ verbs: z.array(z.string()),
1088
+ })
1089
+ .openapi("RbacRole");
1090
+
1091
+ export const UserRbacRolesSchema = z
1092
+ .object({ userId: z.string(), roles: z.array(RbacRoleSchema) })
1093
+ .openapi("UserRbacRoles");
1094
+
1079
1095
  /**
1080
1096
  * Structured communication preferences read from `users.metadata.comms`.
1081
1097
  * All fields are free-form strings; values render verbatim into the requester
@@ -2942,6 +2958,13 @@ export const WorkflowRunSchema = z
2942
2958
  finishedAt: z.string().optional(),
2943
2959
  /** The `sub-workflow` step that started this run. Unset on a top-level run. */
2944
2960
  parentStepId: z.string().optional(),
2961
+ /**
2962
+ * The parent step's attempt (its `retryCount`) that started this run. A
2963
+ * step has at most one child per attempt, so `(parentStepId,
2964
+ * parentStepAttempt)` identifies the invocation; a node `retry` adds a
2965
+ * child with the next attempt. Unset on a top-level run.
2966
+ */
2967
+ parentStepAttempt: z.number().int().min(0).optional(),
2945
2968
  })
2946
2969
  .openapi("WorkflowRun");
2947
2970
 
@@ -1,16 +1,235 @@
1
+ import { formatConfigResult } from "../be/approval-config-question";
1
2
  import {
2
3
  type ApprovalRequest,
3
4
  claimApprovalCancellationNotification,
4
5
  completeApprovalCancellationNotificationClaim,
5
6
  createTaskExtended,
7
+ getApprovalRequestById,
6
8
  getTaskById,
7
9
  releaseApprovalCancellationNotificationClaim,
10
+ updateApprovalRequestNotifications,
8
11
  } from "../be/db";
9
12
  import { resolveTemplate } from "../prompts/resolver";
10
13
  import { getSlackApp } from "../slack/app";
14
+ import { resolveSlackConversationId } from "../slack/dm";
15
+ import {
16
+ externalChannelToolError,
17
+ getChannelBlockReason,
18
+ isBlockExternalEnabled,
19
+ } from "../slack/external-channel";
11
20
  import { TERMINAL_TASK_STATUSES } from "../types";
21
+ import { getAppUrl } from "../utils/constants";
12
22
  import { scrubSecrets } from "../utils/secret-scrubber";
13
23
 
24
+ /** Slack Block Kit caps a section block's text at 3000 characters. */
25
+ const SLACK_SECTION_TEXT_LIMIT = 3000;
26
+
27
+ function escapeSlackMrkdwn(text: string): string {
28
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
29
+ }
30
+
31
+ /**
32
+ * Render the Slack "Questions" section text within Block Kit's 3000-character
33
+ * section limit. Labels are escaped so upstream text cannot inject mentions or
34
+ * links. When the list does not fit, the tail is replaced by a count; every
35
+ * question stays answerable on the dashboard page the card's button opens.
36
+ */
37
+ export function buildSlackQuestionsSummary(
38
+ questions: ReadonlyArray<{ label?: string; type?: string }>,
39
+ timeoutText = "",
40
+ ): string {
41
+ const header = "*Questions:*\n";
42
+ // A config question is only ever answered on the dashboard, never in Slack.
43
+ const lines = questions.map(
44
+ (q) =>
45
+ `• ${escapeSlackMrkdwn((q.label ?? "").replace(/\s+/g, " ").trim())}${q.type === "config" ? " (set on dashboard)" : ""}`,
46
+ );
47
+ const fits = (shown: string[], omitted: number) => {
48
+ const tail =
49
+ omitted > 0 ? `\n_…and ${omitted} more. Answer all of them on the dashboard._` : "";
50
+ const text = `${header}${shown.join("\n")}${tail}${timeoutText}`;
51
+ return text.length <= SLACK_SECTION_TEXT_LIMIT ? text : null;
52
+ };
53
+
54
+ const full = fits(lines, 0);
55
+ if (full) return full;
56
+ for (let count = lines.length - 1; count >= 0; count--) {
57
+ const text = fits(lines.slice(0, count), lines.length - count);
58
+ if (text) return text;
59
+ }
60
+ return `${header}_${questions.length} questions. Answer them on the dashboard._`;
61
+ }
62
+
63
+ function formatTimeout(seconds: number): string {
64
+ if (seconds < 60) return `${seconds}s`;
65
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
66
+ const hours = Math.floor(seconds / 3600);
67
+ const mins = Math.round((seconds % 3600) / 60);
68
+ return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
69
+ }
70
+
71
+ export interface ApprovalNotificationChannel {
72
+ channel: "slack" | "email";
73
+ target: string;
74
+ }
75
+
76
+ export interface ApprovalNotificationsInput {
77
+ requestId: string;
78
+ title: string;
79
+ questions: ReadonlyArray<{ label?: string; type?: string }>;
80
+ timeoutSeconds?: number;
81
+ notifications: ReadonlyArray<ApprovalNotificationChannel>;
82
+ }
83
+
84
+ /** The DB calls the dispatch makes; the HITL executor injects its own. */
85
+ export interface ApprovalNotificationsDb {
86
+ updateApprovalRequestNotifications: typeof updateApprovalRequestNotifications;
87
+ getApprovalRequestById: typeof getApprovalRequestById;
88
+ }
89
+
90
+ /**
91
+ * Post the "Approval Required" card for each notification channel and store
92
+ * the Slack `messageTs` on the request. Used by the workflow HITL executor and
93
+ * by `POST /api/approval-requests`, which checks the caller may post to Slack.
94
+ * Callers fire and forget: a Slack failure is logged and never fails the
95
+ * request. Email is not supported yet and is skipped.
96
+ */
97
+ export async function dispatchApprovalNotifications(
98
+ input: ApprovalNotificationsInput,
99
+ db: ApprovalNotificationsDb = { updateApprovalRequestNotifications, getApprovalRequestById },
100
+ ): Promise<void> {
101
+ const { requestId, title, questions, timeoutSeconds, notifications } = input;
102
+ if (!notifications.length) return;
103
+
104
+ const approvalUrl = `${getAppUrl()}/approval-requests/${requestId}`;
105
+ const updatedChannels: Array<ApprovalNotificationChannel & { messageTs?: string }> = [
106
+ ...notifications,
107
+ ];
108
+ let updated = false;
109
+
110
+ for (let i = 0; i < notifications.length; i++) {
111
+ const notification = notifications[i]!;
112
+
113
+ if (notification.channel === "email") {
114
+ console.warn(`[HITL] Email notifications not yet supported (target: ${notification.target})`);
115
+ continue;
116
+ }
117
+
118
+ if (notification.channel === "slack") {
119
+ try {
120
+ const slackApp = getSlackApp();
121
+ if (!slackApp) {
122
+ console.warn("[HITL] Slack not initialized — cannot send notification");
123
+ continue;
124
+ }
125
+
126
+ // Same guard as the Slack tools: never post into a channel shared with another org.
127
+ if (isBlockExternalEnabled()) {
128
+ const conversationId = await resolveSlackConversationId(
129
+ slackApp.client,
130
+ notification.target,
131
+ );
132
+ const blockReason = await getChannelBlockReason(slackApp.client, conversationId);
133
+ if (blockReason) {
134
+ console.warn(`[HITL] ${externalChannelToolError(conversationId, blockReason)}`);
135
+ continue;
136
+ }
137
+ }
138
+
139
+ const questionsText = buildSlackQuestionsSummary(questions);
140
+
141
+ // The deadline is a caption under the button, not part of the ask.
142
+ const timeoutCaption =
143
+ timeoutSeconds !== undefined
144
+ ? [
145
+ {
146
+ type: "context",
147
+ elements: [
148
+ {
149
+ type: "mrkdwn",
150
+ text: `⏱ Timeout: ${formatTimeout(timeoutSeconds)} — times out if not responded`,
151
+ },
152
+ ],
153
+ },
154
+ ]
155
+ : [];
156
+
157
+ const blocks = [
158
+ {
159
+ type: "section",
160
+ text: {
161
+ type: "mrkdwn",
162
+ text: `🔔 *Approval Required: ${escapeSlackMrkdwn(title)}*`,
163
+ },
164
+ },
165
+ {
166
+ type: "section",
167
+ text: {
168
+ type: "mrkdwn",
169
+ text: questionsText,
170
+ },
171
+ },
172
+ {
173
+ type: "actions",
174
+ elements: [
175
+ {
176
+ type: "button",
177
+ text: { type: "plain_text", text: "Review & Respond" },
178
+ url: approvalUrl,
179
+ style: "primary",
180
+ },
181
+ ],
182
+ },
183
+ ...timeoutCaption,
184
+ ];
185
+
186
+ const result = await slackApp.client.chat.postMessage({
187
+ channel: notification.target,
188
+ text: `Approval Required: ${escapeSlackMrkdwn(title)} — ${approvalUrl}`,
189
+ unfurl_links: false,
190
+ unfurl_media: false,
191
+ // biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
192
+ blocks: blocks as any,
193
+ });
194
+
195
+ if (result.ts) {
196
+ updatedChannels[i] = { ...notification, messageTs: result.ts };
197
+ updated = true;
198
+ }
199
+
200
+ console.log(
201
+ `[HITL] Slack notification sent to ${notification.target} for request ${requestId}`,
202
+ );
203
+ } catch (err) {
204
+ console.error(
205
+ `[HITL] Failed to send Slack notification to ${notification.target}:`,
206
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
207
+ );
208
+ }
209
+ }
210
+ }
211
+
212
+ // Persist messageTs values back to DB
213
+ if (updated) {
214
+ try {
215
+ await db.updateApprovalRequestNotifications(requestId, updatedChannels);
216
+ // A cancel that landed while the card was posting found no messageTs to thread under.
217
+ const latest = await db.getApprovalRequestById(requestId);
218
+ if (latest?.status === "cancelled") {
219
+ await postApprovalCancellationUpdates(
220
+ [latest],
221
+ latest.resolutionReason ?? "Workflow run cancelled",
222
+ );
223
+ }
224
+ } catch (err) {
225
+ console.error(
226
+ "[HITL] Failed to update notification channels in DB:",
227
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
228
+ );
229
+ }
230
+ }
231
+ }
232
+
14
233
  export interface ApprovalSlackClient {
15
234
  chat: {
16
235
  postMessage(input: {
@@ -141,6 +360,8 @@ function formatResponses(
141
360
  let answerText: string;
142
361
  if (answer == null) {
143
362
  answerText = "(no answer)";
363
+ } else if (q.type === "config") {
364
+ answerText = formatConfigResult(answer) ?? "(unreadable config result)";
144
365
  } else if (q.type === "approval") {
145
366
  const a = answer as { approved?: boolean; comment?: string };
146
367
  answerText = a.approved ? "Approved" : "Rejected";
@@ -1,8 +1,30 @@
1
1
  import { getDbClient, getWorkflowRun, updateWorkflowRun, updateWorkflowRunStep } from "../be/db";
2
2
  import type { RetryPolicy } from "../types";
3
3
 
4
+ /**
5
+ * The execution a checkpoint belongs to: the step attempt (`retryCount`) that
6
+ * claimed the row. A fenced checkpoint writes only while that attempt still
7
+ * owns the row: the step is `running` with that `retryCount` in a `running` or
8
+ * `waiting` run. A cancel, a newer attempt or another settle that committed
9
+ * meanwhile wins, and the stale execution writes nothing.
10
+ */
11
+ export type StepFence = { retryCount: number };
12
+
13
+ async function holdsFence(runId: string, stepId: string, fence: StepFence): Promise<boolean> {
14
+ const row = await getDbClient().get<{ found: number }>(
15
+ `SELECT 1 AS found
16
+ FROM workflow_run_steps s
17
+ JOIN workflow_runs r ON r.id = s.runId
18
+ WHERE s.id = ? AND s.runId = ? AND s.status = 'running' AND s.retryCount = ?
19
+ AND r.status IN ('running', 'waiting')`,
20
+ [stepId, runId, fence.retryCount],
21
+ );
22
+ return row != null;
23
+ }
24
+
4
25
  /**
5
26
  * Checkpoint a successful step — atomic DB write of step result + run context.
27
+ * Returns false, writing nothing, when `fence` no longer holds.
6
28
  */
7
29
  export async function checkpointStep(
8
30
  runId: string,
@@ -10,8 +32,10 @@ export async function checkpointStep(
10
32
  nodeId: string,
11
33
  result: { output?: unknown; nextPort?: string },
12
34
  ctx: Record<string, unknown>,
13
- ): Promise<void> {
14
- await getDbClient().transaction(async () => {
35
+ options: { fence?: StepFence } = {},
36
+ ): Promise<boolean> {
37
+ return getDbClient().transaction(async () => {
38
+ if (options.fence && !(await holdsFence(runId, stepId, options.fence))) return false;
15
39
  await updateWorkflowRunStep(stepId, {
16
40
  status: "completed",
17
41
  output: result.output,
@@ -24,11 +48,14 @@ export async function checkpointStep(
24
48
  await updateWorkflowRun(runId, {
25
49
  context: ctx,
26
50
  });
51
+ return true;
27
52
  });
28
53
  }
29
54
 
30
55
  /**
31
56
  * Checkpoint a step failure — marks step failed, calculates retry if applicable.
57
+ * With `fence`, a stale execution writes nothing and gets `stale: true`.
58
+ * `output` is kept on the failed step for observability, in the same write.
32
59
  */
33
60
  export async function checkpointStepFailure(
34
61
  runId: string,
@@ -36,49 +63,56 @@ export async function checkpointStepFailure(
36
63
  error: string,
37
64
  retryCount: number,
38
65
  retryPolicy?: RetryPolicy,
39
- options?: { markRunFailed?: boolean },
40
- ): Promise<{ shouldRetry: boolean }> {
66
+ options?: { markRunFailed?: boolean; fence?: StepFence; output?: unknown },
67
+ ): Promise<{ shouldRetry: boolean; stale?: true }> {
41
68
  const now = new Date().toISOString();
42
69
 
43
- if (retryPolicy && retryCount < retryPolicy.maxRetries) {
44
- const delay = calculateBackoff(retryPolicy, retryCount);
45
- const nextRetryAt = new Date(Date.now() + delay).toISOString();
70
+ return getDbClient().transaction(async () => {
71
+ if (options?.fence && !(await holdsFence(runId, stepId, options.fence))) {
72
+ return { shouldRetry: false, stale: true as const };
73
+ }
74
+
75
+ if (retryPolicy && retryCount < retryPolicy.maxRetries) {
76
+ const delay = calculateBackoff(retryPolicy, retryCount);
77
+ const nextRetryAt = new Date(Date.now() + delay).toISOString();
78
+
79
+ await updateWorkflowRunStep(stepId, {
80
+ status: "failed",
81
+ error,
82
+ output: options?.output,
83
+ retryCount: retryCount + 1,
84
+ maxRetries: retryPolicy.maxRetries,
85
+ nextRetryAt,
86
+ });
87
+
88
+ return { shouldRetry: true };
89
+ }
46
90
 
91
+ // No retries left — mark step failed, and optionally the run too
92
+ // Clear nextRetryAt so the poller stops picking this step up
47
93
  await updateWorkflowRunStep(stepId, {
48
94
  status: "failed",
49
95
  error,
50
- retryCount: retryCount + 1,
51
- maxRetries: retryPolicy.maxRetries,
52
- nextRetryAt,
96
+ output: options?.output,
97
+ finishedAt: now,
98
+ nextRetryAt: null,
53
99
  });
54
100
 
55
- return { shouldRetry: true };
56
- }
57
-
58
- // No retries left — mark step failed, and optionally the run too
59
- // Clear nextRetryAt so the poller stops picking this step up
60
- await updateWorkflowRunStep(stepId, {
61
- status: "failed",
62
- error,
63
- finishedAt: now,
64
- nextRetryAt: null,
65
- });
66
-
67
- const markRunFailed = options?.markRunFailed ?? true;
68
- if (markRunFailed) {
69
- // A cancel or finalization that committed while the step ran wins.
70
- await getDbClient().transaction(async () => {
101
+ const markRunFailed = options?.markRunFailed ?? true;
102
+ if (markRunFailed) {
103
+ // A cancel or finalization that committed while the step ran wins.
71
104
  const run = await getWorkflowRun(runId);
72
- if (run?.status !== "running" && run?.status !== "waiting") return;
73
- await updateWorkflowRun(runId, {
74
- status: "failed",
75
- error: `Step failed: ${error}`,
76
- finishedAt: now,
77
- });
78
- });
79
- }
105
+ if (run?.status === "running" || run?.status === "waiting") {
106
+ await updateWorkflowRun(runId, {
107
+ status: "failed",
108
+ error: `Step failed: ${error}`,
109
+ finishedAt: now,
110
+ });
111
+ }
112
+ }
80
113
 
81
- return { shouldRetry: false };
114
+ return { shouldRetry: false };
115
+ });
82
116
  }
83
117
 
84
118
  /**