@desplega.ai/agent-swarm 1.167.0 → 1.169.0

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 (184) hide show
  1. package/dist/{acp-adapter-zpvy6321.js → acp-adapter-m47zfx4a.js} +2 -2
  2. package/dist/{actions-fq2zrwtf.js → actions-ws7k3tvq.js} +25 -19
  3. package/dist/{amp-adapter-5gbk7501.js → amp-adapter-nvv8k8j5.js} +1 -1
  4. package/dist/{app-nctetn1e.js → app-80w6qspx.js} +5 -5
  5. package/dist/{assistant-0aq8gjvj.js → assistant-jtq439kw.js} +29 -20
  6. package/dist/{boot-reembed-1ze5853n.js → boot-reembed-3nc0aaed.js} +4 -4
  7. package/dist/{boot-reembed-fmp5ww69.js → boot-reembed-feda8wn8.js} +4 -4
  8. package/dist/{boot-scrub-sweep-cbp8xd4e.js → boot-scrub-sweep-grsa2er0.js} +5 -5
  9. package/dist/{boot-seeds-22tx8a53.js → boot-seeds-gcfgq44f.js} +7 -7
  10. package/dist/{claude-adapter-0jhgeh1d.js → claude-adapter-pjv2q7t8.js} +7 -7
  11. package/dist/{claude-managed-adapter-tzwxy803.js → claude-managed-adapter-0wa8se82.js} +2 -2
  12. package/dist/{claude-managed-setup-nrhfx5b0.js → claude-managed-setup-zxa9p9tx.js} +1 -1
  13. package/dist/{claude-sdk-session-wh77rh83.js → claude-sdk-session-c9j6k9kx.js} +8772 -8663
  14. package/dist/{cli-c2tpw9ck.js → cli-3ry5gx4f.js} +1 -1
  15. package/dist/{cli-wydz3feg.js → cli-61tbnjpf.js} +214 -2472
  16. package/dist/{cli-cexctgn5.js → cli-7vz49g7h.js} +101 -34
  17. package/dist/{cli-6qxmjpem.js → cli-8xjst8q2.js} +34 -22
  18. package/dist/{cli-tx2zvn23.js → cli-91r17hb1.js} +3 -3
  19. package/dist/{cli-577bxkf6.js → cli-a8vps458.js} +182 -2208
  20. package/dist/{cli-tc5zec7v.js → cli-b7y8113e.js} +1 -1
  21. package/dist/{cli-69egj654.js → cli-bqrs5ycj.js} +1 -1
  22. package/dist/{cli-cf2vgn9d.js → cli-dv76vq5d.js} +30 -28
  23. package/dist/{cli-g5erf0zt.js → cli-e2prcx5m.js} +31 -0
  24. package/dist/{cli-c8r99akr.js → cli-e5qk3m2a.js} +209 -31
  25. package/dist/{cli-z1dqhc9y.js → cli-eqfbsxsn.js} +3 -3
  26. package/dist/{cli-kw0jcfje.js → cli-fyeft04q.js} +1 -1
  27. package/dist/{cli-5qkcdv8c.js → cli-gmdajfw5.js} +3 -3
  28. package/dist/{cli-ypgba1a8.js → cli-jkfxpg1b.js} +1 -1
  29. package/dist/{cli-mxvnnv5q.js → cli-jv36y8wz.js} +1 -1
  30. package/dist/{cli-m7vmeepg.js → cli-km8559dm.js} +6 -6
  31. package/dist/{cli-ykt8f7va.js → cli-kp3vfknv.js} +1 -1
  32. package/dist/{cli-ymmc1zxg.js → cli-m23r88re.js} +8 -5
  33. package/dist/{cli-v1pwxreq.js → cli-n4z21pt8.js} +1 -1
  34. package/dist/{cli-mxry91sa.js → cli-qg2zhjfc.js} +73 -4
  35. package/dist/{cli-1yrcfa5s.js → cli-rbr91whd.js} +4 -4
  36. package/dist/{cli-mkdsy1nq.js → cli-rg1c8ccw.js} +93 -7
  37. package/dist/cli-rh44xx46.js +2659 -0
  38. package/dist/cli-s0k2j7q9.js +2084 -0
  39. package/dist/{cli-ty4bzkqr.js → cli-sajr098b.js} +1 -1
  40. package/dist/{cli-6vfz44sr.js → cli-swrz3mj8.js} +1 -1
  41. package/dist/{cli-p8rt8spp.js → cli-vejds37t.js} +2 -2
  42. package/dist/{cli-jbzbr36n.js → cli-w6xa5nf5.js} +1 -1
  43. package/dist/{cli-9q3wt23f.js → cli-x6z90nq9.js} +1 -1
  44. package/dist/{cli-qzmzwegq.js → cli-ysnkvdx6.js} +102 -9
  45. package/dist/{cli-y0kjpfm9.js → cli-yvxfaem2.js} +2 -2
  46. package/dist/{cli-pkj1w46r.js → cli-z48ggfyq.js} +1 -1
  47. package/dist/{cli-6bpvv8h4.js → cli-zzev32jw.js} +1 -1
  48. package/dist/cli.js +20 -17
  49. package/dist/{codex-adapter-cgesnjkr.js → codex-adapter-0kpg09ca.js} +3 -3
  50. package/dist/{codex-hook-15dgc4cb.js → codex-hook-295jpbfa.js} +2 -2
  51. package/dist/{codex-session-runner-8kd7ddhr.js → codex-session-runner-8n672hxh.js} +3 -3
  52. package/dist/{commands-h6zaaxym.js → commands-mhmg5n0x.js} +4 -4
  53. package/dist/{cursor-adapter-v8mm6bp5.js → cursor-adapter-wn8n5zzk.js} +2 -2
  54. package/dist/{db-qg3myn53.js → db-wa6s1mwv.js} +4 -4
  55. package/dist/{dispatcher-7vnwj9qw.js → dispatcher-nhs2pkr8.js} +16 -14
  56. package/dist/{dsh-adapter-tz6hfp5r.js → dsh-adapter-5423p70m.js} +1 -1
  57. package/dist/{grok-adapter-jdt2dz2s.js → grok-adapter-28pkdt80.js} +3 -3
  58. package/dist/{handlers-2qtkpncr.js → handlers-5rtf9nng.js} +19 -17
  59. package/dist/{hook-51yfcgsv.js → hook-krjfkpzy.js} +4 -4
  60. package/dist/{hook-qnbdgwvd.js → hook-qkm73zk6.js} +5 -5
  61. package/dist/{http-jsmtag08.js → http-a397xcn4.js} +4845 -3484
  62. package/dist/{index-2t9kesn1.js → index-ac0v8v9d.js} +4 -4
  63. package/dist/{index-ekywyv2v.js → index-kqre45fr.js} +16 -14
  64. package/dist/{index-bt8wkv1w.js → index-rtxg15ah.js} +17 -15
  65. package/dist/{index-t4g06w93.js → index-vvsq7v45.js} +26 -23
  66. package/dist/{index-cx1sb9bq.js → index-ytdhtdry.js} +50 -11
  67. package/dist/{keepalive-4aezx4bj.js → keepalive-psfh8ysx.js} +6 -6
  68. package/dist/{lead-r44bkpm5.js → lead-m6rppjn7.js} +29 -27
  69. package/dist/{maintenance-grz5jrzh.js → maintenance-41f837ke.js} +4 -4
  70. package/dist/{node-qm9eb6z0.js → node-fkajaq96.js} +1 -1
  71. package/dist/{oauth-refresh-sweep-wm4ctjfj.js → oauth-refresh-sweep-6hr55hm8.js} +5 -5
  72. package/dist/{onboard-v60zgqf0.js → onboard-ktrtd7cz.js} +2 -2
  73. package/dist/{otel-impl-ghh4dyf8.js → otel-impl-dq4f2k3m.js} +1 -1
  74. package/dist/{pi-mono-adapter-0eg8kw2c.js → pi-mono-adapter-e5v9w0hv.js} +3 -2
  75. package/dist/{pricing-refresh-mjrqvnvw.js → pricing-refresh-kmk7j1ra.js} +6 -6
  76. package/dist/{rbac-roles-fd5tskj9.js → rbac-roles-9w1dkkxd.js} +4 -4
  77. package/dist/{rbac-roles-axv4d74x.js → rbac-roles-9ykx9tc5.js} +5 -5
  78. package/dist/{render-v2-k50j33qd.js → render-v2-ycqm53np.js} +5 -5
  79. package/dist/resume-cq2pkp8q.js +70 -0
  80. package/dist/{setup-4syh0pd2.js → setup-0dcmdvbn.js} +2 -2
  81. package/dist/{virtual-modules-ftr9kwe8.js → virtual-modules-1x9k4jcs.js} +1 -1
  82. package/dist/{work-objects-jcdbg8rr.js → work-objects-x01x2fh8.js} +19 -17
  83. package/dist/{worker-x8j8c9sx.js → worker-py1ssb1h.js} +29 -27
  84. package/openapi.json +15622 -15042
  85. package/package.json +8 -5
  86. package/src/apps/store.ts +1 -0
  87. package/src/artifact-sdk/feedback-overlay.ts +528 -0
  88. package/src/be/db/workflow-runs.ts +95 -0
  89. package/src/be/db-queries/slack-inbound.ts +489 -0
  90. package/src/be/db.ts +9 -2
  91. package/src/be/extensions/assets.ts +1 -1
  92. package/src/be/migrations/203_workflow_run_parent_step.sql +10 -0
  93. package/src/be/migrations/204_slack_inbound_receipts.sql +51 -0
  94. package/src/be/script-connections.ts +41 -7
  95. package/src/be/scripts/run-saved.ts +40 -20
  96. package/src/be/swarm-config-guard.ts +4 -0
  97. package/src/extensions/catalog.generated.json +17 -0
  98. package/src/extensions/catalog.ts +1 -1
  99. package/src/extensions/dispatcher.ts +3 -3
  100. package/src/heartbeat/heartbeat.ts +152 -2
  101. package/src/heartbeat/templates.ts +16 -0
  102. package/src/http/all-routes.ts +1 -0
  103. package/src/http/index.ts +2 -0
  104. package/src/http/integrations.ts +3 -1
  105. package/src/http/mcp-bridge.ts +1 -0
  106. package/src/http/pages-public.ts +7 -2
  107. package/src/http/pages.ts +178 -0
  108. package/src/http/script-connections.ts +35 -0
  109. package/src/http/slack-inbound.ts +107 -0
  110. package/src/http/tasks.ts +45 -27
  111. package/src/http/users.ts +1 -1
  112. package/src/http/webhooks.ts +20 -20
  113. package/src/prompts/session-templates.ts +32 -0
  114. package/src/providers/acp-targets.ts +1 -1
  115. package/src/providers/amp-adapter.ts +2 -2
  116. package/src/providers/claude-adapter.ts +6 -0
  117. package/src/providers/claude-background-keepalive.ts +100 -0
  118. package/src/providers/claude-managed-adapter.ts +2 -11
  119. package/src/providers/claude-sdk-session.ts +7 -1
  120. package/src/providers/dsh-adapter.ts +1 -1
  121. package/src/providers/opencode-adapter.ts +1 -2
  122. package/src/providers/pi-mono-adapter.ts +3 -3
  123. package/src/realtime/browser.generated.txt +8 -8
  124. package/src/realtime/browser.ts +3 -3
  125. package/src/scheduler/scheduler.ts +82 -13
  126. package/src/scripts-runtime/executors/quickjs-pool-config.ts +26 -0
  127. package/src/scripts-runtime/executors/quickjs.ts +9 -2
  128. package/src/scripts-runtime/executors/registry.ts +1 -1
  129. package/src/server.ts +1 -0
  130. package/src/slack/actions.ts +6 -2
  131. package/src/slack/app.ts +5 -0
  132. package/src/slack/assistant.ts +15 -5
  133. package/src/slack/egress-scrub.ts +3 -2
  134. package/src/slack/handlers.ts +36 -18
  135. package/src/slack/inbound-dispatch.ts +549 -0
  136. package/src/slack/inbound-files.ts +2 -0
  137. package/src/telemetry-context.ts +1 -0
  138. package/src/tests/claude-background-keepalive-wiring.test.ts +220 -0
  139. package/src/tests/claude-background-keepalive.fixture.ts +162 -0
  140. package/src/tests/claude-background-keepalive.test.ts +139 -0
  141. package/src/tests/codex-oauth-device.test.ts +1 -1
  142. package/src/tests/connection-embedded-auth.test.ts +51 -0
  143. package/src/tests/docs-index-footprint.test.js +3 -3
  144. package/src/tests/dsh-adapter.test.ts +1 -1
  145. package/src/tests/extensions-catalog.test.ts +1 -0
  146. package/src/tests/extensions-example-slack-reply-gate.test.ts +756 -0
  147. package/src/tests/heartbeat-checklist.test.ts +243 -0
  148. package/src/tests/pages-feedback.test.ts +218 -0
  149. package/src/tests/schedule-target-type.test.ts +136 -0
  150. package/src/tests/script-connections-http.test.ts +45 -0
  151. package/src/tests/script-connections-mcp.test.ts +41 -0
  152. package/src/tests/script-connections.test.ts +2 -0
  153. package/src/tests/script-executor-quickjs-pool.test.ts +106 -0
  154. package/src/tests/script-executor-registry.test.ts +56 -0
  155. package/src/tests/send-task-self-send.test.ts +107 -0
  156. package/src/tests/slack-inbound-diagnostics-auth.test.ts +93 -0
  157. package/src/tests/slack-inbound-receipts.test.ts +684 -0
  158. package/src/tests/slack-session-status.test.ts +4 -2
  159. package/src/tests/ui-e2e-ingest-payload.test.ts +5 -1
  160. package/src/tests/user-token-routes.test.ts +1 -1
  161. package/src/tests/workflow-executors.test.ts +3 -2
  162. package/src/tests/workflow-sub-workflow.test.ts +363 -0
  163. package/src/tools/script-connections/tool.ts +35 -3
  164. package/src/tools/send-task.ts +9 -1
  165. package/src/utils/internal-ai/complete-structured.ts +6 -3
  166. package/src/utils/runtime-model-catalog.ts +1 -1
  167. package/src/workflows/engine.ts +17 -1
  168. package/src/workflows/executors/agent-task.ts +2 -2
  169. package/src/workflows/executors/human-in-the-loop.ts +3 -3
  170. package/src/workflows/executors/registry.ts +2 -0
  171. package/src/workflows/executors/script.ts +1 -1
  172. package/src/workflows/executors/sub-workflow.ts +135 -0
  173. package/src/workflows/executors/wait.ts +3 -3
  174. package/src/workflows/recovery.ts +11 -1
  175. package/src/workflows/resume.ts +77 -0
  176. package/src/workflows/wait-filter.ts +5 -7
  177. package/templates/extensions/README.md +1 -0
  178. package/templates/extensions/slack-reply-gate/README.md +98 -0
  179. package/templates/extensions/slack-reply-gate/hooks.ts +582 -0
  180. package/templates/extensions/slack-reply-gate/manifest.json +8 -0
  181. package/templates/skills/heartbeat-runbook/content.md +3 -1
  182. package/templates/skills/pages/content.md +25 -0
  183. package/templates/skills/swarm-scripts/SKILL.md +10 -0
  184. package/templates/skills/swarm-scripts/content.md +10 -0
