experimental-ash 0.10.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # experimental-ash
2
2
 
3
+ ## 0.10.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 9ac4a70: fix(slack): capture send once and await it inside the mention listener
8
+
9
+ The chat SDK defers mention handler invocation through the `waitUntil`
10
+ callback passed into `chat.webhooks.slack`, so the listener fires
11
+ after the route handler has returned. Per-request `send` therefore
12
+ cannot be threaded through any scope-based mechanism — by the time
13
+ the listener runs, the route's scope is gone and dispatches drop on
14
+ the floor (workflow never starts). The previous attempt to thread
15
+ `waitUntil(send)` through a per-request map also lost the dispatch:
16
+ the listener's `waitUntil(send)` push landed _after_ the dispatcher
17
+ had already snapshotted its `backgroundTasks` array into
18
+ `event.waitUntil(Promise.allSettled(backgroundTasks))`, so the send
19
+ promise was orphaned and the lambda could terminate mid-RPC.
20
+
21
+ `SendFn` is functionally identical across requests for a given
22
+ channel (`createSendFn` closes only over the bundled runtime, the
23
+ channel adapter, and the channel name — all stable). The channel now
24
+ captures `send` from the first request into a closure-scoped slot and
25
+ reuses it for every subsequent mention dispatch. The listener
26
+ `await`s send directly, so the chat SDK's outer dispatch IIFE awaits
27
+ the full send promise, which keeps the lambda alive through workflow
28
+ start under the request's `event.waitUntil`. No per-request lookup
29
+ map, no body parsing in the route handler, no request reconstruction
30
+ — the mention path is `if (!capturedSend) capturedSend = send; return
31
+ chat.webhooks.slack(req, { waitUntil })`.
32
+
33
+ Also refactors `slackChannel.ts` for readability: extracts the mention
34
+ listener and the interactive-payload handler as named file-scoped
35
+ helpers (`buildMentionListener`, `handleInteractionRequest`).
36
+ `slackChannel()` shrinks from ~316 lines to ~105 and reads
37
+ top-to-bottom — configure → `getChat` factory → `defineChannel` with
38
+ a route handler that branches interaction vs mention and otherwise
39
+ just forwards to the SDK.
40
+
3
41
  ## 0.10.1
4
42
 
5
43
  ### Patch Changes
@@ -6,7 +6,7 @@ import { ASH_PACKAGE_NAME } from "#package-name.js";
6
6
  let cachedPackageInfo;
7
7
  // The package build stamps the published version into `dist` so bundled
8
8
  // deployments can still report package metadata without resolving package.json.
9
- const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.10.1";
9
+ const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.10.2";
10
10
  const BUNDLED_FALLBACK_PACKAGE_VERSION_PLACEHOLDER = "__ASH_PACKAGE_VERSION_PLACEHOLDER__";
11
11
  const WORKFLOW_MODULE_ALIASES = {
12
12
  "workflow/api": "src/compiled/@workflow/core/runtime.js",
@@ -1,4 +1,3 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
2
1
  import { createSlackAdapter } from "#compiled/@chat-adapter/slack/index.js";
3
2
  import { createMemoryState } from "#compiled/@chat-adapter/state-memory/index.js";
4
3
  import { ThreadImpl, } from "#compiled/chat/index.js";
@@ -136,25 +135,145 @@ function defaultInputRequestedHandler() {
136
135
  });
137
136
  };
138
137
  }
