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.
|
|
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
|
-
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
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
|
|
240
|
-
promise.catch(() => {
|
|
303
|
+
chatPromise.catch(() => {
|
|
241
304
|
chatPromise = null;
|
|
242
305
|
});
|
|
243
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|