@@ -0,0 +1,549 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { createHash } from "node:crypto";
3
+ import type { AnyMiddlewareArgs, Middleware } from "@slack/bolt";
4
+ import { decryptSecret, encryptSecret, getEncryptionKey } from "../be/crypto";
5
+ import {
6
+ admitSlackInboundReceipt,
7
+ claimSlackInboundReceipt,
8
+ completeSlackInboundReceipt,
9
+ getNextSlackInboundAvailableAt,
10
+ getSlackInboundReceiptStats,
11
+ markInterruptedSlackInboundReceiptsUncertain,
12
+ releaseSlackInboundReceipt,
13
+ SLACK_INBOUND_LIMITS,
14
+ type SlackInboundAdmission,
15
+ type SlackInboundKind,
16
+ type SlackInboundReceipt,
17
+ type SlackInboundReceiptStats,
18
+ type SlackInboundTransport,
19
+ } from "../be/db-queries/slack-inbound";
20
+ import { scrubSecrets } from "../utils/secret-scrubber";
21
+ import { getSlackConfiguration, type SlackMode } from "./config";
22
+
23
+ /**
24
+ * Explicit outcomes for inbound Slack deliveries.
25
+ *
26
+ * Handlers report what happened through this module instead of implying
27
+ * success by returning:
28
+ *
29
+ * - `noteSlackInboundSideEffect(label)` BEFORE a durable mutation (task
30
+ * creation, steering, thread buffering, workflow emission). Marking before is
31
+ * deliberate: a mutation that throws half way still counts, so its delivery
32
+ * becomes `uncertain` rather than being replayed into a duplicate task.
33
+ * - `ignoreSlackInbound(reason)` at an expected filter (bot message, edit,
34
+ * unauthorized user, rate limit, ...).
35
+ * - `reportSlackInboundFailure(code)` where a handler catches an error it does
36
+ * not rethrow.
37
+ *
38
+ * The resolved outcome is:
39
+ * error or reported failure, after a side effect → uncertain
40
+ * error or reported failure, before any side effect → failed (retryable)
41
+ * an ignore reason → ignored
42
+ * otherwise → processed
43
+ *
44
+ * Two entry points establish the delivery context:
45
+ * - `slackInboundOutcomeMiddleware()`, the first Bolt global middleware, wraps
46
+ * every Socket Mode delivery. It only records outcome counters: Socket Mode
47
+ * ingress, its in-memory event dedup and its error propagation are unchanged.
48
+ * - `runAdmittedSlackDelivery()`, used by the durable drain, marks the delivery
49
+ * as admitted from a receipt. That trusted, in-process flag (never a body
50
+ * field) is what lets handlers skip the legacy in-memory `event_id` cache: the
51
+ * receipt's unique key already deduplicated the delivery.
52
+ */
53
+
54
+ export type SlackInboundOutcomeState = "processed" | "ignored" | "failed" | "uncertain";
55
+
56
+ export interface SlackInboundOutcome {
57
+ state: SlackInboundOutcomeState;
58
+ code: string;
59
+ }
60
+
61
+ interface DeliveryState {
62
+ transport: SlackInboundTransport;
63
+ admitted: boolean;
64
+ dispatched: boolean;
65
+ sideEffects: string[];
66
+ ignoredReasons: string[];
67
+ failures: string[];
68
+ }
69
+
70
+ const deliveryStorage = new AsyncLocalStorage<DeliveryState>();
71
+
72
+ function newDeliveryState(transport: SlackInboundTransport, admitted: boolean): DeliveryState {
73
+ return {
74
+ transport,
75
+ admitted,
76
+ dispatched: false,
77
+ sideEffects: [],
78
+ ignoredReasons: [],
79
+ failures: [],
80
+ };
81
+ }
82
+
83
+ /** Mark a durable mutation about to happen for the current delivery. */
84
+ export function noteSlackInboundSideEffect(label: string): void {
85
+ deliveryStorage.getStore()?.sideEffects.push(label);
86
+ }
87
+
88
+ /** Mark the current delivery as intentionally filtered. */
89
+ export function ignoreSlackInbound(reason: string): void {
90
+ deliveryStorage.getStore()?.ignoredReasons.push(reason);
91
+ }
92
+
93
+ /** Report an error a handler caught and did not rethrow. */
94
+ export function reportSlackInboundFailure(code: string): void {
95
+ deliveryStorage.getStore()?.failures.push(code);
96
+ }
97
+
98
+ /**
99
+ * True only inside a delivery the durable receipt store already admitted. Set
100
+ * by `runAdmittedSlackDelivery`, never derived from the payload.
101
+ */
102
+ export function isAdmittedSlackDelivery(): boolean {
103
+ return deliveryStorage.getStore()?.admitted === true;
104
+ }
105
+
106
+ function errorCode(error: unknown): string {
107
+ if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
108
+ return error.code;
109
+ }
110
+ return "handler_error";
111
+ }
112
+
113
+ export function resolveSlackInboundOutcome(
114
+ state: Pick<DeliveryState, "sideEffects" | "ignoredReasons" | "failures">,
115
+ error?: unknown,
116
+ ): SlackInboundOutcome {
117
+ const threw = error !== undefined;
118
+ if (threw || state.failures.length > 0) {
119
+ const code = state.failures[0] ?? errorCode(error);
120
+ return { state: state.sideEffects.length > 0 ? "uncertain" : "failed", code };
121
+ }
122
+ if (state.ignoredReasons.length > 0) {
123
+ return { state: "ignored", code: state.ignoredReasons[0]! };
124
+ }
125
+ return { state: "processed", code: state.sideEffects[0] ?? "completed" };
126
+ }
127
+
128
+ // ─── Outcome counters (Socket Mode is observed, not stored) ─────────────────
129
+
130
+ type OutcomeCounters = Record<SlackInboundOutcomeState, number> & {
131
+ lastOutcomeAt: string | null;
132
+ lastFailure: { code: string; at: string } | null;
133
+ };
134
+
135
+ function emptyCounters(): OutcomeCounters {
136
+ return {
137
+ processed: 0,
138
+ ignored: 0,
139
+ failed: 0,
140
+ uncertain: 0,
141
+ lastOutcomeAt: null,
142
+ lastFailure: null,
143
+ };
144
+ }
145
+
146
+ const counters: Record<SlackInboundTransport, OutcomeCounters> = {
147
+ socket: emptyCounters(),
148
+ http: emptyCounters(),
149
+ };
150
+
151
+ function recordOutcome(transport: SlackInboundTransport, outcome: SlackInboundOutcome): void {
152
+ const bucket = counters[transport];
153
+ const at = new Date().toISOString();
154
+ bucket[outcome.state] += 1;
155
+ bucket.lastOutcomeAt = at;
156
+ if (outcome.state === "failed" || outcome.state === "uncertain") {
157
+ bucket.lastFailure = { code: outcome.code, at };
158
+ console.warn(`[Slack] inbound ${transport} delivery ${outcome.state}: ${outcome.code}`);
159
+ }
160
+ }
161
+
162
+ /** Test-only. */
163
+ export function resetSlackInboundCountersForTests(): void {
164
+ counters.socket = emptyCounters();
165
+ counters.http = emptyCounters();
166
+ }
167
+
168
+ /**
169
+ * First Bolt global middleware. Everything downstream (the Assistant
170
+ * middleware and every listener) runs inside `next()`, so listener errors,
171
+ * which Bolt routes to its error handler after this middleware, are observed
172
+ * here and rethrown unchanged.
173
+ */
174
+ export function slackInboundOutcomeMiddleware(): Middleware<AnyMiddlewareArgs> {
175
+ return async ({ next }) => {
176
+ const existing = deliveryStorage.getStore();
177
+ if (existing) {
178
+ // A durable delivery: the drain resolves and stores the outcome. Record
179
+ // the error here too, because Bolt's error handler may swallow it before
180
+ // App.processEvent() returns to the drain.
181
+ existing.dispatched = true;
182
+ try {
183
+ await next();
184
+ } catch (error) {
185
+ existing.failures.push(errorCode(error));
186
+ throw error;
187
+ }
188
+ return;
189
+ }
190
+
191
+ const state = newDeliveryState("socket", false);
192
+ state.dispatched = true;
193
+ await deliveryStorage.run(state, async () => {
194
+ try {
195
+ await next();
196
+ } catch (error) {
197
+ recordOutcome("socket", resolveSlackInboundOutcome(state, error));
198
+ throw error;
199
+ }
200
+ recordOutcome("socket", resolveSlackInboundOutcome(state));
201
+ });
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Run `fn` as an admitted durable delivery and resolve its outcome. Never
207
+ * throws: a thrown error becomes a `failed` or `uncertain` outcome.
208
+ *
209
+ * `requireBoltDispatch` turns "the Bolt middleware never ran" (authorization
210
+ * failure, unrecognized payload) into `failed`, because `App.processEvent()`
211
+ * resolving is not by itself evidence that a listener handled the delivery.
212
+ */
213
+ export async function runAdmittedSlackDelivery(
214
+ transport: SlackInboundTransport,
215
+ fn: () => Promise<void>,
216
+ options: { requireBoltDispatch?: boolean } = {},
217
+ ): Promise<SlackInboundOutcome> {
218
+ const state = newDeliveryState(transport, true);
219
+ try {
220
+ await deliveryStorage.run(state, fn);
221
+ } catch (error) {
222
+ return resolveSlackInboundOutcome(state, error);
223
+ }
224
+ if (options.requireBoltDispatch && !state.dispatched) {
225
+ return { state: "failed", code: "not_dispatched" };
226
+ }
227
+ return resolveSlackInboundOutcome(state);
228
+ }
229
+
230
+ // ─── Admission ───────────────────────────────────────────────────────────────
231
+
232
+ export type SlackInboundKeyResult =
233
+ | {
234
+ ok: true;
235
+ dedupKey: string;
236
+ payloadType: string;
237
+ apiAppId: string | null;
238
+ eventId: string | null;
239
+ }
240
+ | { ok: false; reason: "missing_event_id" | "malformed" };
241
+
242
+ function sha256(value: string): string {
243
+ return createHash("sha256").update(value).digest("hex");
244
+ }
245
+
246
+ function stringField(body: Record<string, unknown>, key: string): string | null {
247
+ const value = body[key];
248
+ return typeof value === "string" && value.length > 0 ? value : null;
249
+ }
250
+
251
+ /**
252
+ * Derive the dedup key for a delivery.
253
+ *
254
+ * - Events: `(api_app_id, event_id)`. `event_callback` without an `event_id`
255
+ * is refused. Other event envelopes (control messages) key on a body hash.
256
+ * - Interactions and commands carry no universal id: key on a kind-scoped
257
+ * SHA-256 of the original body. Action timestamps, view hashes and trigger
258
+ * ids make genuine later actions distinct; retry headers are not part of the
259
+ * body, so a retry maps to the same key.
260
+ */
261
+ export function computeSlackInboundKey(
262
+ kind: SlackInboundKind,
263
+ rawBody: string,
264
+ body: unknown,
265
+ ): SlackInboundKeyResult {
266
+ if (!body || typeof body !== "object") return { ok: false, reason: "malformed" };
267
+ const record = body as Record<string, unknown>;
268
+ const payloadType = stringField(record, "type") ?? (kind === "command" ? "command" : null);
269
+ if (!payloadType) return { ok: false, reason: "malformed" };
270
+ const apiAppId = stringField(record, "api_app_id");
271
+
272
+ if (kind === "event" && payloadType === "event_callback") {
273
+ const eventId = stringField(record, "event_id");
274
+ if (!eventId) return { ok: false, reason: "missing_event_id" };
275
+ return {
276
+ ok: true,
277
+ dedupKey: `event:${apiAppId ?? "-"}:${eventId}`,
278
+ payloadType,
279
+ apiAppId,
280
+ eventId,
281
+ };
282
+ }
283
+
284
+ return {
285
+ ok: true,
286
+ dedupKey: `${kind}:${payloadType}:${sha256(rawBody)}`,
287
+ payloadType,
288
+ apiAppId,
289
+ eventId: null,
290
+ };
291
+ }
292
+
293
+ export interface AdmitSlackInboundInput {
294
+ transport: SlackInboundTransport;
295
+ kind: SlackInboundKind;
296
+ rawBody: string;
297
+ body: unknown;
298
+ retryNum?: number | null;
299
+ retryReason?: string | null;
300
+ }
301
+
302
+ export type SlackInboundAdmitResult =
303
+ | SlackInboundAdmission
304
+ | { status: "invalid"; reason: "missing_event_id" | "malformed" };
305
+
306
+ /**
307
+ * Persist a delivery before it is acknowledged. The encrypted payload and the
308
+ * dedup key commit in one transaction; a crash after this returns leaves a
309
+ * recoverable `pending` receipt.
310
+ */
311
+ export async function admitSlackInbound(
312
+ input: AdmitSlackInboundInput,
313
+ now: Date = new Date(),
314
+ ): Promise<SlackInboundAdmitResult> {
315
+ const key = computeSlackInboundKey(input.kind, input.rawBody, input.body);
316
+ if (!key.ok) return { status: "invalid", reason: key.reason };
317
+ const payloadBytes = Buffer.byteLength(input.rawBody, "utf8");
318
+ if (payloadBytes > SLACK_INBOUND_LIMITS.maxPayloadBytes) {
319
+ return { status: "rejected", reason: "payload_too_large" };
320
+ }
321
+ return admitSlackInboundReceipt(
322
+ {
323
+ dedupKey: key.dedupKey,
324
+ transport: input.transport,
325
+ kind: input.kind,
326
+ payloadType: key.payloadType,
327
+ apiAppId: key.apiAppId,
328
+ eventId: key.eventId,
329
+ payloadCiphertext: encryptSecret(input.rawBody, getEncryptionKey()),
330
+ payloadBytes,
331
+ retryNum: input.retryNum ?? null,
332
+ retryReason: input.retryReason ?? null,
333
+ },
334
+ now,
335
+ );
336
+ }
337
+
338
+ // ─── Drain ───────────────────────────────────────────────────────────────────
339
+
340
+ export interface SlackInboundDelivery {
341
+ receipt: SlackInboundReceipt;
342
+ rawBody: string;
343
+ }
344
+
345
+ /** Hands one admitted delivery to Bolt (or a test double). */
346
+ export type SlackInboundProcessor = (delivery: SlackInboundDelivery) => Promise<void>;
347
+
348
+ export interface SlackInboundDrainResult {
349
+ receiptId: string;
350
+ outcome: SlackInboundOutcome;
351
+ /** Where the receipt ended up after this attempt. */
352
+ state: "pending" | SlackInboundOutcomeState;
353
+ }
354
+
355
+ /**
356
+ * Claim and process the oldest available receipt. Returns null when nothing
357
+ * is claimable.
358
+ */
359
+ export async function processNextSlackInboundReceipt(
360
+ processor: SlackInboundProcessor,
361
+ options: { now?: () => Date; requireBoltDispatch?: boolean } = {},
362
+ ): Promise<SlackInboundDrainResult | null> {
363
+ const now = options.now ?? (() => new Date());
364
+ const receipt = await claimSlackInboundReceipt(now());
365
+ if (!receipt) return null;
366
+
367
+ let rawBody: string;
368
+ try {
369
+ if (!receipt.payloadCiphertext) throw new Error("payload already erased");
370
+ rawBody = decryptSecret(receipt.payloadCiphertext, getEncryptionKey());
371
+ } catch {
372
+ // Nothing ran, but retrying cannot fix an unreadable payload.
373
+ const outcome: SlackInboundOutcome = { state: "failed", code: "payload_unreadable" };
374
+ await completeSlackInboundReceipt(
375
+ receipt.id,
376
+ { state: "failed", errorCode: outcome.code },
377
+ now(),
378
+ );
379
+ recordOutcome(receipt.transport, outcome);
380
+ return { receiptId: receipt.id, outcome, state: "failed" };
381
+ }
382
+
383
+ const outcome = await runAdmittedSlackDelivery(
384
+ receipt.transport,
385
+ () => processor({ receipt, rawBody }),
386
+ { requireBoltDispatch: options.requireBoltDispatch },
387
+ );
388
+ recordOutcome(receipt.transport, outcome);
389
+
390
+ if (outcome.state === "failed" && receipt.attempts < SLACK_INBOUND_LIMITS.maxAttempts) {
391
+ const availableAt = new Date(
392
+ now().getTime() + SLACK_INBOUND_LIMITS.retryBackoffMs * receipt.attempts,
393
+ );
394
+ await releaseSlackInboundReceipt(receipt.id, outcome.code, availableAt, now());
395
+ return { receiptId: receipt.id, outcome, state: "pending" };
396
+ }
397
+
398
+ await completeSlackInboundReceipt(
399
+ receipt.id,
400
+ {
401
+ state: outcome.state,
402
+ outcomeCode:
403
+ outcome.state === "processed" || outcome.state === "ignored" ? outcome.code : null,
404
+ errorCode: outcome.state === "failed" || outcome.state === "uncertain" ? outcome.code : null,
405
+ },
406
+ now(),
407
+ );
408
+ return { receiptId: receipt.id, outcome, state: outcome.state };
409
+ }
410
+
411
+ /** Arms a one-shot timer and returns its cancel function. */
412
+ export type SlackInboundTimer = (fn: () => void, delayMs: number) => () => void;
413
+
414
+ const defaultTimer: SlackInboundTimer = (fn, delayMs) => {
415
+ const handle = setTimeout(fn, delayMs);
416
+ handle.unref?.();
417
+ return () => clearTimeout(handle);
418
+ };
419
+
420
+ /** Floor for the retry wake-up, so a clock skew never spins the drain. */
421
+ const MIN_REARM_MS = 250;
422
+
423
+ /**
424
+ * One bounded, single-flight drain. `wake()` starts a pass (or schedules one
425
+ * more pass if a drain is already running) and resolves when the drain goes
426
+ * idle. At most `maxPerPass` receipts are processed per pass.
427
+ *
428
+ * On going idle the drain arms a timer for the earliest pending receipt, so a
429
+ * receipt released for a backoff retry is retried even when no new delivery
430
+ * calls `wake()`. `stop()` cancels that timer.
431
+ */
432
+ export function createSlackInboundDrain(
433
+ processor: SlackInboundProcessor,
434
+ options: {
435
+ maxPerPass?: number;
436
+ requireBoltDispatch?: boolean;
437
+ now?: () => Date;
438
+ setTimer?: SlackInboundTimer;
439
+ } = {},
440
+ ): { wake(): Promise<void>; stop(): void } {
441
+ const maxPerPass = options.maxPerPass ?? 50;
442
+ const now = options.now ?? (() => new Date());
443
+ const setTimer = options.setTimer ?? defaultTimer;
444
+ let running: Promise<void> | null = null;
445
+ let again = false;
446
+ let stopped = false;
447
+ let cancelTimer: (() => void) | null = null;
448
+
449
+ const clearTimer = () => {
450
+ cancelTimer?.();
451
+ cancelTimer = null;
452
+ };
453
+
454
+ const pass = async () => {
455
+ for (let i = 0; i < maxPerPass; i++) {
456
+ const result = await processNextSlackInboundReceipt(processor, {
457
+ now,
458
+ requireBoltDispatch: options.requireBoltDispatch,
459
+ });
460
+ if (!result) return;
461
+ }
462
+ };
463
+
464
+ const armRetryTimer = async () => {
465
+ const next = await getNextSlackInboundAvailableAt();
466
+ if (!next || stopped) return;
467
+ const delayMs = Math.max(next.getTime() - now().getTime(), MIN_REARM_MS);
468
+ cancelTimer = setTimer(() => {
469
+ cancelTimer = null;
470
+ void drain.wake();
471
+ }, delayMs);
472
+ };
473
+
474
+ const loop = async () => {
475
+ do {
476
+ again = false;
477
+ clearTimer();
478
+ try {
479
+ await pass();
480
+ } catch (error) {
481
+ // A DB failure mid-pass leaves the receipt `processing`; boot recovery
482
+ // turns it `uncertain`. Never loop hot on a broken store.
483
+ console.error("[Slack] inbound drain pass failed:", scrubSecrets(String(error)));
484
+ return;
485
+ }
486
+ if (again) continue;
487
+ try {
488
+ await armRetryTimer();
489
+ } catch (error) {
490
+ console.error("[Slack] inbound drain could not arm retry:", scrubSecrets(String(error)));
491
+ }
492
+ } while (again);
493
+ };
494
+
495
+ const drain = {
496
+ wake() {
497
+ if (stopped) return Promise.resolve();
498
+ if (running) {
499
+ again = true;
500
+ return running;
501
+ }
502
+ running = loop().finally(() => {
503
+ running = null;
504
+ });
505
+ return running;
506
+ },
507
+ stop() {
508
+ stopped = true;
509
+ clearTimer();
510
+ },
511
+ };
512
+ return drain;
513
+ }
514
+
515
+ /** Boot recovery; call once before the drain starts. */
516
+ export async function recoverSlackInboundAfterRestart(now: Date = new Date()): Promise<number> {
517
+ const interrupted = await markInterruptedSlackInboundReceiptsUncertain(now);
518
+ if (interrupted > 0) {
519
+ console.warn(
520
+ `[Slack] ${interrupted} inbound receipt(s) were interrupted mid-processing and are now uncertain`,
521
+ );
522
+ }
523
+ return interrupted;
524
+ }
525
+
526
+ // ─── Diagnostics ─────────────────────────────────────────────────────────────
527
+
528
+ export interface SlackInboundDiagnostics {
529
+ mode: SlackMode | null;
530
+ disabled: boolean;
531
+ receipts: SlackInboundReceiptStats;
532
+ outcomes: Record<SlackInboundTransport, OutcomeCounters>;
533
+ limits: typeof SLACK_INBOUND_LIMITS;
534
+ }
535
+
536
+ /** Operator view. Contains no payloads, tokens, signatures or response URLs. */
537
+ export async function getSlackInboundDiagnostics(): Promise<SlackInboundDiagnostics> {
538
+ const config = getSlackConfiguration();
539
+ return {
540
+ mode: config.mode,
541
+ disabled: config.disabled,
542
+ receipts: await getSlackInboundReceiptStats(),
543
+ outcomes: {
544
+ socket: { ...counters.socket },
545
+ http: { ...counters.http },
546
+ },
547
+ limits: SLACK_INBOUND_LIMITS,
548
+ };
549
+ }
@@ -28,6 +28,7 @@ import type { AgentTask, CreateTaskOptions, TaskAttachment } from "../types";
28
28
  import { scrubSecrets } from "../utils/secret-scrubber";
29
29
  import { taskAttachmentFetchCommand } from "../utils/task-attachment-links";
30
30
  import { getFileInfo, type SlackFile } from "./files";
31
+ import { noteSlackInboundSideEffect } from "./inbound-dispatch";
31
32
  // Side-effect import: registers all Slack event templates in the in-memory registry
32
33
  import "./templates";
33
34
 
@@ -287,6 +288,7 @@ export async function createSlackTaskWithFiles(
287
288
  options: CreateTaskOptions,
288
289
  inbound: InboundSlackFiles,
289
290
  ): Promise<{ task: AgentTask; unattached: SlackFileFailure[] }> {
291
+ noteSlackInboundSideEffect("task_created");
290
292
  if (inbound.fetched.length === 0) {
291
293
  const task = await createTaskWithSiblingAwareness(description, options, { origin: "slack" });
292
294
  return { task, unattached: inbound.failed };
@@ -127,6 +127,7 @@ let specsByEvent: Map<string, Record<string, CatalogPropertySpec>> | null = null
127
127
 
128
128
  function catalogSpecs(event: string): Record<string, CatalogPropertySpec> | undefined {
129
129
  if (!specsByEvent) {
130
+ // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- generated catalog; its JSON literal types do not match the spec type.
130
131
  const file = catalog as unknown as {
131
132
  events: Array<{
132
133
  product: string;