138
+ /**
139
+ * Build the once-registered `onNewMention` listener for a `Chat`
140
+ * instance. The chat SDK defers handler invocation through the
141
+ * `waitUntil` callback we pass into `webhooks.slack`, so the listener
142
+ * cannot rely on any per-request scope from the route handler. It
143
+ * uses `getSend` to look up the channel's captured `send` (which is
144
+ * functionally identical across requests — `createSendFn` closes only
145
+ * over the bundled runtime + adapter + channel name, all stable) and
146
+ * awaits it directly. The route's `event.waitUntil` keeps the lambda
147
+ * alive through the SDK's dispatch IIFE, which awaits this listener,
148
+ * which awaits send — so the workflow start completes before the
149
+ * lambda may terminate.
150
+ */
151
+ function buildMentionListener(config, uploadPolicy, getSend) {
152
+ return async (thread, message) => {
153
+ const raw = message.raw;
154
+ // Slack delivers `app_mention` and `message.channels` for the same
155
+ // utterance; only the former is the listener's job.
156
+ if (raw?.type !== "app_mention")
157
+ return;
158
+ const send = getSend();
159
+ if (!send) {
160
+ log.warn("slack mention received before any request captured send");
161
+ return;
162
+ }
163
+ const teamId = raw.team_id ?? raw.team;
164
+ const slackCtx = {
165
+ thread,
166
+ slack: buildSlackApiHandle(thread, config.credentials?.botToken, teamId),
167
+ };
168
+ const runOpts = config.run ? await config.run(slackCtx, message) : { auth: null };
169
+ if (runOpts === null)
170
+ return;
171
+ if (config.onMention) {
172
+ try {
173
+ await config.onMention(slackCtx, message);
174
+ }
175
+ catch (error) {
176
+ log.error("onMention handler failed", { error });
177
+ }
178
+ }
179
+ const decoded = decodeThreadId(thread.id ?? "");
180
+ const continuationToken = `slack:${decoded.channelId}:${decoded.threadTs}`;
181
+ const fileParts = collectSlackFileParts(message, uploadPolicy);
182
+ const turnMessage = buildSlackTurnMessage(message.text, fileParts);
183
+ try {
184
+ await send(turnMessage, {
185
+ auth: runOpts.auth,
186
+ continuationToken,
187
+ state: {
188
+ serializedThread: thread.toJSON(),
189
+ teamId: teamId ?? null,
190
+ },
191
+ });
192
+ }
193
+ catch (error) {
194
+ log.error("mention delivery failed", { error });
195
+ }
196
+ };
197
+ }
198
+ /**
199
+ * Handle a Slack `block_actions` interactive payload (button clicks,
200
+ * select changes, modal submits). HITL `ash_input:*` actions resume
201
+ * their paused session via `send`; non-HITL actions are forwarded to
202
+ * `config.onInteraction`. Always returns `Response("ok")` — dispatch
203
+ * runs under `waitUntil` so the webhook ACKs immediately.
204
+ */
205
+ async function handleInteractionRequest(rawBody, ctx, config) {
206
+ const ack = new Response("ok", { status: 200 });
207
+ const params = new URLSearchParams(rawBody);
208
+ const payloadStr = params.get("payload");
209
+ if (!payloadStr)
210
+ return ack;
211
+ let interaction;
212
+ try {
213
+ interaction = parseBlockActionsPayload(JSON.parse(payloadStr));
214
+ }
215
+ catch {
216
+ log.warn("failed to parse Slack interaction payload");
217
+ return ack;
218
+ }
219
+ if (!interaction)
220
+ return ack;
221
+ const continuationToken = `slack:${interaction.channelId}:${interaction.threadTs}`;
222
+ const inputResponses = interaction.actions
223
+ .map(deriveHitlResponse)
224
+ .filter((r) => r !== null);
225
+ if (inputResponses.length > 0) {
226
+ ctx.waitUntil(ctx
227
+ .send({ inputResponses }, {
228
+ auth: null,
229
+ continuationToken,
230
+ state: { serializedThread: null, teamId: interaction.teamId ?? null },
231
+ })
232
+ .catch((error) => {
233
+ log.error("HITL interaction delivery failed", { error });
234
+ }));
235
+ }
236
+ const onInteraction = config.onInteraction;
237
+ if (onInteraction) {
238
+ const customActions = interaction.actions.filter((a) => !isHitlAction(a.actionId));
239
+ if (customActions.length > 0) {
240
+ const chatModule = await import("#compiled/chat/index.js");
241
+ const thread = new chatModule.ThreadImpl({
242
+ adapterName: "slack",
243
+ channelId: interaction.channelId,
244
+ id: `slack:${interaction.channelId}:${interaction.threadTs}`,
245
+ isDM: false,
246
+ });
247
+ const slackCtx = {
248
+ thread,
249
+ slack: buildSlackApiHandle(thread, config.credentials?.botToken, interaction.teamId),
250
+ };
251
+ for (const action of customActions) {
252
+ ctx.waitUntil(Promise.resolve(onInteraction(action, slackCtx)).catch((error) => {
253
+ log.error("custom interaction handler failed", { error });
254
+ }));
255
+ }
256
+ }
257
+ }
258
+ return ack;
259
+ }
139
260
  export function slackChannel(config = {}) {
140
261
  const uploadPolicy = mergeUploadPolicy(config.uploadPolicy);
141
- const slackFetchFile = createSlackFetchFile({
142
- botToken: config.credentials?.botToken,
143
- });
262
+ const slackFetchFile = createSlackFetchFile({ botToken: config.credentials?.botToken });
144
263
  const stateAdapter = config.stateAdapter ?? createMemoryState();
145
- // Threads per-request `send`/`waitUntil` to the once-registered
146
- // `onNewMention` listener. A shared mutable slot would race under
147
- // concurrent webhook deliveries `webhooks.slack` yields on
148
- // `await req.text()`, so request B could overwrite A's slot before A's
149
- // listener fires. ALS scopes the store to each route's async call tree.
150
- // Allowlisted in `scripts/guard-agents-rules-baseline.json` (rule 19);
151
- // this is channel-local request state, not Ash runtime state.
152
- const requestContext = new AsyncLocalStorage();
264
+ const inputHandler = config.events?.["input.requested"] ?? defaultInputRequestedHandler();
265
+ // The chat SDK defers mention handler invocation past the route
266
+ // handler returning, so the listener can't read `send` off the
267
+ // current request. Capture it on the first request and reuse it —
268
+ // `createSendFn` closes only over the runtime + adapter + channel
269
+ // name, all stable across requests, so the captured instance works
270
+ // for every subsequent dispatch.
271
+ let capturedSend = null;
153
272
  let chatPromise = null;
154
273
  async function getChat() {
155
274
  if (chatPromise)
156
275
  return chatPromise;
157
- const promise = (async () => {
276
+ chatPromise = (async () => {
158
277
  const { botToken, signingSecret, webhookVerifier } = resolveSlackAdapterCredentials(config.credentials);
159
278
  if (!botToken) {
160
279
  throw new Error("slackChannel requires a bot token. Pass credentials.botToken or set SLACK_BOT_TOKEN.");
@@ -175,74 +294,17 @@ export function slackChannel(config = {}) {
175
294
  userName: config.botName ?? "ash-agent",
176
295
  });
177
296
  await chat.initialize();
178
- // Register exactly once per chat instance — the chat SDK's
179
- // `onNewMention` pushes handlers (does not replace), so registering
180
- // on every webhook accumulates listeners on warm workers and the
181
- // N-th `app_mention` dispatches N times.
182
- chat.onNewMention(async (thread, message) => {
183
- const rawEvent = message.raw;
184
- // Slack sends both `app_mention` and `message.channels` for the same
185
- // utterance. The Chat SDK dedup relies on in-memory state that doesn't
186
- // survive serverless invocations, so both events reach this handler.
187
- // Only process `app_mention` to prevent duplicate runs.
188
- if (rawEvent?.type !== "app_mention")
189
- return;
190
- const ctx = requestContext.getStore();
191
- if (!ctx) {
192
- // Defensive: the chat SDK invokes handlers inside the
193
- // `requestContext.run(...)` tree below, so this should never
194
- // miss. Log and drop in case a future SDK ever defers
195
- // invocation (e.g. via a queue or `setImmediate`).
196
- log.warn("slack mention received but no request context is active");
197
- return;
198
- }
199
- const teamId = rawEvent.team_id ?? rawEvent.team;
200
- const slackCtx = {
201
- thread,
202
- slack: buildSlackApiHandle(thread, config.credentials?.botToken, teamId),
203
- };
204
- const runOpts = config.run ? await config.run(slackCtx, message) : { auth: null };
205
- if (runOpts === null)
206
- return;
207
- if (config.onMention) {
208
- try {
209
- await config.onMention(slackCtx, message);
210
- }
211
- catch (error) {
212
- log.error("onMention handler failed", { error });
213
- }
214
- }
215
- const decoded = decodeThreadId(thread.id ?? "");
216
- const continuationToken = `slack:${decoded.channelId}:${decoded.threadTs}`;
217
- const fileParts = collectSlackFileParts(message, uploadPolicy);
218
- const turnMessage = buildSlackTurnMessage(message.text, fileParts);
219
- // Dispatch via `waitUntil` so the webhook ACKs within Slack's ~3s
220
- // window even on cold starts. Awaiting `send` here would let a
221
- // slow dispatch push the response past 3s and trigger Slack
222
- // retries of the same `app_mention` (which the type filter
223
- // cannot dedupe). Errors are logged; Slack already got its 200.
224
- ctx.waitUntil(ctx
225
- .send(turnMessage, {
226
- auth: runOpts.auth,
227
- continuationToken,
228
- state: {
229
- serializedThread: thread.toJSON(),
230
- teamId: teamId ?? null,
231
- },
232
- })
233
- .catch((error) => {
234
- log.error("mention delivery failed", { error });
235
- }));
236
- });
297
+ // Register once per chat instance — `onNewMention` pushes
298
+ // handlers (does not replace), so registering per request would
299
+ // accumulate listeners on warm workers.
300
+ chat.onNewMention(buildMentionListener(config, uploadPolicy, () => capturedSend));
237
301
  return { chat };
238
302
  })();
239
- chatPromise = promise;
240
- promise.catch(() => {
303
+ chatPromise.catch(() => {
241
304
  chatPromise = null;
242
305
  });
243
- return promise;
306
+ return chatPromise;
244
307
  }
245
- const inputHandler = config.events?.["input.requested"] ?? defaultInputRequestedHandler();
246
308
  return defineChannel({
247
309
  state: { serializedThread: null, teamId: null },
248
310
  fetchFile: slackFetchFile,
@@ -252,68 +314,13 @@ export function slackChannel(config = {}) {
252
314
  routes: [
253
315
  POST(config.route ?? "/ash/v1/slack", async (req, { send, waitUntil }) => {
254
316
  const { chat } = await getChat();
317
+ if (!capturedSend)
318
+ capturedSend = send;
255
319
  const contentType = req.headers.get("content-type") ?? "";
256
320
  if (contentType.includes("application/x-www-form-urlencoded")) {
257
- const formData = await req.text();
258
- const params = new URLSearchParams(formData);
259
- const payloadStr = params.get("payload");
260
- if (payloadStr) {
261
- try {
262
- const payload = JSON.parse(payloadStr);
263
- const interaction = parseBlockActionsPayload(payload);
264
- if (interaction) {
265
- const continuationToken = `slack:${interaction.channelId}:${interaction.threadTs}`;
266
- const inputResponses = [];
267
- for (const action of interaction.actions) {
268
- const response = deriveHitlResponse(action);
269
- if (response !== null) {
270
- inputResponses.push(response);
271
- }
272
- }
273
- if (inputResponses.length > 0) {
274
- waitUntil(send({ inputResponses }, {
275
- auth: null,
276
- continuationToken,
277
- state: {
278
- serializedThread: null,
279
- teamId: interaction.teamId ?? null,
280
- },
281
- }).catch((error) => {
282
- log.error("HITL interaction delivery failed", { error });
283
- }));
284
- }
285
- if (config.onInteraction) {
286
- const customActions = interaction.actions.filter((a) => !isHitlAction(a.actionId));
287
- if (customActions.length > 0) {
288
- const chatModule = await import("#compiled/chat/index.js");
289
- const thread = new chatModule.ThreadImpl({
290
- adapterName: "slack",
291
- channelId: interaction.channelId,
292
- id: `slack:${interaction.channelId}:${interaction.threadTs}`,
293
- isDM: false,
294
- });
295
- const slackCtx = {
296
- thread,
297
- slack: buildSlackApiHandle(thread, config.credentials?.botToken, interaction.teamId),
298
- };
299
- for (const action of customActions) {
300
- waitUntil(Promise.resolve(config.onInteraction(action, slackCtx)).catch((error) => {
301
- log.error("custom interaction handler failed", { error });
302
- }));
303
- }
304
- }
305
- }
306
- }
307
- }
308
- catch {
309
- log.warn("failed to parse Slack interaction payload");
310
- }
311
- }
312
- return new Response("ok", { status: 200 });
321
+ return handleInteractionRequest(await req.text(), { send, waitUntil }, config);
313
322
  }
314
- // Scope `send` / `waitUntil` to this request's async call tree so
315
- // the `onNewMention` listener picks them up via `getStore()`.
316
- return await requestContext.run({ send, waitUntil }, () => chat.webhooks.slack(req, { waitUntil }));
323
+ return await chat.webhooks.slack(req, { waitUntil });
317
324
  }),
318
325
  ],
319
326
  async receive(input, { send }) {
@@ -332,10 +339,7 @@ export function slackChannel(config = {}) {
332
339
  return send(input.message, {
333
340
  auth: input.auth,
334
341
  continuationToken: `slack:${channelId}:`,
335
- state: {
336
- serializedThread: thread.toJSON(),
337
- teamId: null,
338
- },
342
+ state: { serializedThread: thread.toJSON(), teamId: null },
339
343
  });
340
344
  },
341
345
  events: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-ash",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "bin": {
5
5
  "ash": "./bin/ash.js",
6
6
  "experimental-ash": "./bin/ash.js"