@sentry/junior 0.114.0 → 0.115.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.
- package/dist/{agent-hooks-WNBUY4OB.js → agent-hooks-4EUUXP2F.js} +12 -11
- package/dist/api/conversations/detail.d.ts +2 -0
- package/dist/api/conversations/event-list.d.ts +2 -0
- package/dist/api/health.d.ts +7 -0
- package/dist/api/plugin-reports.d.ts +62 -0
- package/dist/api/plugins.d.ts +9 -0
- package/dist/api/runtime.d.ts +24 -0
- package/dist/api/schema/conversation.d.ts +8 -0
- package/dist/api/schema.d.ts +2 -2
- package/dist/api/schema.js +6 -6
- package/dist/api/skills.d.ts +6 -0
- package/dist/api.js +123 -99
- package/dist/app.js +1104 -5006
- package/dist/catalog-runtime-Y32UEQLB.js +18 -0
- package/dist/chat/agent/request.d.ts +6 -0
- package/dist/chat/agent/sandbox.d.ts +2 -0
- package/dist/chat/agent/tools.d.ts +2 -0
- package/dist/chat/config.d.ts +2 -0
- package/dist/chat/conversations/history.d.ts +6 -0
- package/dist/chat/local/credential-sync.d.ts +5 -0
- package/dist/chat/local/oauth-relay.d.ts +4 -0
- package/dist/chat/mcp/oauth.d.ts +1 -0
- package/dist/chat/oauth-authorization.d.ts +17 -0
- package/dist/chat/oauth-flow.d.ts +9 -0
- package/dist/chat/sandbox/egress/schemas.d.ts +46 -0
- package/dist/chat/sandbox/egress/signals.d.ts +9 -0
- package/dist/chat/sandbox/sandbox.d.ts +2 -0
- package/dist/chat/sentry-payload-filter.d.ts +4 -4
- package/dist/chat/services/mcp-auth-orchestration.d.ts +5 -3
- package/dist/chat/services/plugin-auth-orchestration.d.ts +2 -0
- package/dist/chat/slack/message-control.d.ts +2 -0
- package/dist/chat/slack/oauth-authorization-message.d.ts +3 -0
- package/dist/chat/task-execution/slack-work.d.ts +2 -0
- package/dist/chat/tool-support/zod-tool.d.ts +1 -1
- package/dist/chat/tools/definition.d.ts +5 -0
- package/dist/{chunk-TLGM5X2M.js → chunk-2QG4MZQ7.js} +2 -2
- package/dist/{chunk-WV4UJBAS.js → chunk-43RYMWJ2.js} +523 -2373
- package/dist/chunk-4CODA7P5.js +43 -0
- package/dist/{chunk-YU6PO42Q.js → chunk-67CVNDJ7.js} +1 -1
- package/dist/{chunk-OSKEC4XU.js → chunk-6FKLPVF3.js} +1 -1
- package/dist/{chunk-2ZTTXTSZ.js → chunk-6OPTF4BQ.js} +10 -4
- package/dist/{chunk-W6RDUAO3.js → chunk-6V762AB4.js} +1 -1
- package/dist/{chunk-ABPSDO6M.js → chunk-AGLQYELI.js} +77 -8
- package/dist/{chunk-6QWPVTMO.js → chunk-CEYOL62C.js} +9 -7
- package/dist/{chunk-RD7EJI7H.js → chunk-CWEZEPD4.js} +3 -3
- package/dist/chunk-DIB4GX3G.js +8 -0
- package/dist/{chunk-K2W4CK7K.js → chunk-EW5MLKT5.js} +10 -75
- package/dist/{chunk-42WRQLJ6.js → chunk-F3B2YJ76.js} +0 -14
- package/dist/chunk-FA6POGLH.js +70 -0
- package/dist/{chunk-PM3PX42K.js → chunk-FHC4JUSF.js} +1 -1
- package/dist/{chunk-6Y5L4BOM.js → chunk-FXOYURO4.js} +5 -3
- package/dist/{chunk-S4QDH23A.js → chunk-G36V2YGB.js} +83 -81
- package/dist/{chunk-O4GT7LYB.js → chunk-G5SX4UPR.js} +3 -43
- package/dist/chunk-HDSDCEPK.js +80 -0
- package/dist/{chunk-FIGAF6KL.js → chunk-HWCQRGLL.js} +5 -3
- package/dist/{chunk-C4MZ3GP5.js → chunk-HZF5P5UN.js} +1 -1
- package/dist/chunk-LYA2AB5G.js +1914 -0
- package/dist/{chunk-4FUEG3QY.js → chunk-NVUQSFWX.js} +2 -2
- package/dist/chunk-OB42YVAE.js +16 -0
- package/dist/{chunk-XLFBQJUO.js → chunk-PVZHG2XZ.js} +4 -4
- package/dist/{chunk-VNNBSKCO.js → chunk-RAVKCKXA.js} +7 -7
- package/dist/{chunk-ZGXH3REA.js → chunk-SYJT7AKB.js} +12 -2
- package/dist/{chunk-5CZ7RETL.js → chunk-TVE25LPW.js} +1 -1
- package/dist/chunk-W3U3YQYI.js +71 -0
- package/dist/chunk-YCLO7D7M.js +4267 -0
- package/dist/cli/chat.js +72 -40
- package/dist/cli/check.js +10 -8
- package/dist/cli/init.js +1 -0
- package/dist/cli/plugins.js +16 -14
- package/dist/cli/snapshot-warmup.js +13 -11
- package/dist/cli/upgrade.js +10 -8
- package/dist/{db-4VLP7KBF.js → db-KBSJHAZU.js} +8 -7
- package/dist/handlers/sandbox-egress-signals.d.ts +4 -0
- package/dist/instrumentation.js +72 -4
- package/dist/nitro.js +2 -2
- package/dist/oauth-callback-server-EYRMJTCS.js +231 -0
- package/dist/oauth-relay-3ZU72UGL.js +9 -0
- package/dist/reporting-schema.d.ts +12 -4
- package/dist/reporting.d.ts +5 -5
- package/dist/{runner-IPXLWQOF.js → runner-RQGZ5Z2S.js} +139 -72
- package/dist/sandbox-egress-signals-CDFAP67O.js +75 -0
- package/dist/{skills-QCNZISNF.js → skills-JJQIE3BT.js} +10 -8
- package/dist/validation-MZ47PZP5.js +21 -0
- package/dist/version.js +1 -1
- package/package.json +5 -5
- package/dist/catalog-runtime-6O4M6JGA.js +0 -16
- package/dist/chat/oauth-authorization-message.d.ts +0 -6
- package/dist/validation-I2BW3UHD.js +0 -19
|
@@ -0,0 +1,4267 @@
|
|
|
1
|
+
import {
|
|
2
|
+
relayLocalOAuthCallback
|
|
3
|
+
} from "./chunk-FA6POGLH.js";
|
|
4
|
+
import {
|
|
5
|
+
ConversationTurnLifecycleService,
|
|
6
|
+
buildDeliveredTurnStatePatch,
|
|
7
|
+
finalizeFailedTurnReplyWithEvent,
|
|
8
|
+
getTurnUserMessage,
|
|
9
|
+
getTurnUserMessageId,
|
|
10
|
+
getTurnUserReplyAttachmentContext,
|
|
11
|
+
getTurnUserSlackMessageTs,
|
|
12
|
+
persistAuthPauseTurnState,
|
|
13
|
+
requireTurnFailureEventId,
|
|
14
|
+
scheduleSessionCompletedPluginTasks
|
|
15
|
+
} from "./chunk-AGLQYELI.js";
|
|
16
|
+
import {
|
|
17
|
+
buildSentryConversationUrl
|
|
18
|
+
} from "./chunk-4CODA7P5.js";
|
|
19
|
+
import {
|
|
20
|
+
getVercelConversationWorkQueue
|
|
21
|
+
} from "./chunk-MIAKQNYX.js";
|
|
22
|
+
import {
|
|
23
|
+
RetryableDeliveryError,
|
|
24
|
+
addReactionToMessage,
|
|
25
|
+
createUserTokenStore,
|
|
26
|
+
deleteMcpAuthSession,
|
|
27
|
+
escapeSlackMrkdwnText,
|
|
28
|
+
finalizeMcpAuthorization,
|
|
29
|
+
formatProviderLabel,
|
|
30
|
+
formatSlackLink,
|
|
31
|
+
getMcpAuthSession,
|
|
32
|
+
getMcpStoredOAuthCredentials,
|
|
33
|
+
normalizeSlackStatusText,
|
|
34
|
+
parseOAuthStatePayload,
|
|
35
|
+
postSlackMessage,
|
|
36
|
+
removeReactionFromMessage,
|
|
37
|
+
resolveBaseUrl,
|
|
38
|
+
resolvePluginOAuthAccount,
|
|
39
|
+
splitSlackReplyText,
|
|
40
|
+
truncateStatusText
|
|
41
|
+
} from "./chunk-LYA2AB5G.js";
|
|
42
|
+
import {
|
|
43
|
+
JUNIOR_THREAD_STATE_TTL_MS,
|
|
44
|
+
TurnSliceLimitExceededError,
|
|
45
|
+
abandonAgentTurnSessionRecord,
|
|
46
|
+
buildConversationContext,
|
|
47
|
+
buildTurnLimitResponse,
|
|
48
|
+
clearPendingAuth,
|
|
49
|
+
coerceThreadArtifactsState,
|
|
50
|
+
coerceThreadConversationState,
|
|
51
|
+
failAgentTurnSessionRecord,
|
|
52
|
+
getAgentTurnSessionRecord,
|
|
53
|
+
getChannelConfigurationServiceById,
|
|
54
|
+
getConversationPendingAuth,
|
|
55
|
+
getPersistedSandboxState,
|
|
56
|
+
getPersistedThreadState,
|
|
57
|
+
hydrateConversationMessages,
|
|
58
|
+
isPendingAuthLatestRequest,
|
|
59
|
+
loadProjection,
|
|
60
|
+
markConversationMessage,
|
|
61
|
+
markTurnFailed,
|
|
62
|
+
persistCompletedSessionRecord,
|
|
63
|
+
persistConversationMessages,
|
|
64
|
+
persistThreadStateById,
|
|
65
|
+
persistWithRetry,
|
|
66
|
+
recordAuthorizationCompleted,
|
|
67
|
+
recordDeliveredAssistantMessage,
|
|
68
|
+
turnHasReply,
|
|
69
|
+
updateConversationStats
|
|
70
|
+
} from "./chunk-CEYOL62C.js";
|
|
71
|
+
import {
|
|
72
|
+
getDashboardConversationLink,
|
|
73
|
+
getPluginSlackConversationLink
|
|
74
|
+
} from "./chunk-RAVKCKXA.js";
|
|
75
|
+
import {
|
|
76
|
+
acquireActiveLock,
|
|
77
|
+
getDefaultRedisStateAdapterFor,
|
|
78
|
+
getStateAdapter
|
|
79
|
+
} from "./chunk-67CVNDJ7.js";
|
|
80
|
+
import {
|
|
81
|
+
SlackActionError,
|
|
82
|
+
getHeaderString,
|
|
83
|
+
getSlackClient,
|
|
84
|
+
isDmChannel,
|
|
85
|
+
normalizeSlackConversationId
|
|
86
|
+
} from "./chunk-CWEZEPD4.js";
|
|
87
|
+
import {
|
|
88
|
+
getConversationEventStore,
|
|
89
|
+
getConversationStore,
|
|
90
|
+
parseDestination,
|
|
91
|
+
requireSlackDestination,
|
|
92
|
+
sameDestination
|
|
93
|
+
} from "./chunk-HWCQRGLL.js";
|
|
94
|
+
import {
|
|
95
|
+
FUNCTION_TIMEOUT_BUFFER_SECONDS,
|
|
96
|
+
botConfig,
|
|
97
|
+
escapeXml,
|
|
98
|
+
getChatConfig,
|
|
99
|
+
getRuntimeMetadata,
|
|
100
|
+
normalizeSlackEmojiName,
|
|
101
|
+
standardModelId
|
|
102
|
+
} from "./chunk-SYJT7AKB.js";
|
|
103
|
+
import {
|
|
104
|
+
parseSlackThreadId,
|
|
105
|
+
readSlackRawMessageContext,
|
|
106
|
+
resolveSlackChannelIdFromMessage,
|
|
107
|
+
resolveSlackChannelIdFromThreadId
|
|
108
|
+
} from "./chunk-FXOYURO4.js";
|
|
109
|
+
import {
|
|
110
|
+
discoverSkills
|
|
111
|
+
} from "./chunk-2QG4MZQ7.js";
|
|
112
|
+
import {
|
|
113
|
+
pluginCatalogRuntime
|
|
114
|
+
} from "./chunk-6FKLPVF3.js";
|
|
115
|
+
import {
|
|
116
|
+
buildOAuthTokenRequest,
|
|
117
|
+
hasRequiredOAuthScope,
|
|
118
|
+
parseOAuthTokenResponse
|
|
119
|
+
} from "./chunk-EW5MLKT5.js";
|
|
120
|
+
import {
|
|
121
|
+
createSlackResumeActor,
|
|
122
|
+
isUserActor,
|
|
123
|
+
parseStoredSlackActor
|
|
124
|
+
} from "./chunk-HZF5P5UN.js";
|
|
125
|
+
import {
|
|
126
|
+
buildTurnFailureResponse,
|
|
127
|
+
logException,
|
|
128
|
+
logInfo,
|
|
129
|
+
logWarn
|
|
130
|
+
} from "./chunk-NVUQSFWX.js";
|
|
131
|
+
import {
|
|
132
|
+
isRecord,
|
|
133
|
+
toOptionalNumber,
|
|
134
|
+
toOptionalString
|
|
135
|
+
} from "./chunk-OB42YVAE.js";
|
|
136
|
+
import {
|
|
137
|
+
parseSlackMessageTs
|
|
138
|
+
} from "./chunk-F3B2YJ76.js";
|
|
139
|
+
import {
|
|
140
|
+
homeDir
|
|
141
|
+
} from "./chunk-3V7OFJZC.js";
|
|
142
|
+
|
|
143
|
+
// src/chat/slack/assistant-thread/status-render.ts
|
|
144
|
+
var DEFAULT_STATUS_CONTEXTS = {
|
|
145
|
+
thinking: "\u2026",
|
|
146
|
+
searching: "sources",
|
|
147
|
+
reading: "task",
|
|
148
|
+
reviewing: "results",
|
|
149
|
+
drafting: "reply",
|
|
150
|
+
running: "tasks"
|
|
151
|
+
};
|
|
152
|
+
function formatAssistantStatusText(verb, context) {
|
|
153
|
+
const normalizedVerb = normalizeSlackStatusText(verb).trim().toLowerCase();
|
|
154
|
+
const normalizedContext = normalizeSlackStatusText(context ?? "") || DEFAULT_STATUS_CONTEXTS[normalizedVerb] || "";
|
|
155
|
+
if (!normalizedVerb) {
|
|
156
|
+
return truncateStatusText(normalizedContext || "Working");
|
|
157
|
+
}
|
|
158
|
+
const displayVerb = `${normalizedVerb[0]?.toUpperCase() ?? ""}${normalizedVerb.slice(1)}`;
|
|
159
|
+
return truncateStatusText(
|
|
160
|
+
normalizedContext ? `${displayVerb} ${normalizedContext}` : displayVerb
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
function makeAssistantStatus(verb, context) {
|
|
164
|
+
return {
|
|
165
|
+
text: formatAssistantStatusText(verb, context)
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function renderAssistantStatus(args) {
|
|
169
|
+
const visible = truncateStatusText(
|
|
170
|
+
normalizeSlackStatusText(args.status.text)
|
|
171
|
+
);
|
|
172
|
+
return {
|
|
173
|
+
key: visible,
|
|
174
|
+
visible
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function selectAssistantLoadingMessages(args) {
|
|
178
|
+
const random = args.random ?? Math.random;
|
|
179
|
+
const normalized = Array.from(
|
|
180
|
+
new Set(
|
|
181
|
+
args.messages.map((message) => truncateStatusText(normalizeSlackStatusText(message))).filter((message) => message.length > 0)
|
|
182
|
+
)
|
|
183
|
+
);
|
|
184
|
+
if (normalized.length === 0) {
|
|
185
|
+
return void 0;
|
|
186
|
+
}
|
|
187
|
+
const shuffled = [...normalized];
|
|
188
|
+
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
|
189
|
+
const otherIndex = Math.floor(random() * (index + 1));
|
|
190
|
+
[shuffled[index], shuffled[otherIndex]] = [
|
|
191
|
+
shuffled[otherIndex],
|
|
192
|
+
shuffled[index]
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
return shuffled.slice(0, 10);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// src/chat/slack/assistant-thread/status-scheduler.ts
|
|
199
|
+
var STATUS_UPDATE_DEBOUNCE_MS = 1e3;
|
|
200
|
+
var STATUS_MIN_VISIBLE_MS = 1200;
|
|
201
|
+
var STATUS_ROTATION_INTERVAL_MS = 3e4;
|
|
202
|
+
function createAssistantStatusScheduler(args) {
|
|
203
|
+
const now3 = args.now ?? (() => Date.now());
|
|
204
|
+
const setTimer = args.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
205
|
+
const clearTimer = args.clearTimer ?? ((timer) => clearTimeout(timer));
|
|
206
|
+
const random = args.random ?? Math.random;
|
|
207
|
+
const loadingMessages = selectAssistantLoadingMessages({
|
|
208
|
+
messages: args.loadingMessages ?? [],
|
|
209
|
+
random
|
|
210
|
+
});
|
|
211
|
+
const defaultStatus = makeAssistantStatus("thinking");
|
|
212
|
+
let active = false;
|
|
213
|
+
let currentKey = "";
|
|
214
|
+
let currentVisibleStatus = "";
|
|
215
|
+
let currentLoadingMessages;
|
|
216
|
+
let lastStatusAt = 0;
|
|
217
|
+
let pendingStatus = null;
|
|
218
|
+
let pendingKey = "";
|
|
219
|
+
let pendingTimer = null;
|
|
220
|
+
let rotationTimer = null;
|
|
221
|
+
let inflightStatusUpdate = Promise.resolve();
|
|
222
|
+
const enqueueStatusUpdate = (task) => {
|
|
223
|
+
const request = inflightStatusUpdate.catch(() => void 0).then(async () => {
|
|
224
|
+
await task();
|
|
225
|
+
});
|
|
226
|
+
inflightStatusUpdate = request.catch(() => void 0);
|
|
227
|
+
return request;
|
|
228
|
+
};
|
|
229
|
+
const scheduleRotation = () => {
|
|
230
|
+
if (rotationTimer) {
|
|
231
|
+
clearTimer(rotationTimer);
|
|
232
|
+
rotationTimer = null;
|
|
233
|
+
}
|
|
234
|
+
if (!active || !currentVisibleStatus) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
rotationTimer = setTimer(() => {
|
|
238
|
+
rotationTimer = null;
|
|
239
|
+
if (!active || !currentVisibleStatus) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
void postStatus(currentVisibleStatus, currentLoadingMessages);
|
|
243
|
+
}, STATUS_ROTATION_INTERVAL_MS);
|
|
244
|
+
};
|
|
245
|
+
const getLoadingMessagesForVisibleStatus = (visible) => visible ? [visible] : void 0;
|
|
246
|
+
const getInitialStatusText = () => {
|
|
247
|
+
if (loadingMessages?.length) {
|
|
248
|
+
return loadingMessages[0];
|
|
249
|
+
}
|
|
250
|
+
return defaultStatus.text;
|
|
251
|
+
};
|
|
252
|
+
const haveSameLoadingMessages = (left, right) => {
|
|
253
|
+
if (left === right) {
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
if (!left || !right || left.length !== right.length) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
return left.every((message, index) => message === right[index]);
|
|
260
|
+
};
|
|
261
|
+
const postStatus = async (text, nextLoadingMessages) => {
|
|
262
|
+
if (!text && !currentVisibleStatus) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
currentVisibleStatus = text;
|
|
266
|
+
currentLoadingMessages = nextLoadingMessages;
|
|
267
|
+
lastStatusAt = now3();
|
|
268
|
+
scheduleRotation();
|
|
269
|
+
await enqueueStatusUpdate(async () => {
|
|
270
|
+
await args.sendStatus(text, nextLoadingMessages);
|
|
271
|
+
});
|
|
272
|
+
};
|
|
273
|
+
const postRenderedStatus = async (status) => {
|
|
274
|
+
const presentation = renderAssistantStatus({
|
|
275
|
+
status
|
|
276
|
+
});
|
|
277
|
+
const nextLoadingMessages = getLoadingMessagesForVisibleStatus(
|
|
278
|
+
presentation.visible
|
|
279
|
+
);
|
|
280
|
+
currentKey = presentation.key;
|
|
281
|
+
await postStatus(presentation.visible, nextLoadingMessages);
|
|
282
|
+
};
|
|
283
|
+
const clearPending = () => {
|
|
284
|
+
if (pendingTimer) {
|
|
285
|
+
clearTimer(pendingTimer);
|
|
286
|
+
pendingTimer = null;
|
|
287
|
+
}
|
|
288
|
+
pendingStatus = null;
|
|
289
|
+
pendingKey = "";
|
|
290
|
+
};
|
|
291
|
+
const flushPending = async () => {
|
|
292
|
+
if (!active || !pendingStatus) {
|
|
293
|
+
clearPending();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const next = pendingStatus;
|
|
297
|
+
clearPending();
|
|
298
|
+
const nextPresentation = renderAssistantStatus({
|
|
299
|
+
status: next
|
|
300
|
+
});
|
|
301
|
+
if (nextPresentation.key !== currentKey) {
|
|
302
|
+
await postRenderedStatus(next);
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
return {
|
|
306
|
+
async clear() {
|
|
307
|
+
active = false;
|
|
308
|
+
clearPending();
|
|
309
|
+
if (rotationTimer) {
|
|
310
|
+
clearTimer(rotationTimer);
|
|
311
|
+
rotationTimer = null;
|
|
312
|
+
}
|
|
313
|
+
currentKey = "";
|
|
314
|
+
await postStatus("");
|
|
315
|
+
},
|
|
316
|
+
update(status) {
|
|
317
|
+
if (!active) {
|
|
318
|
+
active = true;
|
|
319
|
+
clearPending();
|
|
320
|
+
if (status) {
|
|
321
|
+
void postRenderedStatus(status);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
currentKey = "initial";
|
|
325
|
+
void postStatus(getInitialStatusText(), loadingMessages);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (!status) {
|
|
329
|
+
clearPending();
|
|
330
|
+
currentKey = "initial";
|
|
331
|
+
void postStatus(getInitialStatusText(), loadingMessages);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const presentation = renderAssistantStatus({
|
|
335
|
+
status
|
|
336
|
+
});
|
|
337
|
+
if (!presentation.visible) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (presentation.key === currentKey || presentation.key === pendingKey) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (presentation.visible === currentVisibleStatus) {
|
|
344
|
+
clearPending();
|
|
345
|
+
currentKey = presentation.key;
|
|
346
|
+
const nextLoadingMessages = getLoadingMessagesForVisibleStatus(
|
|
347
|
+
presentation.visible
|
|
348
|
+
);
|
|
349
|
+
if (!haveSameLoadingMessages(currentLoadingMessages, nextLoadingMessages)) {
|
|
350
|
+
void postStatus(presentation.visible, nextLoadingMessages);
|
|
351
|
+
}
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const elapsed = now3() - lastStatusAt;
|
|
355
|
+
const waitMs = Math.max(
|
|
356
|
+
STATUS_UPDATE_DEBOUNCE_MS - elapsed,
|
|
357
|
+
STATUS_MIN_VISIBLE_MS - elapsed,
|
|
358
|
+
0
|
|
359
|
+
);
|
|
360
|
+
if (waitMs <= 0) {
|
|
361
|
+
clearPending();
|
|
362
|
+
void postRenderedStatus(status);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
pendingStatus = status;
|
|
366
|
+
pendingKey = presentation.key;
|
|
367
|
+
if (pendingTimer) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
pendingTimer = setTimer(
|
|
371
|
+
() => {
|
|
372
|
+
pendingTimer = null;
|
|
373
|
+
void flushPending();
|
|
374
|
+
},
|
|
375
|
+
Math.max(1, waitMs)
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// src/chat/slack/assistant-thread/status-send.ts
|
|
382
|
+
var SLACK_ASSISTANT_ACTIVE_STATUS = "is working on your request...";
|
|
383
|
+
function createSlackAdapterStatusSender(args) {
|
|
384
|
+
const adapter = args.getSlackAdapter();
|
|
385
|
+
const boundToken = getSlackAdapterRequestToken(adapter);
|
|
386
|
+
return async (text, loadingMessages) => {
|
|
387
|
+
const channelId = args.channelId;
|
|
388
|
+
const threadTs = args.threadTs;
|
|
389
|
+
if (!channelId || !threadTs) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const normalizedChannelId = normalizeSlackConversationId(channelId);
|
|
393
|
+
if (!normalizedChannelId) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const nextLoadingMessages = text ? loadingMessages ?? [text] : void 0;
|
|
397
|
+
try {
|
|
398
|
+
await runWithBoundSlackToken(
|
|
399
|
+
adapter,
|
|
400
|
+
boundToken,
|
|
401
|
+
() => adapter.setAssistantStatus(
|
|
402
|
+
normalizedChannelId,
|
|
403
|
+
threadTs,
|
|
404
|
+
text ? SLACK_ASSISTANT_ACTIVE_STATUS : "",
|
|
405
|
+
nextLoadingMessages
|
|
406
|
+
)
|
|
407
|
+
);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
logAssistantStatusFailure({
|
|
410
|
+
status: text,
|
|
411
|
+
error,
|
|
412
|
+
channelId,
|
|
413
|
+
normalizedChannelId,
|
|
414
|
+
threadTs
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
function createSlackWebApiStatusSender(args) {
|
|
420
|
+
const getClient = args.getSlackClient ?? getSlackClient;
|
|
421
|
+
return async (text, loadingMessages) => {
|
|
422
|
+
const channelId = args.channelId;
|
|
423
|
+
const threadTs = args.threadTs;
|
|
424
|
+
if (!channelId || !threadTs) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const normalizedChannelId = normalizeSlackConversationId(channelId);
|
|
428
|
+
if (!normalizedChannelId) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const nextLoadingMessages = text ? loadingMessages ?? [text] : void 0;
|
|
432
|
+
try {
|
|
433
|
+
await getClient().assistant.threads.setStatus({
|
|
434
|
+
channel_id: normalizedChannelId,
|
|
435
|
+
thread_ts: threadTs,
|
|
436
|
+
status: text ? SLACK_ASSISTANT_ACTIVE_STATUS : "",
|
|
437
|
+
...nextLoadingMessages ? { loading_messages: nextLoadingMessages } : {}
|
|
438
|
+
});
|
|
439
|
+
} catch (error) {
|
|
440
|
+
logAssistantStatusFailure({
|
|
441
|
+
status: text,
|
|
442
|
+
error,
|
|
443
|
+
channelId,
|
|
444
|
+
normalizedChannelId,
|
|
445
|
+
threadTs
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
function getSlackAdapterRequestToken(adapter) {
|
|
451
|
+
const token = adapter.requestContext?.getStore()?.token;
|
|
452
|
+
if (typeof token !== "string") {
|
|
453
|
+
return void 0;
|
|
454
|
+
}
|
|
455
|
+
const trimmed = token.trim();
|
|
456
|
+
return trimmed || void 0;
|
|
457
|
+
}
|
|
458
|
+
async function runWithBoundSlackToken(adapter, token, task) {
|
|
459
|
+
if (!token) {
|
|
460
|
+
return await task();
|
|
461
|
+
}
|
|
462
|
+
return await adapter.withBotToken(token, task);
|
|
463
|
+
}
|
|
464
|
+
function logAssistantStatusFailure(args) {
|
|
465
|
+
logWarn(
|
|
466
|
+
"assistant_status_update_failed",
|
|
467
|
+
{},
|
|
468
|
+
{
|
|
469
|
+
"app.slack.status_text": args.status || "(clear)",
|
|
470
|
+
"app.slack.channel_id_raw": args.channelId,
|
|
471
|
+
"app.slack.channel_id": args.normalizedChannelId,
|
|
472
|
+
"app.slack.thread_ts": args.threadTs,
|
|
473
|
+
"exception.message": args.error instanceof Error ? args.error.message : String(args.error)
|
|
474
|
+
},
|
|
475
|
+
`Failed to update assistant status channel=${args.normalizedChannelId} raw=${args.channelId} thread=${args.threadTs}`
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// src/chat/slack/assistant-thread/status.ts
|
|
480
|
+
function createSlackAdapterAssistantStatusSession(args) {
|
|
481
|
+
return createAssistantStatusScheduler({
|
|
482
|
+
sendStatus: createSlackAdapterStatusSender({
|
|
483
|
+
channelId: args.channelId,
|
|
484
|
+
threadTs: args.threadTs,
|
|
485
|
+
getSlackAdapter: args.getSlackAdapter
|
|
486
|
+
}),
|
|
487
|
+
loadingMessages: args.loadingMessages ?? botConfig.loadingMessages,
|
|
488
|
+
now: args.now,
|
|
489
|
+
setTimer: args.setTimer,
|
|
490
|
+
clearTimer: args.clearTimer,
|
|
491
|
+
random: args.random
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
function createSlackWebApiAssistantStatusSession(args) {
|
|
495
|
+
return createAssistantStatusScheduler({
|
|
496
|
+
sendStatus: createSlackWebApiStatusSender({
|
|
497
|
+
channelId: args.channelId,
|
|
498
|
+
threadTs: args.threadTs,
|
|
499
|
+
getSlackClient: args.getSlackClient
|
|
500
|
+
}),
|
|
501
|
+
loadingMessages: args.loadingMessages ?? botConfig.loadingMessages,
|
|
502
|
+
now: args.now,
|
|
503
|
+
setTimer: args.setTimer,
|
|
504
|
+
clearTimer: args.clearTimer,
|
|
505
|
+
random: args.random
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/chat/slack/footer.ts
|
|
510
|
+
function buildSlackReplyFooter(args) {
|
|
511
|
+
const items = [];
|
|
512
|
+
const conversationId = args.conversationId?.trim();
|
|
513
|
+
if (conversationId) {
|
|
514
|
+
const idItem = {
|
|
515
|
+
label: "ID",
|
|
516
|
+
value: conversationId
|
|
517
|
+
};
|
|
518
|
+
const conversationUrl = getPluginSlackConversationLink(conversationId)?.url ?? getDashboardConversationLink(conversationId) ?? buildSentryConversationUrl(conversationId);
|
|
519
|
+
if (conversationUrl) {
|
|
520
|
+
idItem.url = conversationUrl;
|
|
521
|
+
}
|
|
522
|
+
items.push(idItem);
|
|
523
|
+
}
|
|
524
|
+
return items.length > 0 ? { items } : void 0;
|
|
525
|
+
}
|
|
526
|
+
function buildSlackReplyBlocks(text, footer) {
|
|
527
|
+
if (!text.trim()) {
|
|
528
|
+
return void 0;
|
|
529
|
+
}
|
|
530
|
+
const blocks = [
|
|
531
|
+
{
|
|
532
|
+
type: "markdown",
|
|
533
|
+
text
|
|
534
|
+
}
|
|
535
|
+
];
|
|
536
|
+
if (footer?.items.length) {
|
|
537
|
+
blocks.push({
|
|
538
|
+
type: "context",
|
|
539
|
+
elements: footer.items.map((item) => ({
|
|
540
|
+
type: "mrkdwn",
|
|
541
|
+
text: item.url ? `*${escapeSlackMrkdwnText(item.label)}:* ${formatSlackLink(item.url, item.value)}` : `*${escapeSlackMrkdwnText(item.label)}:* ${escapeSlackMrkdwnText(item.value)}`
|
|
542
|
+
}))
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
return blocks;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// src/chat/slack/reply.ts
|
|
549
|
+
async function sendSlackReply(args) {
|
|
550
|
+
const chunks = splitSlackReplyText(args.text);
|
|
551
|
+
const footer = buildSlackReplyFooter({
|
|
552
|
+
conversationId: args.conversationId
|
|
553
|
+
});
|
|
554
|
+
let lastMessageTs;
|
|
555
|
+
for (const [index, text] of chunks.entries()) {
|
|
556
|
+
const blocks = buildSlackReplyBlocks(
|
|
557
|
+
text,
|
|
558
|
+
index === chunks.length - 1 ? footer : void 0
|
|
559
|
+
);
|
|
560
|
+
const response = await postSlackMessage({
|
|
561
|
+
channelId: args.channelId,
|
|
562
|
+
threadTs: args.threadTs,
|
|
563
|
+
text,
|
|
564
|
+
...blocks ? { blocks } : {}
|
|
565
|
+
});
|
|
566
|
+
lastMessageTs = response.ts;
|
|
567
|
+
}
|
|
568
|
+
return lastMessageTs;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// src/chat/slack/errors.ts
|
|
572
|
+
function getSlackApiErrorCode(error) {
|
|
573
|
+
if (!error || typeof error !== "object") {
|
|
574
|
+
return void 0;
|
|
575
|
+
}
|
|
576
|
+
const candidate = error;
|
|
577
|
+
if (typeof candidate.data?.error === "string" && candidate.data.error.trim().length > 0) {
|
|
578
|
+
return candidate.data.error;
|
|
579
|
+
}
|
|
580
|
+
if (typeof candidate.code === "string" && candidate.code.trim().length > 0) {
|
|
581
|
+
return candidate.code;
|
|
582
|
+
}
|
|
583
|
+
return void 0;
|
|
584
|
+
}
|
|
585
|
+
function getSlackErrorObservabilityAttributes(error) {
|
|
586
|
+
if (!error || typeof error !== "object") {
|
|
587
|
+
return {};
|
|
588
|
+
}
|
|
589
|
+
const candidate = error;
|
|
590
|
+
const attributes = {};
|
|
591
|
+
if (typeof candidate.code === "string" && candidate.code.trim().length > 0) {
|
|
592
|
+
attributes["app.slack.error_code"] = candidate.code;
|
|
593
|
+
}
|
|
594
|
+
if (typeof candidate.data?.error === "string" && candidate.data.error.trim().length > 0) {
|
|
595
|
+
attributes["app.slack.api_error"] = candidate.data.error;
|
|
596
|
+
}
|
|
597
|
+
const requestId = getHeaderString(candidate.headers, "x-slack-req-id");
|
|
598
|
+
if (requestId) {
|
|
599
|
+
attributes["app.slack.request_id"] = requestId;
|
|
600
|
+
}
|
|
601
|
+
if (typeof candidate.statusCode === "number" && Number.isFinite(candidate.statusCode)) {
|
|
602
|
+
attributes["http.response.status_code"] = candidate.statusCode;
|
|
603
|
+
}
|
|
604
|
+
return attributes;
|
|
605
|
+
}
|
|
606
|
+
var TRANSIENT_SLACK_API_ERRORS = /* @__PURE__ */ new Set([
|
|
607
|
+
"fatal_error",
|
|
608
|
+
"internal_error",
|
|
609
|
+
// chat.postMessage documents both spellings.
|
|
610
|
+
"rate_limited",
|
|
611
|
+
"ratelimited",
|
|
612
|
+
"request_timeout",
|
|
613
|
+
"service_unavailable"
|
|
614
|
+
]);
|
|
615
|
+
function isRetryableSlackPostError(error) {
|
|
616
|
+
if (!error || typeof error !== "object") {
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
const candidate = error;
|
|
620
|
+
if (typeof candidate.statusCode === "number") {
|
|
621
|
+
if (candidate.statusCode === 429 || candidate.statusCode >= 500) {
|
|
622
|
+
return true;
|
|
623
|
+
}
|
|
624
|
+
if (candidate.statusCode >= 400) {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const apiError = error instanceof SlackActionError ? error.apiError : typeof candidate.data?.error === "string" ? candidate.data.error : void 0;
|
|
629
|
+
if (apiError) {
|
|
630
|
+
return TRANSIENT_SLACK_API_ERRORS.has(apiError);
|
|
631
|
+
}
|
|
632
|
+
if (error instanceof SlackActionError) {
|
|
633
|
+
return error.code === "rate_limited" || error.code === "internal_error";
|
|
634
|
+
}
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
function isSlackTitlePermissionError(error) {
|
|
638
|
+
const code = getSlackApiErrorCode(error);
|
|
639
|
+
return code === "no_permission" || code === "missing_scope" || code === "not_allowed_token_type";
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// src/chat/runtime/thread-context.ts
|
|
643
|
+
function escapeRegExp(value) {
|
|
644
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
645
|
+
}
|
|
646
|
+
function readObjectField(value, key) {
|
|
647
|
+
return value[key];
|
|
648
|
+
}
|
|
649
|
+
function readOptionalStringField(value, key) {
|
|
650
|
+
return toOptionalString(readObjectField(value, key));
|
|
651
|
+
}
|
|
652
|
+
function stripLeadingBotMention(text, options = {}) {
|
|
653
|
+
if (!text.trim()) return text;
|
|
654
|
+
let next = text;
|
|
655
|
+
if (options.stripLeadingSlackMentionToken) {
|
|
656
|
+
if (options.botUserId) {
|
|
657
|
+
const botUserId = escapeRegExp(options.botUserId);
|
|
658
|
+
const mentionByBotUserIdRe = new RegExp(
|
|
659
|
+
`^\\s*(?:<@${botUserId}(?:\\|[^>]+)?>|@${botUserId})[\\s,:-]*`,
|
|
660
|
+
"i"
|
|
661
|
+
);
|
|
662
|
+
next = next.replace(mentionByBotUserIdRe, "").trim();
|
|
663
|
+
} else {
|
|
664
|
+
next = next.replace(/^\s*<@[^>]+>[\s,:-]*/, "").trim();
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const mentionByNameRe = new RegExp(
|
|
668
|
+
`^\\s*@${escapeRegExp(botConfig.userName)}\\b[\\s,:-]*`,
|
|
669
|
+
"i"
|
|
670
|
+
);
|
|
671
|
+
next = next.replace(mentionByNameRe, "").trim();
|
|
672
|
+
const mentionByLabeledEntityRe = new RegExp(
|
|
673
|
+
`^\\s*<@[^>|]+\\|${escapeRegExp(botConfig.userName)}>[\\s,:-]*`,
|
|
674
|
+
"i"
|
|
675
|
+
);
|
|
676
|
+
next = next.replace(mentionByLabeledEntityRe, "").trim();
|
|
677
|
+
return next;
|
|
678
|
+
}
|
|
679
|
+
function getThreadId(thread, _message) {
|
|
680
|
+
return toOptionalString(thread.id);
|
|
681
|
+
}
|
|
682
|
+
function getRunId(thread, message) {
|
|
683
|
+
return readOptionalStringField(thread, "runId") ?? readOptionalStringField(message, "runId");
|
|
684
|
+
}
|
|
685
|
+
function getChannelId(thread, message) {
|
|
686
|
+
return resolveSlackChannelIdFromThreadId(toOptionalString(thread.id)) ?? normalizeSlackConversationId(toOptionalString(thread.channelId)) ?? resolveSlackChannelIdFromMessage(message);
|
|
687
|
+
}
|
|
688
|
+
function getThreadTs(threadId) {
|
|
689
|
+
return parseSlackThreadId(threadId)?.threadTs;
|
|
690
|
+
}
|
|
691
|
+
function getAssistantThreadContext(message) {
|
|
692
|
+
const rawContext = readSlackRawMessageContext(message);
|
|
693
|
+
const channelId = rawContext?.channelId;
|
|
694
|
+
if (channelId) {
|
|
695
|
+
const threadTs = isDmChannel(channelId) ? rawContext.threadTs : rawContext.threadTs ?? rawContext.messageTs;
|
|
696
|
+
if (threadTs) {
|
|
697
|
+
return { channelId, threadTs };
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
const parsedThreadId = parseSlackThreadId(
|
|
701
|
+
readOptionalStringField(message, "threadId")
|
|
702
|
+
);
|
|
703
|
+
if (!parsedThreadId || isDmChannel(parsedThreadId.channelId)) {
|
|
704
|
+
return void 0;
|
|
705
|
+
}
|
|
706
|
+
return parsedThreadId;
|
|
707
|
+
}
|
|
708
|
+
function getMessageTs(message) {
|
|
709
|
+
const directTs = readOptionalStringField(message, "ts");
|
|
710
|
+
const parsedDirectTs = parseSlackMessageTs(directTs);
|
|
711
|
+
if (parsedDirectTs) {
|
|
712
|
+
return parsedDirectTs;
|
|
713
|
+
}
|
|
714
|
+
const rawContext = readSlackRawMessageContext(message);
|
|
715
|
+
if (!rawContext) {
|
|
716
|
+
return void 0;
|
|
717
|
+
}
|
|
718
|
+
const candidates = [rawContext.messageTs, rawContext.nestedMessageTs];
|
|
719
|
+
for (const candidate of candidates) {
|
|
720
|
+
const ts = parseSlackMessageTs(candidate);
|
|
721
|
+
if (ts) {
|
|
722
|
+
return ts;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return void 0;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// src/chat/runtime/processing-reaction.ts
|
|
729
|
+
var noProcessingReaction = {
|
|
730
|
+
complete: async () => void 0,
|
|
731
|
+
keep: () => void 0,
|
|
732
|
+
stop: async () => void 0
|
|
733
|
+
};
|
|
734
|
+
function isProcessingReactionEmoji(value) {
|
|
735
|
+
return typeof value === "string" && normalizeSlackEmojiName(value) === getChatConfig().slack.processingReactionEmoji;
|
|
736
|
+
}
|
|
737
|
+
function shouldKeepProcessingReactionForToolInvocation(input) {
|
|
738
|
+
return input.toolName === "addReaction" && isProcessingReactionEmoji(input.params.emoji);
|
|
739
|
+
}
|
|
740
|
+
async function startSlackProcessingReaction(args) {
|
|
741
|
+
if (args.message.author.isMe) {
|
|
742
|
+
return noProcessingReaction;
|
|
743
|
+
}
|
|
744
|
+
const channelId = getChannelId(args.thread, args.message);
|
|
745
|
+
const messageTs = getMessageTs(args.message);
|
|
746
|
+
if (!channelId || !messageTs) {
|
|
747
|
+
return noProcessingReaction;
|
|
748
|
+
}
|
|
749
|
+
return startSlackProcessingReactionForMessage({
|
|
750
|
+
channelId,
|
|
751
|
+
timestamp: messageTs,
|
|
752
|
+
logException: args.logException,
|
|
753
|
+
logContext: args.logContext
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
async function startSlackProcessingReactionForMessage(args) {
|
|
757
|
+
try {
|
|
758
|
+
await addReactionToMessage({
|
|
759
|
+
channelId: args.channelId,
|
|
760
|
+
timestamp: args.timestamp,
|
|
761
|
+
emoji: getChatConfig().slack.processingReactionEmoji
|
|
762
|
+
});
|
|
763
|
+
} catch (error) {
|
|
764
|
+
args.logException(
|
|
765
|
+
error,
|
|
766
|
+
"slack_processing_reaction_add_failed",
|
|
767
|
+
args.logContext,
|
|
768
|
+
{
|
|
769
|
+
"app.slack.action": "reactions.add",
|
|
770
|
+
"messaging.message.id": args.timestamp,
|
|
771
|
+
...getSlackErrorObservabilityAttributes(error)
|
|
772
|
+
},
|
|
773
|
+
"Failed to add Slack processing reaction"
|
|
774
|
+
);
|
|
775
|
+
return noProcessingReaction;
|
|
776
|
+
}
|
|
777
|
+
let shouldRemove = true;
|
|
778
|
+
const removeProcessingReaction = async () => {
|
|
779
|
+
if (!shouldRemove) {
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
try {
|
|
783
|
+
await removeReactionFromMessage({
|
|
784
|
+
channelId: args.channelId,
|
|
785
|
+
timestamp: args.timestamp,
|
|
786
|
+
emoji: getChatConfig().slack.processingReactionEmoji
|
|
787
|
+
});
|
|
788
|
+
return true;
|
|
789
|
+
} catch (error) {
|
|
790
|
+
args.logException(
|
|
791
|
+
error,
|
|
792
|
+
"slack_processing_reaction_remove_failed",
|
|
793
|
+
args.logContext,
|
|
794
|
+
{
|
|
795
|
+
"app.slack.action": "reactions.remove",
|
|
796
|
+
"messaging.message.id": args.timestamp,
|
|
797
|
+
...getSlackErrorObservabilityAttributes(error)
|
|
798
|
+
},
|
|
799
|
+
"Failed to remove Slack processing reaction"
|
|
800
|
+
);
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
return {
|
|
805
|
+
complete: async () => {
|
|
806
|
+
const shouldAddCompleted = shouldRemove;
|
|
807
|
+
await removeProcessingReaction();
|
|
808
|
+
if (!shouldAddCompleted) {
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
try {
|
|
812
|
+
await addReactionToMessage({
|
|
813
|
+
channelId: args.channelId,
|
|
814
|
+
timestamp: args.timestamp,
|
|
815
|
+
emoji: getChatConfig().slack.completedReactionEmoji
|
|
816
|
+
});
|
|
817
|
+
} catch (error) {
|
|
818
|
+
args.logException(
|
|
819
|
+
error,
|
|
820
|
+
"slack_processing_reaction_complete_failed",
|
|
821
|
+
args.logContext,
|
|
822
|
+
{
|
|
823
|
+
"app.slack.action": "reactions.add",
|
|
824
|
+
"messaging.message.id": args.timestamp,
|
|
825
|
+
...getSlackErrorObservabilityAttributes(error)
|
|
826
|
+
},
|
|
827
|
+
"Failed to add Slack completed reaction"
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
keep: () => {
|
|
832
|
+
shouldRemove = false;
|
|
833
|
+
},
|
|
834
|
+
stop: async () => {
|
|
835
|
+
await removeProcessingReaction();
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// src/chat/services/auth-pause-response.ts
|
|
841
|
+
var MAX_AUTH_REQUEST_LENGTH = 200;
|
|
842
|
+
function formatAuthRequest(requestText) {
|
|
843
|
+
const normalized = requestText.replace(/\s+/g, " ").trim();
|
|
844
|
+
if (!normalized) {
|
|
845
|
+
return void 0;
|
|
846
|
+
}
|
|
847
|
+
const bounded = normalized.length > MAX_AUTH_REQUEST_LENGTH ? `${normalized.slice(0, MAX_AUTH_REQUEST_LENGTH - 1).trimEnd()}\u2026` : normalized;
|
|
848
|
+
return bounded.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
849
|
+
}
|
|
850
|
+
function buildAuthPauseResponse(slackUserId, providerDisplayName, requestText) {
|
|
851
|
+
const mention = slackUserId ? `<@${slackUserId}> ` : "";
|
|
852
|
+
const request = requestText ? formatAuthRequest(requestText) : void 0;
|
|
853
|
+
if (!request) {
|
|
854
|
+
return `${mention}I'll need you to authorize ${providerDisplayName}. I sent you a link.`;
|
|
855
|
+
}
|
|
856
|
+
return `${mention}I need access to ${providerDisplayName} to continue.
|
|
857
|
+
|
|
858
|
+
*Why:* ${request}
|
|
859
|
+
|
|
860
|
+
I sent you a link.`;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// src/chat/runtime/request-deadline.ts
|
|
864
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
865
|
+
var requestDeadlineStorage = new AsyncLocalStorage();
|
|
866
|
+
function createTurnRequestDeadline(startedAtMs) {
|
|
867
|
+
const requestBudgetMs = Math.max(
|
|
868
|
+
1,
|
|
869
|
+
getChatConfig().functionMaxDurationSeconds * 1e3 - FUNCTION_TIMEOUT_BUFFER_SECONDS * 1e3
|
|
870
|
+
);
|
|
871
|
+
return {
|
|
872
|
+
startedAtMs,
|
|
873
|
+
deadlineAtMs: startedAtMs + requestBudgetMs
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
function getTurnRequestDeadline() {
|
|
877
|
+
return requestDeadlineStorage.getStore();
|
|
878
|
+
}
|
|
879
|
+
function runWithTurnRequestDeadline(callback, startedAtMs = Date.now()) {
|
|
880
|
+
const existingDeadline = requestDeadlineStorage.getStore();
|
|
881
|
+
if (existingDeadline) {
|
|
882
|
+
return callback();
|
|
883
|
+
}
|
|
884
|
+
return requestDeadlineStorage.run(
|
|
885
|
+
createTurnRequestDeadline(startedAtMs),
|
|
886
|
+
callback
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// src/chat/runtime/slack-resume.ts
|
|
891
|
+
function resolveReplyTimeoutMs(explicitTimeoutMs) {
|
|
892
|
+
if (typeof explicitTimeoutMs === "number" && explicitTimeoutMs > 0) {
|
|
893
|
+
return explicitTimeoutMs;
|
|
894
|
+
}
|
|
895
|
+
const raw = process.env.EVAL_AGENT_REPLY_TIMEOUT_MS?.trim();
|
|
896
|
+
if (!raw) {
|
|
897
|
+
return void 0;
|
|
898
|
+
}
|
|
899
|
+
const parsed = Number.parseInt(raw, 10);
|
|
900
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
901
|
+
}
|
|
902
|
+
async function postSlackMessageBestEffort(channelId, threadTs, text, conversationId) {
|
|
903
|
+
try {
|
|
904
|
+
if (conversationId) {
|
|
905
|
+
await sendSlackReply({
|
|
906
|
+
channelId,
|
|
907
|
+
conversationId,
|
|
908
|
+
text,
|
|
909
|
+
threadTs
|
|
910
|
+
});
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
await postSlackMessage({ channelId, threadTs, text });
|
|
914
|
+
} catch {
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
function createReadOnlyConfigService(values) {
|
|
918
|
+
const entries = Object.entries(values).map(([key, value]) => ({
|
|
919
|
+
key,
|
|
920
|
+
value,
|
|
921
|
+
scope: "conversation",
|
|
922
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
923
|
+
}));
|
|
924
|
+
return {
|
|
925
|
+
get: async (key) => entries.find((entry) => entry.key === key),
|
|
926
|
+
set: async () => {
|
|
927
|
+
throw new Error("Read-only configuration in resumed context");
|
|
928
|
+
},
|
|
929
|
+
unset: async () => false,
|
|
930
|
+
list: async ({ prefix } = {}) => entries.filter((entry) => !prefix || entry.key.startsWith(prefix)),
|
|
931
|
+
resolve: async (key) => values[key],
|
|
932
|
+
resolveValues: async ({ keys, prefix } = {}) => {
|
|
933
|
+
const filtered = {};
|
|
934
|
+
for (const [key, value] of Object.entries(values)) {
|
|
935
|
+
if (prefix && !key.startsWith(prefix)) continue;
|
|
936
|
+
if (keys && !keys.includes(key)) continue;
|
|
937
|
+
filtered[key] = value;
|
|
938
|
+
}
|
|
939
|
+
return filtered;
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
var ResumeTurnBusyError = class extends Error {
|
|
944
|
+
constructor(lockKey) {
|
|
945
|
+
super(`A turn already owns resume lock "${lockKey}"`);
|
|
946
|
+
this.name = "ResumeTurnBusyError";
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
function getDefaultLockKey(channelId, threadTs) {
|
|
950
|
+
return `slack:${channelId}:${threadTs}`;
|
|
951
|
+
}
|
|
952
|
+
function getResumeLogContext(args, lockKey) {
|
|
953
|
+
const routing = args.replyContext?.routing;
|
|
954
|
+
const actor = routing?.actor;
|
|
955
|
+
return {
|
|
956
|
+
conversationId: args.conversationId,
|
|
957
|
+
messageConversationId: lockKey,
|
|
958
|
+
userId: isUserActor(actor) ? actor.userId : void 0,
|
|
959
|
+
userName: isUserActor(actor) ? actor.userName : void 0,
|
|
960
|
+
destinationName: args.channelId,
|
|
961
|
+
assistantUserName: botConfig.userName,
|
|
962
|
+
modelId: standardModelId(botConfig)
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
async function postResumeFailureReply(args) {
|
|
966
|
+
await postSlackMessage({
|
|
967
|
+
channelId: args.channelId,
|
|
968
|
+
threadTs: args.threadTs,
|
|
969
|
+
text: args.error instanceof TurnSliceLimitExceededError ? buildTurnLimitResponse(args.eventId) : buildTurnFailureResponse(args.eventId)
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
async function handleResumeFailure(args) {
|
|
973
|
+
const logContext = getResumeLogContext(args.resumeArgs, args.lockKey);
|
|
974
|
+
const capturedEventId = logException(
|
|
975
|
+
args.error,
|
|
976
|
+
args.eventName,
|
|
977
|
+
logContext,
|
|
978
|
+
{},
|
|
979
|
+
args.body
|
|
980
|
+
);
|
|
981
|
+
const eventId = requireTurnFailureEventId(capturedEventId, args.eventName);
|
|
982
|
+
let failureStatePersistError;
|
|
983
|
+
try {
|
|
984
|
+
await args.resumeArgs.onFailure?.(args.error);
|
|
985
|
+
} catch (persistError) {
|
|
986
|
+
const persistEventId = logException(
|
|
987
|
+
persistError,
|
|
988
|
+
"slack_resume_failure_state_persist_failed",
|
|
989
|
+
logContext,
|
|
990
|
+
{ "app.error.original_event_id": eventId },
|
|
991
|
+
"Failed to persist resumed turn failure state"
|
|
992
|
+
);
|
|
993
|
+
try {
|
|
994
|
+
await args.turnLifecycle.fail({
|
|
995
|
+
conversationId: args.resumeArgs.conversationId,
|
|
996
|
+
turnId: args.resumeArgs.turnId,
|
|
997
|
+
createdAtMs: Date.now(),
|
|
998
|
+
failureCode: "persistence_failed",
|
|
999
|
+
...persistEventId ? { eventId: persistEventId } : {}
|
|
1000
|
+
});
|
|
1001
|
+
} catch (lifecycleError) {
|
|
1002
|
+
logException(
|
|
1003
|
+
lifecycleError,
|
|
1004
|
+
"slack_resume_failure_lifecycle_persist_failed",
|
|
1005
|
+
logContext,
|
|
1006
|
+
{ "app.error.original_event_id": eventId },
|
|
1007
|
+
"Failed to record resumed turn persistence failure"
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
failureStatePersistError = persistError;
|
|
1011
|
+
}
|
|
1012
|
+
try {
|
|
1013
|
+
await postResumeFailureReply({
|
|
1014
|
+
channelId: args.resumeArgs.channelId,
|
|
1015
|
+
threadTs: args.resumeArgs.threadTs,
|
|
1016
|
+
eventId,
|
|
1017
|
+
error: args.error
|
|
1018
|
+
});
|
|
1019
|
+
} catch (deliveryError) {
|
|
1020
|
+
const deliveryEventId = logException(
|
|
1021
|
+
deliveryError,
|
|
1022
|
+
"slack_resume_failure_delivery_failed",
|
|
1023
|
+
logContext,
|
|
1024
|
+
{ "app.error.original_event_id": eventId },
|
|
1025
|
+
"Failed to deliver resumed turn failure state"
|
|
1026
|
+
);
|
|
1027
|
+
try {
|
|
1028
|
+
await args.turnLifecycle.fail({
|
|
1029
|
+
conversationId: args.resumeArgs.conversationId,
|
|
1030
|
+
turnId: args.resumeArgs.turnId,
|
|
1031
|
+
createdAtMs: Date.now(),
|
|
1032
|
+
failureCode: "delivery_failed",
|
|
1033
|
+
...deliveryEventId ? { eventId: deliveryEventId } : {}
|
|
1034
|
+
});
|
|
1035
|
+
} catch (lifecycleError) {
|
|
1036
|
+
logException(
|
|
1037
|
+
lifecycleError,
|
|
1038
|
+
"slack_resume_failure_lifecycle_persist_failed",
|
|
1039
|
+
logContext,
|
|
1040
|
+
{ "app.error.original_event_id": eventId },
|
|
1041
|
+
"Failed to record resumed turn delivery failure"
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
throw deliveryError;
|
|
1045
|
+
}
|
|
1046
|
+
if (failureStatePersistError) {
|
|
1047
|
+
throw failureStatePersistError;
|
|
1048
|
+
}
|
|
1049
|
+
await args.turnLifecycle.fail({
|
|
1050
|
+
conversationId: args.resumeArgs.conversationId,
|
|
1051
|
+
turnId: args.resumeArgs.turnId,
|
|
1052
|
+
createdAtMs: Date.now(),
|
|
1053
|
+
failureCode: args.failureCode,
|
|
1054
|
+
eventId
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
function createResumeReplyContext(args, statusSession, delivery) {
|
|
1058
|
+
const replyContext = args.replyContext;
|
|
1059
|
+
if (!replyContext) {
|
|
1060
|
+
throw new TypeError("Slack resume requires a reply context");
|
|
1061
|
+
}
|
|
1062
|
+
if (!replyContext.routing.source) {
|
|
1063
|
+
throw new TypeError("Slack resume requires a reply context source");
|
|
1064
|
+
}
|
|
1065
|
+
const source = replyContext.routing.source;
|
|
1066
|
+
if (source.platform !== "slack") {
|
|
1067
|
+
throw new TypeError("Slack resume requires a Slack source");
|
|
1068
|
+
}
|
|
1069
|
+
if (replyContext.routing.destination.platform !== "slack") {
|
|
1070
|
+
throw new TypeError("Slack resume requires a Slack destination");
|
|
1071
|
+
}
|
|
1072
|
+
const requestDeadline = getTurnRequestDeadline();
|
|
1073
|
+
const threadId = args.lockKey ?? getDefaultLockKey(args.channelId, args.threadTs);
|
|
1074
|
+
const persistedChannelConfiguration = replyContext.policy?.channelConfiguration ?? (replyContext.policy?.configuration ? createReadOnlyConfigService(replyContext.policy.configuration) : void 0);
|
|
1075
|
+
return {
|
|
1076
|
+
conversationId: args.conversationId,
|
|
1077
|
+
turnId: args.turnId,
|
|
1078
|
+
input: {
|
|
1079
|
+
...replyContext.input ?? {},
|
|
1080
|
+
messageText: args.messageText
|
|
1081
|
+
},
|
|
1082
|
+
routing: {
|
|
1083
|
+
...replyContext.routing,
|
|
1084
|
+
source: {
|
|
1085
|
+
...source,
|
|
1086
|
+
channelId: args.channelId,
|
|
1087
|
+
threadTs: args.threadTs
|
|
1088
|
+
}
|
|
1089
|
+
},
|
|
1090
|
+
policy: {
|
|
1091
|
+
...replyContext.policy,
|
|
1092
|
+
turnDeadlineAtMs: replyContext.policy?.turnDeadlineAtMs ?? requestDeadline?.deadlineAtMs,
|
|
1093
|
+
channelConfiguration: persistedChannelConfiguration
|
|
1094
|
+
},
|
|
1095
|
+
state: replyContext.state,
|
|
1096
|
+
observers: {
|
|
1097
|
+
...replyContext.observers,
|
|
1098
|
+
onStatus: async (nextStatus) => {
|
|
1099
|
+
statusSession.update(nextStatus);
|
|
1100
|
+
await replyContext.observers?.onStatus?.(nextStatus);
|
|
1101
|
+
}
|
|
1102
|
+
},
|
|
1103
|
+
delivery,
|
|
1104
|
+
durability: {
|
|
1105
|
+
...replyContext.durability,
|
|
1106
|
+
onSandboxRefChanged: async (sandboxRef) => {
|
|
1107
|
+
await persistThreadStateById(threadId, {
|
|
1108
|
+
sandboxRef
|
|
1109
|
+
});
|
|
1110
|
+
await replyContext.durability?.onSandboxRefChanged?.(sandboxRef);
|
|
1111
|
+
},
|
|
1112
|
+
onArtifactStateUpdated: async (artifacts) => {
|
|
1113
|
+
await persistThreadStateById(threadId, { artifacts });
|
|
1114
|
+
await replyContext.durability?.onArtifactStateUpdated?.(artifacts);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
async function resumeSlackTurn(args) {
|
|
1120
|
+
const stateAdapter = getStateAdapter();
|
|
1121
|
+
await stateAdapter.connect();
|
|
1122
|
+
const lockKey = args.lockKey ?? getDefaultLockKey(args.channelId, args.threadTs);
|
|
1123
|
+
const lock = await acquireActiveLock(stateAdapter, lockKey);
|
|
1124
|
+
if (!lock) {
|
|
1125
|
+
throw new ResumeTurnBusyError(lockKey);
|
|
1126
|
+
}
|
|
1127
|
+
const status = createSlackWebApiAssistantStatusSession({
|
|
1128
|
+
channelId: args.channelId,
|
|
1129
|
+
threadTs: args.threadTs
|
|
1130
|
+
});
|
|
1131
|
+
let processingReaction;
|
|
1132
|
+
let deferredAuthInfo;
|
|
1133
|
+
let deferredPauseHandler;
|
|
1134
|
+
let deferredFailureHandler;
|
|
1135
|
+
let runResultHandled = false;
|
|
1136
|
+
let assistantMessageDelivered = false;
|
|
1137
|
+
let postDeliveryCommitError;
|
|
1138
|
+
const turnLifecycle = args.turnLifecycle ?? new ConversationTurnLifecycleService(getConversationEventStore());
|
|
1139
|
+
let failureCode = "agent_run_failed";
|
|
1140
|
+
let runArgs = args;
|
|
1141
|
+
try {
|
|
1142
|
+
const preparedArgs = await args.beforeStart?.();
|
|
1143
|
+
if (preparedArgs === false) {
|
|
1144
|
+
return false;
|
|
1145
|
+
}
|
|
1146
|
+
if (preparedArgs) {
|
|
1147
|
+
runArgs = { ...args, ...preparedArgs };
|
|
1148
|
+
}
|
|
1149
|
+
const activeReplyContext = runArgs.replyContext;
|
|
1150
|
+
if (!activeReplyContext) {
|
|
1151
|
+
throw new Error("Resumed turn requires replyContext");
|
|
1152
|
+
}
|
|
1153
|
+
const credentialContext = activeReplyContext.routing.credentialContext;
|
|
1154
|
+
if (!credentialContext) {
|
|
1155
|
+
throw new Error("Resumed turn requires replyContext.credentialContext");
|
|
1156
|
+
}
|
|
1157
|
+
const routingActor = activeReplyContext.routing.actor;
|
|
1158
|
+
let resumeActor;
|
|
1159
|
+
if ("type" in credentialContext.actor) {
|
|
1160
|
+
if (!isUserActor(routingActor) || credentialContext.actor.userId !== routingActor.userId) {
|
|
1161
|
+
throw new Error(
|
|
1162
|
+
"Resumed turn credential actor must match replyContext.routing.actor.userId"
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
resumeActor = routingActor;
|
|
1166
|
+
} else {
|
|
1167
|
+
if (routingActor && (routingActor.platform !== "system" || routingActor.name !== credentialContext.actor.name)) {
|
|
1168
|
+
throw new Error(
|
|
1169
|
+
"Resumed turn system credential actor must match replyContext.routing.actor"
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
resumeActor = credentialContext.actor;
|
|
1173
|
+
}
|
|
1174
|
+
if (runArgs.messageTs) {
|
|
1175
|
+
processingReaction = await startSlackProcessingReactionForMessage({
|
|
1176
|
+
channelId: runArgs.channelId,
|
|
1177
|
+
timestamp: runArgs.messageTs,
|
|
1178
|
+
logException,
|
|
1179
|
+
logContext: { ...getResumeLogContext(runArgs, lockKey) }
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
if (runArgs.initialText) {
|
|
1183
|
+
await postSlackMessageBestEffort(
|
|
1184
|
+
runArgs.channelId,
|
|
1185
|
+
runArgs.threadTs,
|
|
1186
|
+
runArgs.initialText
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
status.update(runArgs.initialStatus);
|
|
1190
|
+
const conversationId = runArgs.conversationId;
|
|
1191
|
+
const visibleConversationId = lockKey;
|
|
1192
|
+
const sessionId = runArgs.turnId;
|
|
1193
|
+
let deliveryConversation;
|
|
1194
|
+
const getDeliveryConversation = async () => {
|
|
1195
|
+
if (deliveryConversation) {
|
|
1196
|
+
const userMessageId2 = getTurnUserMessage(
|
|
1197
|
+
deliveryConversation,
|
|
1198
|
+
sessionId
|
|
1199
|
+
)?.id;
|
|
1200
|
+
return {
|
|
1201
|
+
conversation: deliveryConversation,
|
|
1202
|
+
sessionId,
|
|
1203
|
+
...userMessageId2 ? { userMessageId: userMessageId2 } : {}
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
const persistedState = await getPersistedThreadState(
|
|
1207
|
+
visibleConversationId
|
|
1208
|
+
);
|
|
1209
|
+
const conversation = coerceThreadConversationState(persistedState);
|
|
1210
|
+
await hydrateConversationMessages({
|
|
1211
|
+
conversation,
|
|
1212
|
+
conversationId
|
|
1213
|
+
});
|
|
1214
|
+
deliveryConversation = conversation;
|
|
1215
|
+
const userMessageId = getTurnUserMessage(conversation, sessionId)?.id;
|
|
1216
|
+
return {
|
|
1217
|
+
conversation,
|
|
1218
|
+
sessionId,
|
|
1219
|
+
...userMessageId ? { userMessageId } : {}
|
|
1220
|
+
};
|
|
1221
|
+
};
|
|
1222
|
+
const deliverAssistantMessage = async (assistantMessage) => {
|
|
1223
|
+
if (!assistantMessage.text.trim()) {
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
failureCode = "delivery_failed";
|
|
1227
|
+
const deliveryState2 = await getDeliveryConversation();
|
|
1228
|
+
let messageTs;
|
|
1229
|
+
try {
|
|
1230
|
+
messageTs = await sendSlackReply({
|
|
1231
|
+
channelId: runArgs.channelId,
|
|
1232
|
+
conversationId: runArgs.conversationId,
|
|
1233
|
+
text: assistantMessage.text,
|
|
1234
|
+
threadTs: runArgs.threadTs
|
|
1235
|
+
});
|
|
1236
|
+
} catch (error) {
|
|
1237
|
+
if (isRetryableSlackPostError(error)) {
|
|
1238
|
+
throw new RetryableDeliveryError(error);
|
|
1239
|
+
}
|
|
1240
|
+
throw error;
|
|
1241
|
+
}
|
|
1242
|
+
assistantMessageDelivered = true;
|
|
1243
|
+
const recordedMessageId = recordDeliveredAssistantMessage({
|
|
1244
|
+
conversation: deliveryState2.conversation,
|
|
1245
|
+
sessionId: deliveryState2.sessionId,
|
|
1246
|
+
text: assistantMessage.text,
|
|
1247
|
+
userMessageId: deliveryState2.userMessageId
|
|
1248
|
+
});
|
|
1249
|
+
if (messageTs) {
|
|
1250
|
+
markConversationMessage(deliveryState2.conversation, recordedMessageId, {
|
|
1251
|
+
slackTs: messageTs
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
try {
|
|
1255
|
+
await persistWithRetry(
|
|
1256
|
+
() => persistConversationMessages({
|
|
1257
|
+
conversation: deliveryState2.conversation,
|
|
1258
|
+
conversationId
|
|
1259
|
+
})
|
|
1260
|
+
);
|
|
1261
|
+
} catch (error) {
|
|
1262
|
+
logException(
|
|
1263
|
+
new Error("Accepted assistant message persistence failed"),
|
|
1264
|
+
"slack_resume_assistant_message_post_delivery_persist_failed",
|
|
1265
|
+
getResumeLogContext(runArgs, lockKey),
|
|
1266
|
+
{ "error.type": error instanceof Error ? error.name : typeof error },
|
|
1267
|
+
"Failed to persist an accepted resumed assistant message"
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
failureCode = "agent_run_failed";
|
|
1271
|
+
};
|
|
1272
|
+
const deliveryState = await getDeliveryConversation();
|
|
1273
|
+
assistantMessageDelivered = turnHasReply(
|
|
1274
|
+
deliveryState.conversation,
|
|
1275
|
+
sessionId
|
|
1276
|
+
);
|
|
1277
|
+
const replyContext = createResumeReplyContext(runArgs, status, {
|
|
1278
|
+
onAssistantMessage: deliverAssistantMessage
|
|
1279
|
+
});
|
|
1280
|
+
if (runArgs.inputMessageIds?.length) {
|
|
1281
|
+
await turnLifecycle.start({
|
|
1282
|
+
conversationId: runArgs.conversationId,
|
|
1283
|
+
turnId: runArgs.turnId,
|
|
1284
|
+
createdAtMs: Date.now(),
|
|
1285
|
+
inputMessageIds: [...new Set(runArgs.inputMessageIds)],
|
|
1286
|
+
surface: "slack"
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
const replyPromise = runArgs.agentRunner.run(replyContext);
|
|
1290
|
+
const replyTimeoutMs = resolveReplyTimeoutMs(runArgs.replyTimeoutMs);
|
|
1291
|
+
const outcome = typeof replyTimeoutMs === "number" ? await Promise.race([
|
|
1292
|
+
replyPromise,
|
|
1293
|
+
new Promise(
|
|
1294
|
+
(_, reject) => setTimeout(
|
|
1295
|
+
() => reject(
|
|
1296
|
+
new Error(
|
|
1297
|
+
`executeAgentRun timed out after ${replyTimeoutMs}ms`
|
|
1298
|
+
)
|
|
1299
|
+
),
|
|
1300
|
+
replyTimeoutMs
|
|
1301
|
+
)
|
|
1302
|
+
)
|
|
1303
|
+
]) : await replyPromise;
|
|
1304
|
+
if (outcome.status !== "completed") {
|
|
1305
|
+
await status.clear();
|
|
1306
|
+
const onAuthPause = runArgs.onAuthPause;
|
|
1307
|
+
const onSuspend = runArgs.onSuspend;
|
|
1308
|
+
if (outcome.status === "awaiting_auth" && onAuthPause) {
|
|
1309
|
+
deferredAuthInfo = {
|
|
1310
|
+
providerDisplayName: outcome.providerDisplayName,
|
|
1311
|
+
actorId: isUserActor(resumeActor) ? resumeActor.userId : void 0,
|
|
1312
|
+
...outcome.requestText ? { requestText: outcome.requestText } : {}
|
|
1313
|
+
};
|
|
1314
|
+
deferredPauseHandler = async () => {
|
|
1315
|
+
await onAuthPause({
|
|
1316
|
+
providerDisplayName: outcome.providerDisplayName
|
|
1317
|
+
});
|
|
1318
|
+
};
|
|
1319
|
+
} else if (outcome.status === "suspended" && onSuspend) {
|
|
1320
|
+
deferredPauseHandler = async () => {
|
|
1321
|
+
await onSuspend(outcome.resumeVersion);
|
|
1322
|
+
};
|
|
1323
|
+
} else {
|
|
1324
|
+
deferredFailureHandler = async () => {
|
|
1325
|
+
await handleResumeFailure({
|
|
1326
|
+
body: "Failed to resume Slack turn",
|
|
1327
|
+
error: new Error(
|
|
1328
|
+
`Resumed run ended ${outcome.status} without a pause handler`
|
|
1329
|
+
),
|
|
1330
|
+
eventName: "slack_resume_turn_failed",
|
|
1331
|
+
failureCode: "agent_run_failed",
|
|
1332
|
+
turnLifecycle,
|
|
1333
|
+
lockKey,
|
|
1334
|
+
resumeArgs: runArgs
|
|
1335
|
+
});
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
} else {
|
|
1339
|
+
const finalized = finalizeFailedTurnReplyWithEvent({
|
|
1340
|
+
reply: outcome.result,
|
|
1341
|
+
logException,
|
|
1342
|
+
context: getResumeLogContext(runArgs, lockKey)
|
|
1343
|
+
});
|
|
1344
|
+
const reply = finalized.reply;
|
|
1345
|
+
if (reply.diagnostics.outcome !== "success") {
|
|
1346
|
+
await deliverAssistantMessage({ text: reply.text });
|
|
1347
|
+
}
|
|
1348
|
+
runResultHandled = true;
|
|
1349
|
+
await status.clear();
|
|
1350
|
+
failureCode = "persistence_failed";
|
|
1351
|
+
if (reply.piMessages?.length) {
|
|
1352
|
+
await persistCompletedSessionRecord({
|
|
1353
|
+
conversationId: runArgs.conversationId,
|
|
1354
|
+
sessionId: runArgs.turnId,
|
|
1355
|
+
allMessages: reply.piMessages,
|
|
1356
|
+
modelId: reply.diagnostics.modelId,
|
|
1357
|
+
currentDurationMs: reply.diagnostics.durationMs,
|
|
1358
|
+
currentUsage: reply.diagnostics.usage,
|
|
1359
|
+
destination: replyContext.routing.destination,
|
|
1360
|
+
source: replyContext.routing.source,
|
|
1361
|
+
actor: resumeActor,
|
|
1362
|
+
surface: "slack"
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
await runArgs.commitResult?.(reply);
|
|
1366
|
+
if (reply.diagnostics.outcome === "success") {
|
|
1367
|
+
await turnLifecycle.complete({
|
|
1368
|
+
conversationId: runArgs.conversationId,
|
|
1369
|
+
turnId: runArgs.turnId,
|
|
1370
|
+
createdAtMs: Date.now(),
|
|
1371
|
+
outcome: assistantMessageDelivered ? "success" : "no_reply"
|
|
1372
|
+
});
|
|
1373
|
+
} else {
|
|
1374
|
+
await turnLifecycle.fail({
|
|
1375
|
+
conversationId: runArgs.conversationId,
|
|
1376
|
+
turnId: runArgs.turnId,
|
|
1377
|
+
createdAtMs: Date.now(),
|
|
1378
|
+
failureCode: "model_execution_failed",
|
|
1379
|
+
...finalized.eventId ? { eventId: finalized.eventId } : {}
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
if (reply.diagnostics.outcome === "success") {
|
|
1383
|
+
try {
|
|
1384
|
+
const params = {
|
|
1385
|
+
conversationId: runArgs.conversationId,
|
|
1386
|
+
sessionId: runArgs.turnId
|
|
1387
|
+
};
|
|
1388
|
+
if (runArgs.scheduleSessionCompletedPluginTasks) {
|
|
1389
|
+
await runArgs.scheduleSessionCompletedPluginTasks(params);
|
|
1390
|
+
} else {
|
|
1391
|
+
await scheduleSessionCompletedPluginTasks(params);
|
|
1392
|
+
}
|
|
1393
|
+
} catch (scheduleError) {
|
|
1394
|
+
logException(
|
|
1395
|
+
scheduleError,
|
|
1396
|
+
"plugin_session_completed_task_schedule_failed",
|
|
1397
|
+
getResumeLogContext(runArgs, lockKey),
|
|
1398
|
+
{},
|
|
1399
|
+
"Plugin session.completed task scheduling failed"
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
} catch (error) {
|
|
1405
|
+
await status.clear();
|
|
1406
|
+
if (runResultHandled) {
|
|
1407
|
+
postDeliveryCommitError = error;
|
|
1408
|
+
try {
|
|
1409
|
+
await runArgs.onPostDeliveryCommitFailure?.(error);
|
|
1410
|
+
} catch (terminalizeError) {
|
|
1411
|
+
logException(
|
|
1412
|
+
terminalizeError,
|
|
1413
|
+
"slack_resume_post_delivery_terminalize_failed",
|
|
1414
|
+
getResumeLogContext(runArgs, lockKey),
|
|
1415
|
+
{},
|
|
1416
|
+
"Failed to terminalize resumed turn after post-delivery commit failure"
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
} else {
|
|
1420
|
+
deferredFailureHandler = async () => {
|
|
1421
|
+
await handleResumeFailure({
|
|
1422
|
+
body: "Failed to resume Slack turn",
|
|
1423
|
+
error,
|
|
1424
|
+
eventName: "slack_resume_turn_failed",
|
|
1425
|
+
failureCode,
|
|
1426
|
+
turnLifecycle,
|
|
1427
|
+
lockKey,
|
|
1428
|
+
resumeArgs: runArgs
|
|
1429
|
+
});
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
} finally {
|
|
1433
|
+
if (runResultHandled) {
|
|
1434
|
+
await processingReaction?.complete();
|
|
1435
|
+
} else {
|
|
1436
|
+
await processingReaction?.stop();
|
|
1437
|
+
}
|
|
1438
|
+
await stateAdapter.releaseLock(lock);
|
|
1439
|
+
}
|
|
1440
|
+
if (postDeliveryCommitError) {
|
|
1441
|
+
const eventId = logException(
|
|
1442
|
+
postDeliveryCommitError,
|
|
1443
|
+
"slack_resume_success_handler_failed",
|
|
1444
|
+
getResumeLogContext(runArgs, lockKey),
|
|
1445
|
+
{},
|
|
1446
|
+
"Failed to persist resumed turn state after assistant output handling"
|
|
1447
|
+
);
|
|
1448
|
+
await turnLifecycle.fail({
|
|
1449
|
+
conversationId: runArgs.conversationId,
|
|
1450
|
+
turnId: runArgs.turnId,
|
|
1451
|
+
createdAtMs: Date.now(),
|
|
1452
|
+
failureCode: "persistence_failed",
|
|
1453
|
+
...eventId ? { eventId } : {}
|
|
1454
|
+
});
|
|
1455
|
+
throw postDeliveryCommitError;
|
|
1456
|
+
}
|
|
1457
|
+
if (deferredPauseHandler) {
|
|
1458
|
+
try {
|
|
1459
|
+
await deferredPauseHandler();
|
|
1460
|
+
if (deferredAuthInfo) {
|
|
1461
|
+
await postSlackMessageBestEffort(
|
|
1462
|
+
runArgs.channelId,
|
|
1463
|
+
runArgs.threadTs,
|
|
1464
|
+
buildAuthPauseResponse(
|
|
1465
|
+
deferredAuthInfo.actorId,
|
|
1466
|
+
deferredAuthInfo.providerDisplayName,
|
|
1467
|
+
deferredAuthInfo.requestText
|
|
1468
|
+
),
|
|
1469
|
+
runArgs.conversationId
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
return true;
|
|
1473
|
+
} catch (pauseError) {
|
|
1474
|
+
await handleResumeFailure({
|
|
1475
|
+
body: "Failed to handle resumed turn pause",
|
|
1476
|
+
error: pauseError,
|
|
1477
|
+
eventName: "slack_resume_pause_handler_failed",
|
|
1478
|
+
failureCode: "persistence_failed",
|
|
1479
|
+
turnLifecycle,
|
|
1480
|
+
lockKey,
|
|
1481
|
+
resumeArgs: runArgs
|
|
1482
|
+
});
|
|
1483
|
+
return true;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
if (deferredFailureHandler) {
|
|
1487
|
+
await deferredFailureHandler();
|
|
1488
|
+
}
|
|
1489
|
+
return true;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// src/chat/task-execution/state.ts
|
|
1493
|
+
import { randomUUID } from "crypto";
|
|
1494
|
+
import { destinationSchema } from "@sentry/junior-plugin-api";
|
|
1495
|
+
import { z } from "zod";
|
|
1496
|
+
var CONVERSATION_PREFIX = "junior:conversation";
|
|
1497
|
+
var CONVERSATION_SCHEMA_VERSION = 2;
|
|
1498
|
+
var CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH = 1e4;
|
|
1499
|
+
var CONVERSATION_INDEX_LOCK_TTL_MS = 1e4;
|
|
1500
|
+
var CONVERSATION_INDEX_LOCK_WAIT_MS = 2e3;
|
|
1501
|
+
var CONVERSATION_INDEX_LOCK_RETRY_MS = 25;
|
|
1502
|
+
var CONVERSATION_MUTATION_LOCK_TTL_MS = 1e4;
|
|
1503
|
+
var CONVERSATION_MUTATION_WAIT_MS = 1e4;
|
|
1504
|
+
var CONVERSATION_MUTATION_RETRY_MS = 25;
|
|
1505
|
+
var InvalidConversationRecordError = class extends Error {
|
|
1506
|
+
constructor(conversationId) {
|
|
1507
|
+
super(`Conversation record is invalid for ${conversationId}`);
|
|
1508
|
+
this.name = "InvalidConversationRecordError";
|
|
1509
|
+
}
|
|
1510
|
+
};
|
|
1511
|
+
function isInvalidConversationRecordError(error) {
|
|
1512
|
+
return error instanceof InvalidConversationRecordError;
|
|
1513
|
+
}
|
|
1514
|
+
var ConversationMutationFencedError = class extends Error {
|
|
1515
|
+
constructor(conversationId) {
|
|
1516
|
+
super(
|
|
1517
|
+
`Conversation mutation lock was lost before write for ${conversationId}`
|
|
1518
|
+
);
|
|
1519
|
+
this.name = "ConversationMutationFencedError";
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
var CONVERSATION_BY_ACTIVITY_INDEX_KEY = `${CONVERSATION_PREFIX}:by-activity`;
|
|
1523
|
+
var CONVERSATION_ACTIVE_INDEX_KEY = `${CONVERSATION_PREFIX}:active`;
|
|
1524
|
+
var CONVERSATION_WORK_LEASE_TTL_MS = 9e4;
|
|
1525
|
+
var CONVERSATION_WORK_CHECK_IN_INTERVAL_MS = 15e3;
|
|
1526
|
+
var CONVERSATION_WORK_STALE_ENQUEUE_MS = 6e4;
|
|
1527
|
+
var CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS = 5;
|
|
1528
|
+
var inboundMessageSourceSchema = z.enum([
|
|
1529
|
+
"api",
|
|
1530
|
+
"internal",
|
|
1531
|
+
"local",
|
|
1532
|
+
"plugin",
|
|
1533
|
+
"resource_event",
|
|
1534
|
+
"scheduler",
|
|
1535
|
+
"slack"
|
|
1536
|
+
]);
|
|
1537
|
+
var agentInputSchema = z.object({
|
|
1538
|
+
attachments: z.array(z.unknown()).optional(),
|
|
1539
|
+
authorId: z.string().min(1).optional(),
|
|
1540
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
1541
|
+
text: z.string()
|
|
1542
|
+
}).strict().refine((input) => input.text.trim() || input.attachments?.length, {
|
|
1543
|
+
message: "agent input requires text or attachments"
|
|
1544
|
+
});
|
|
1545
|
+
var inboundMessageDeliverySchema = z.enum(["defer", "interrupt"]);
|
|
1546
|
+
var inboundMessageSchema = z.object({
|
|
1547
|
+
attemptCount: z.number().finite().optional(),
|
|
1548
|
+
conversationId: z.string().refine((value) => value.trim().length > 0),
|
|
1549
|
+
createdAtMs: z.number().finite(),
|
|
1550
|
+
delivery: inboundMessageDeliverySchema,
|
|
1551
|
+
destination: destinationSchema,
|
|
1552
|
+
inboundMessageId: z.string().refine((value) => value.trim().length > 0),
|
|
1553
|
+
injectedAtMs: z.number().finite().optional(),
|
|
1554
|
+
input: agentInputSchema,
|
|
1555
|
+
receivedAtMs: z.number().finite(),
|
|
1556
|
+
source: inboundMessageSourceSchema
|
|
1557
|
+
}).strict();
|
|
1558
|
+
function conversationKey(conversationId) {
|
|
1559
|
+
return `${CONVERSATION_PREFIX}:${conversationId}`;
|
|
1560
|
+
}
|
|
1561
|
+
function indexLockKey(indexKey) {
|
|
1562
|
+
return `${indexKey}:lock`;
|
|
1563
|
+
}
|
|
1564
|
+
function mutationLockKey(conversationId) {
|
|
1565
|
+
return `${CONVERSATION_PREFIX}:mutation:${conversationId}`;
|
|
1566
|
+
}
|
|
1567
|
+
function now() {
|
|
1568
|
+
return Date.now();
|
|
1569
|
+
}
|
|
1570
|
+
function compareMessages(left, right) {
|
|
1571
|
+
return left.createdAtMs - right.createdAtMs || left.receivedAtMs - right.receivedAtMs || left.inboundMessageId.localeCompare(right.inboundMessageId);
|
|
1572
|
+
}
|
|
1573
|
+
function inputHasAttachments(input) {
|
|
1574
|
+
return Array.isArray(input.attachments) && input.attachments.length > 0;
|
|
1575
|
+
}
|
|
1576
|
+
function upgradedPendingMessage(stored, duplicate) {
|
|
1577
|
+
const promotesInterrupt = stored.delivery === "defer" && duplicate.delivery === "interrupt";
|
|
1578
|
+
const delivery = promotesInterrupt ? "interrupt" : stored.delivery;
|
|
1579
|
+
const input = promotesInterrupt ? {
|
|
1580
|
+
...duplicate.input,
|
|
1581
|
+
...inputHasAttachments(duplicate.input) || !inputHasAttachments(stored.input) ? {} : { attachments: stored.input.attachments }
|
|
1582
|
+
} : inputHasAttachments(duplicate.input) && !inputHasAttachments(stored.input) ? duplicate.input : stored.input;
|
|
1583
|
+
if (input === stored.input && delivery === stored.delivery) {
|
|
1584
|
+
return stored;
|
|
1585
|
+
}
|
|
1586
|
+
return { ...stored, delivery, input };
|
|
1587
|
+
}
|
|
1588
|
+
function compareIndexDescending(left, right) {
|
|
1589
|
+
return right.score - left.score || right.conversationId.localeCompare(left.conversationId);
|
|
1590
|
+
}
|
|
1591
|
+
function compareIndexAscending(left, right) {
|
|
1592
|
+
return left.score - right.score || left.conversationId.localeCompare(right.conversationId);
|
|
1593
|
+
}
|
|
1594
|
+
function uniqueStrings(values) {
|
|
1595
|
+
return [...new Set(values)];
|
|
1596
|
+
}
|
|
1597
|
+
function normalizeSource(value) {
|
|
1598
|
+
const parsed = inboundMessageSourceSchema.safeParse(value);
|
|
1599
|
+
return parsed.success ? parsed.data : void 0;
|
|
1600
|
+
}
|
|
1601
|
+
function normalizeExecutionStatus(value) {
|
|
1602
|
+
if (value === "awaiting_resume" || value === "failed" || value === "idle" || value === "pending" || value === "running") {
|
|
1603
|
+
return value;
|
|
1604
|
+
}
|
|
1605
|
+
return void 0;
|
|
1606
|
+
}
|
|
1607
|
+
function normalizeMessage(value, schemaVersion) {
|
|
1608
|
+
const candidate = schemaVersion === 1 && isRecord(value) && value.delivery === void 0 ? {
|
|
1609
|
+
...value,
|
|
1610
|
+
delivery: "defer"
|
|
1611
|
+
} : value;
|
|
1612
|
+
const parsed = inboundMessageSchema.safeParse(candidate);
|
|
1613
|
+
return parsed.success ? parsed.data : void 0;
|
|
1614
|
+
}
|
|
1615
|
+
function isFinalAttempt(message) {
|
|
1616
|
+
return (message.attemptCount ?? 0) >= CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS - 1;
|
|
1617
|
+
}
|
|
1618
|
+
function normalizeActor(value) {
|
|
1619
|
+
return parseStoredSlackActor(value);
|
|
1620
|
+
}
|
|
1621
|
+
function normalizeLease(value) {
|
|
1622
|
+
if (!isRecord(value)) {
|
|
1623
|
+
return void 0;
|
|
1624
|
+
}
|
|
1625
|
+
const token = toOptionalString(value.token);
|
|
1626
|
+
const acquiredAtMs = toOptionalNumber(value.acquiredAtMs);
|
|
1627
|
+
const lastCheckInAtMs = toOptionalNumber(value.lastCheckInAtMs);
|
|
1628
|
+
const expiresAtMs = toOptionalNumber(value.expiresAtMs);
|
|
1629
|
+
if (!token || typeof acquiredAtMs !== "number" || typeof lastCheckInAtMs !== "number" || typeof expiresAtMs !== "number") {
|
|
1630
|
+
return void 0;
|
|
1631
|
+
}
|
|
1632
|
+
return {
|
|
1633
|
+
token,
|
|
1634
|
+
acquiredAtMs,
|
|
1635
|
+
lastCheckInAtMs,
|
|
1636
|
+
expiresAtMs
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
function normalizeExecution(conversationId, value, schemaVersion) {
|
|
1640
|
+
if (!isRecord(value)) {
|
|
1641
|
+
return void 0;
|
|
1642
|
+
}
|
|
1643
|
+
const status = normalizeExecutionStatus(value.status);
|
|
1644
|
+
if (!status) {
|
|
1645
|
+
return void 0;
|
|
1646
|
+
}
|
|
1647
|
+
const pendingMessages2 = [];
|
|
1648
|
+
if (Array.isArray(value.pendingMessages)) {
|
|
1649
|
+
for (const rawMessage of value.pendingMessages) {
|
|
1650
|
+
const message = normalizeMessage(rawMessage, schemaVersion);
|
|
1651
|
+
if (!message || message.conversationId !== conversationId) {
|
|
1652
|
+
return void 0;
|
|
1653
|
+
}
|
|
1654
|
+
if (message.injectedAtMs !== void 0) {
|
|
1655
|
+
continue;
|
|
1656
|
+
}
|
|
1657
|
+
pendingMessages2.push(message);
|
|
1658
|
+
}
|
|
1659
|
+
pendingMessages2.sort(compareMessages);
|
|
1660
|
+
}
|
|
1661
|
+
const inboundMessageIds = Array.isArray(value.inboundMessageIds) ? uniqueStrings(
|
|
1662
|
+
value.inboundMessageIds.map((id) => typeof id === "string" ? id : void 0).filter((id) => Boolean(id))
|
|
1663
|
+
) : [];
|
|
1664
|
+
const lease = normalizeLease(value.lease);
|
|
1665
|
+
const normalizedStatus = status === "idle" && lease ? "running" : status === "idle" && pendingMessages2.length > 0 ? "pending" : status;
|
|
1666
|
+
return {
|
|
1667
|
+
status: normalizedStatus,
|
|
1668
|
+
inboundMessageIds: uniqueStrings([
|
|
1669
|
+
...inboundMessageIds,
|
|
1670
|
+
...pendingMessages2.map((message) => message.inboundMessageId)
|
|
1671
|
+
]),
|
|
1672
|
+
pendingCount: pendingMessages2.length,
|
|
1673
|
+
pendingMessages: pendingMessages2,
|
|
1674
|
+
lease,
|
|
1675
|
+
lastCheckpointAtMs: toOptionalNumber(value.lastCheckpointAtMs),
|
|
1676
|
+
lastEnqueuedAtMs: toOptionalNumber(value.lastEnqueuedAtMs),
|
|
1677
|
+
runId: toOptionalString(value.runId),
|
|
1678
|
+
updatedAtMs: toOptionalNumber(value.updatedAtMs)
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
function normalizeConversation(conversationId, value) {
|
|
1682
|
+
if (!isRecord(value) || value.schemaVersion !== 1 && value.schemaVersion !== CONVERSATION_SCHEMA_VERSION) {
|
|
1683
|
+
return void 0;
|
|
1684
|
+
}
|
|
1685
|
+
const schemaVersion = value.schemaVersion;
|
|
1686
|
+
const storedConversationId = toOptionalString(value.conversationId);
|
|
1687
|
+
const createdAtMs = toOptionalNumber(value.createdAtMs);
|
|
1688
|
+
const lastActivityAtMs = toOptionalNumber(value.lastActivityAtMs);
|
|
1689
|
+
const updatedAtMs = toOptionalNumber(value.updatedAtMs);
|
|
1690
|
+
const execution = normalizeExecution(
|
|
1691
|
+
conversationId,
|
|
1692
|
+
value.execution,
|
|
1693
|
+
schemaVersion
|
|
1694
|
+
);
|
|
1695
|
+
const destination = value.destination === void 0 ? void 0 : parseDestination(value.destination);
|
|
1696
|
+
if (storedConversationId !== conversationId || typeof createdAtMs !== "number" || typeof lastActivityAtMs !== "number" || typeof updatedAtMs !== "number" || !execution || value.destination !== void 0 && !destination) {
|
|
1697
|
+
return void 0;
|
|
1698
|
+
}
|
|
1699
|
+
if (execution.pendingMessages.length > 0 && (!destination || execution.pendingMessages.some(
|
|
1700
|
+
(message) => !sameDestination(message.destination, destination)
|
|
1701
|
+
))) {
|
|
1702
|
+
return void 0;
|
|
1703
|
+
}
|
|
1704
|
+
return {
|
|
1705
|
+
schemaVersion: CONVERSATION_SCHEMA_VERSION,
|
|
1706
|
+
conversationId,
|
|
1707
|
+
createdAtMs,
|
|
1708
|
+
lastActivityAtMs,
|
|
1709
|
+
updatedAtMs,
|
|
1710
|
+
execution,
|
|
1711
|
+
...destination ? { destination } : {},
|
|
1712
|
+
...toOptionalString(value.title) ? { title: toOptionalString(value.title) } : {},
|
|
1713
|
+
...toOptionalString(value.channelName) ? { channelName: toOptionalString(value.channelName) } : {},
|
|
1714
|
+
...normalizeActor(value.actor) ? { actor: normalizeActor(value.actor) } : {},
|
|
1715
|
+
...normalizeSource(value.source) ? { source: normalizeSource(value.source) } : {}
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
function emptyConversation(args) {
|
|
1719
|
+
return {
|
|
1720
|
+
schemaVersion: CONVERSATION_SCHEMA_VERSION,
|
|
1721
|
+
conversationId: args.conversationId,
|
|
1722
|
+
createdAtMs: args.nowMs,
|
|
1723
|
+
lastActivityAtMs: args.nowMs,
|
|
1724
|
+
updatedAtMs: args.nowMs,
|
|
1725
|
+
...args.destination ? { destination: args.destination } : {},
|
|
1726
|
+
...args.source ? { source: args.source } : {},
|
|
1727
|
+
execution: {
|
|
1728
|
+
status: "idle",
|
|
1729
|
+
inboundMessageIds: [],
|
|
1730
|
+
pendingCount: 0,
|
|
1731
|
+
pendingMessages: [],
|
|
1732
|
+
updatedAtMs: args.nowMs
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
function isLeaseActive(lease, nowMs) {
|
|
1737
|
+
return Boolean(lease && lease.expiresAtMs > nowMs);
|
|
1738
|
+
}
|
|
1739
|
+
function pendingMessages(conversation) {
|
|
1740
|
+
return [...conversation.execution.pendingMessages].sort(compareMessages);
|
|
1741
|
+
}
|
|
1742
|
+
function isRunnableStatus(status) {
|
|
1743
|
+
return status !== "failed" && status !== "idle";
|
|
1744
|
+
}
|
|
1745
|
+
function hasRunnableWork(conversation) {
|
|
1746
|
+
return isRunnableStatus(conversation.execution.status) || pendingMessages(conversation).length > 0;
|
|
1747
|
+
}
|
|
1748
|
+
function executionWithPendingMessages(execution, pending) {
|
|
1749
|
+
const pendingMessages2 = [...pending].sort(compareMessages);
|
|
1750
|
+
const status = execution.status === "idle" && execution.lease ? "running" : execution.status === "idle" && pendingMessages2.length > 0 ? "pending" : execution.status;
|
|
1751
|
+
return {
|
|
1752
|
+
...execution,
|
|
1753
|
+
status,
|
|
1754
|
+
inboundMessageIds: uniqueStrings([
|
|
1755
|
+
...execution.inboundMessageIds,
|
|
1756
|
+
...pendingMessages2.map((message) => message.inboundMessageId)
|
|
1757
|
+
]),
|
|
1758
|
+
pendingMessages: pendingMessages2,
|
|
1759
|
+
pendingCount: pendingMessages2.length
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
function withExecutionUpdate(conversation, execution, nowMs) {
|
|
1763
|
+
return {
|
|
1764
|
+
...conversation,
|
|
1765
|
+
updatedAtMs: nowMs,
|
|
1766
|
+
execution: {
|
|
1767
|
+
...executionWithPendingMessages(execution, execution.pendingMessages),
|
|
1768
|
+
updatedAtMs: nowMs
|
|
1769
|
+
}
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
async function getConnectedState(stateAdapter) {
|
|
1773
|
+
const state = stateAdapter ?? getStateAdapter();
|
|
1774
|
+
await state.connect();
|
|
1775
|
+
return state;
|
|
1776
|
+
}
|
|
1777
|
+
async function sleep(ms) {
|
|
1778
|
+
await new Promise((resolve) => {
|
|
1779
|
+
const timer = setTimeout(resolve, ms);
|
|
1780
|
+
timer.unref?.();
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
async function withIndexLock(state, indexKey, callback) {
|
|
1784
|
+
const startedAtMs = now();
|
|
1785
|
+
let lock;
|
|
1786
|
+
while (true) {
|
|
1787
|
+
lock = await state.acquireLock(
|
|
1788
|
+
indexLockKey(indexKey),
|
|
1789
|
+
CONVERSATION_INDEX_LOCK_TTL_MS
|
|
1790
|
+
);
|
|
1791
|
+
if (lock) {
|
|
1792
|
+
break;
|
|
1793
|
+
}
|
|
1794
|
+
if (now() - startedAtMs >= CONVERSATION_INDEX_LOCK_WAIT_MS) {
|
|
1795
|
+
throw new Error(
|
|
1796
|
+
`Could not acquire conversation index lock for ${indexKey}`
|
|
1797
|
+
);
|
|
1798
|
+
}
|
|
1799
|
+
await sleep(CONVERSATION_INDEX_LOCK_RETRY_MS);
|
|
1800
|
+
}
|
|
1801
|
+
try {
|
|
1802
|
+
return await callback();
|
|
1803
|
+
} finally {
|
|
1804
|
+
await state.releaseLock(lock);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
function normalizeIndexEntry(value) {
|
|
1808
|
+
if (!isRecord(value)) {
|
|
1809
|
+
return void 0;
|
|
1810
|
+
}
|
|
1811
|
+
const conversationId = toOptionalString(value.conversationId);
|
|
1812
|
+
const score = toOptionalNumber(value.score);
|
|
1813
|
+
if (!conversationId || typeof score !== "number") {
|
|
1814
|
+
return void 0;
|
|
1815
|
+
}
|
|
1816
|
+
return { conversationId, score };
|
|
1817
|
+
}
|
|
1818
|
+
function uniqueIndexEntries(value) {
|
|
1819
|
+
if (!Array.isArray(value)) {
|
|
1820
|
+
return [];
|
|
1821
|
+
}
|
|
1822
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1823
|
+
for (const item of value) {
|
|
1824
|
+
const entry = normalizeIndexEntry(item);
|
|
1825
|
+
if (!entry) {
|
|
1826
|
+
continue;
|
|
1827
|
+
}
|
|
1828
|
+
const existing = entries.get(entry.conversationId);
|
|
1829
|
+
if (!existing || entry.score > existing.score) {
|
|
1830
|
+
entries.set(entry.conversationId, entry);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
return [...entries.values()];
|
|
1834
|
+
}
|
|
1835
|
+
function retainedIndexEntries(indexKey, entries) {
|
|
1836
|
+
if (indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY) {
|
|
1837
|
+
return entries.sort(compareIndexDescending).slice(0, CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH);
|
|
1838
|
+
}
|
|
1839
|
+
if (indexKey === CONVERSATION_ACTIVE_INDEX_KEY) {
|
|
1840
|
+
return entries.sort(compareIndexAscending);
|
|
1841
|
+
}
|
|
1842
|
+
throw new Error(`Unknown conversation index ${indexKey}`);
|
|
1843
|
+
}
|
|
1844
|
+
function redisIndexKey(indexKey) {
|
|
1845
|
+
const prefix = getChatConfig().state.keyPrefix;
|
|
1846
|
+
return [...prefix ? [prefix] : [], indexKey].join(":");
|
|
1847
|
+
}
|
|
1848
|
+
function parseRedisIndexEntries(values) {
|
|
1849
|
+
if (!Array.isArray(values)) {
|
|
1850
|
+
return [];
|
|
1851
|
+
}
|
|
1852
|
+
const entries = [];
|
|
1853
|
+
for (let index = 0; index < values.length; index += 2) {
|
|
1854
|
+
const conversationId = toOptionalString(values[index]);
|
|
1855
|
+
const score = typeof values[index + 1] === "number" ? values[index + 1] : Number(values[index + 1]);
|
|
1856
|
+
if (!conversationId || !Number.isFinite(score)) {
|
|
1857
|
+
continue;
|
|
1858
|
+
}
|
|
1859
|
+
entries.push({ conversationId, score });
|
|
1860
|
+
}
|
|
1861
|
+
return entries;
|
|
1862
|
+
}
|
|
1863
|
+
function redisConversationIndexStore(client) {
|
|
1864
|
+
const upsertBoundedActivityScript = `
|
|
1865
|
+
redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2])
|
|
1866
|
+
redis.call("PEXPIRE", KEYS[1], ARGV[3])
|
|
1867
|
+
local extra = redis.call("ZCARD", KEYS[1]) - tonumber(ARGV[4])
|
|
1868
|
+
if extra > 0 then
|
|
1869
|
+
redis.call("ZREMRANGEBYRANK", KEYS[1], 0, extra - 1)
|
|
1870
|
+
end
|
|
1871
|
+
return 1
|
|
1872
|
+
`;
|
|
1873
|
+
return {
|
|
1874
|
+
async list(args) {
|
|
1875
|
+
const key = redisIndexKey(args.indexKey);
|
|
1876
|
+
const limit = args.limit;
|
|
1877
|
+
const offset = Math.max(0, args.offset ?? 0);
|
|
1878
|
+
if (limit === 0) {
|
|
1879
|
+
return [];
|
|
1880
|
+
}
|
|
1881
|
+
const values = args.scoreMax !== void 0 ? await client.sendCommand([
|
|
1882
|
+
"ZRANGEBYSCORE",
|
|
1883
|
+
key,
|
|
1884
|
+
"-inf",
|
|
1885
|
+
String(args.scoreMax),
|
|
1886
|
+
"WITHSCORES",
|
|
1887
|
+
...limit !== void 0 || offset > 0 ? ["LIMIT", String(offset), String(limit ?? 1e9)] : []
|
|
1888
|
+
]) : await client.sendCommand([
|
|
1889
|
+
args.order === "asc" ? "ZRANGE" : "ZREVRANGE",
|
|
1890
|
+
key,
|
|
1891
|
+
String(offset),
|
|
1892
|
+
String(
|
|
1893
|
+
limit === void 0 ? -1 : offset + Math.max(0, limit - 1)
|
|
1894
|
+
),
|
|
1895
|
+
"WITHSCORES"
|
|
1896
|
+
]);
|
|
1897
|
+
return parseRedisIndexEntries(values);
|
|
1898
|
+
},
|
|
1899
|
+
async remove(args) {
|
|
1900
|
+
await client.sendCommand([
|
|
1901
|
+
"ZREM",
|
|
1902
|
+
redisIndexKey(args.indexKey),
|
|
1903
|
+
args.conversationId
|
|
1904
|
+
]);
|
|
1905
|
+
},
|
|
1906
|
+
async upsert(args) {
|
|
1907
|
+
const key = redisIndexKey(args.indexKey);
|
|
1908
|
+
if (args.indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY) {
|
|
1909
|
+
await client.sendCommand([
|
|
1910
|
+
"EVAL",
|
|
1911
|
+
upsertBoundedActivityScript,
|
|
1912
|
+
"1",
|
|
1913
|
+
key,
|
|
1914
|
+
String(args.score),
|
|
1915
|
+
args.conversationId,
|
|
1916
|
+
String(JUNIOR_THREAD_STATE_TTL_MS),
|
|
1917
|
+
String(CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH)
|
|
1918
|
+
]);
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
if (args.indexKey === CONVERSATION_ACTIVE_INDEX_KEY) {
|
|
1922
|
+
await client.sendCommand([
|
|
1923
|
+
"ZADD",
|
|
1924
|
+
key,
|
|
1925
|
+
String(args.score),
|
|
1926
|
+
args.conversationId
|
|
1927
|
+
]);
|
|
1928
|
+
await client.sendCommand([
|
|
1929
|
+
"PEXPIRE",
|
|
1930
|
+
key,
|
|
1931
|
+
String(JUNIOR_THREAD_STATE_TTL_MS)
|
|
1932
|
+
]);
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
throw new Error(`Unknown conversation index ${args.indexKey}`);
|
|
1936
|
+
}
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
function emulatedConversationIndexStore(state) {
|
|
1940
|
+
const readIndex = async (indexKey) => uniqueIndexEntries(await state.get(indexKey));
|
|
1941
|
+
const writeIndex = async (indexKey, entries) => {
|
|
1942
|
+
await state.set(indexKey, entries, JUNIOR_THREAD_STATE_TTL_MS);
|
|
1943
|
+
};
|
|
1944
|
+
return {
|
|
1945
|
+
async list(args) {
|
|
1946
|
+
const entries = (await readIndex(args.indexKey)).filter(
|
|
1947
|
+
(entry) => args.scoreMax === void 0 ? true : entry.score <= args.scoreMax
|
|
1948
|
+
).sort(
|
|
1949
|
+
args.order === "asc" ? compareIndexAscending : compareIndexDescending
|
|
1950
|
+
);
|
|
1951
|
+
const offset = Math.max(0, args.offset ?? 0);
|
|
1952
|
+
return entries.slice(
|
|
1953
|
+
offset,
|
|
1954
|
+
args.limit === void 0 ? entries.length : offset + args.limit
|
|
1955
|
+
);
|
|
1956
|
+
},
|
|
1957
|
+
async remove(args) {
|
|
1958
|
+
await withIndexLock(state, args.indexKey, async () => {
|
|
1959
|
+
const entries = await readIndex(args.indexKey);
|
|
1960
|
+
const next = entries.filter(
|
|
1961
|
+
(entry) => entry.conversationId !== args.conversationId
|
|
1962
|
+
);
|
|
1963
|
+
if (next.length === entries.length) {
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
await writeIndex(args.indexKey, next);
|
|
1967
|
+
});
|
|
1968
|
+
},
|
|
1969
|
+
async upsert(args) {
|
|
1970
|
+
await withIndexLock(state, args.indexKey, async () => {
|
|
1971
|
+
const entries = await readIndex(args.indexKey);
|
|
1972
|
+
const withoutCurrent = entries.filter(
|
|
1973
|
+
(entry) => entry.conversationId !== args.conversationId
|
|
1974
|
+
);
|
|
1975
|
+
const next = retainedIndexEntries(args.indexKey, [
|
|
1976
|
+
...withoutCurrent,
|
|
1977
|
+
{ conversationId: args.conversationId, score: args.score }
|
|
1978
|
+
]);
|
|
1979
|
+
await writeIndex(args.indexKey, next);
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
async function getConversationIndexStore(state) {
|
|
1985
|
+
const redisStateAdapter = await getDefaultRedisStateAdapterFor(state);
|
|
1986
|
+
if (redisStateAdapter) {
|
|
1987
|
+
return redisConversationIndexStore(redisStateAdapter.getClient());
|
|
1988
|
+
}
|
|
1989
|
+
return emulatedConversationIndexStore(state);
|
|
1990
|
+
}
|
|
1991
|
+
async function upsertIndexEntry(args) {
|
|
1992
|
+
const index = await getConversationIndexStore(args.state);
|
|
1993
|
+
await index.upsert({
|
|
1994
|
+
conversationId: args.conversationId,
|
|
1995
|
+
indexKey: args.indexKey,
|
|
1996
|
+
score: args.score
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
async function removeIndexEntry(args) {
|
|
2000
|
+
const index = await getConversationIndexStore(args.state);
|
|
2001
|
+
await index.remove({
|
|
2002
|
+
conversationId: args.conversationId,
|
|
2003
|
+
indexKey: args.indexKey
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
async function acquireMutationLock(state, conversationId) {
|
|
2007
|
+
const startedAtMs = now();
|
|
2008
|
+
while (true) {
|
|
2009
|
+
const lock = await state.acquireLock(
|
|
2010
|
+
mutationLockKey(conversationId),
|
|
2011
|
+
CONVERSATION_MUTATION_LOCK_TTL_MS
|
|
2012
|
+
);
|
|
2013
|
+
if (lock) {
|
|
2014
|
+
return lock;
|
|
2015
|
+
}
|
|
2016
|
+
if (now() - startedAtMs >= CONVERSATION_MUTATION_WAIT_MS) {
|
|
2017
|
+
throw new Error(
|
|
2018
|
+
`Could not acquire conversation mutation lock for ${conversationId}`
|
|
2019
|
+
);
|
|
2020
|
+
}
|
|
2021
|
+
await sleep(CONVERSATION_MUTATION_RETRY_MS);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
async function withConversationMutation(args, callback) {
|
|
2025
|
+
const state = await getConnectedState(args.state);
|
|
2026
|
+
const lock = await acquireMutationLock(state, args.conversationId);
|
|
2027
|
+
try {
|
|
2028
|
+
return await callback(state, lock);
|
|
2029
|
+
} finally {
|
|
2030
|
+
await state.releaseLock(lock);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
async function readConversation(state, conversationId) {
|
|
2034
|
+
const raw = await state.get(conversationKey(conversationId));
|
|
2035
|
+
if (raw == null) {
|
|
2036
|
+
return void 0;
|
|
2037
|
+
}
|
|
2038
|
+
const conversation = normalizeConversation(conversationId, raw);
|
|
2039
|
+
if (!conversation) {
|
|
2040
|
+
throw new InvalidConversationRecordError(conversationId);
|
|
2041
|
+
}
|
|
2042
|
+
return conversation;
|
|
2043
|
+
}
|
|
2044
|
+
async function writeConversation(state, lock, conversation) {
|
|
2045
|
+
const execution = executionWithPendingMessages(
|
|
2046
|
+
conversation.execution,
|
|
2047
|
+
conversation.execution.pendingMessages
|
|
2048
|
+
);
|
|
2049
|
+
const next = {
|
|
2050
|
+
...conversation,
|
|
2051
|
+
execution
|
|
2052
|
+
};
|
|
2053
|
+
const fenced = await state.extendLock(
|
|
2054
|
+
lock,
|
|
2055
|
+
CONVERSATION_MUTATION_LOCK_TTL_MS
|
|
2056
|
+
);
|
|
2057
|
+
if (!fenced) {
|
|
2058
|
+
throw new ConversationMutationFencedError(next.conversationId);
|
|
2059
|
+
}
|
|
2060
|
+
await state.set(
|
|
2061
|
+
conversationKey(next.conversationId),
|
|
2062
|
+
next,
|
|
2063
|
+
JUNIOR_THREAD_STATE_TTL_MS
|
|
2064
|
+
);
|
|
2065
|
+
await upsertIndexEntry({
|
|
2066
|
+
state,
|
|
2067
|
+
indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY,
|
|
2068
|
+
conversationId: next.conversationId,
|
|
2069
|
+
score: next.lastActivityAtMs
|
|
2070
|
+
});
|
|
2071
|
+
if (!hasRunnableWork(next)) {
|
|
2072
|
+
await removeIndexEntry({
|
|
2073
|
+
state,
|
|
2074
|
+
indexKey: CONVERSATION_ACTIVE_INDEX_KEY,
|
|
2075
|
+
conversationId: next.conversationId
|
|
2076
|
+
});
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
await upsertIndexEntry({
|
|
2080
|
+
state,
|
|
2081
|
+
indexKey: CONVERSATION_ACTIVE_INDEX_KEY,
|
|
2082
|
+
conversationId: next.conversationId,
|
|
2083
|
+
score: next.execution.updatedAtMs ?? next.updatedAtMs
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
function assertSameConversationDestination(args) {
|
|
2087
|
+
if (!args.current || sameDestination(args.current, args.next)) {
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
throw new Error(
|
|
2091
|
+
`Conversation destination changed for ${args.conversationId}`
|
|
2092
|
+
);
|
|
2093
|
+
}
|
|
2094
|
+
function conversationWorkState(conversation) {
|
|
2095
|
+
const lease = conversation.execution.lease;
|
|
2096
|
+
return {
|
|
2097
|
+
...conversation,
|
|
2098
|
+
lastEnqueuedAtMs: conversation.execution.lastEnqueuedAtMs,
|
|
2099
|
+
...lease ? {
|
|
2100
|
+
lease: {
|
|
2101
|
+
acquiredAtMs: lease.acquiredAtMs,
|
|
2102
|
+
lastCheckInAtMs: lease.lastCheckInAtMs,
|
|
2103
|
+
leaseExpiresAtMs: lease.expiresAtMs,
|
|
2104
|
+
leaseToken: lease.token
|
|
2105
|
+
}
|
|
2106
|
+
} : {},
|
|
2107
|
+
messages: pendingMessages(conversation),
|
|
2108
|
+
needsRun: hasRunnableWork(conversation)
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
async function getConversation(args) {
|
|
2112
|
+
const state = await getConnectedState(args.state);
|
|
2113
|
+
return await readConversation(state, args.conversationId);
|
|
2114
|
+
}
|
|
2115
|
+
async function getConversationWorkState(args) {
|
|
2116
|
+
const conversation = await getConversation(args);
|
|
2117
|
+
return conversation ? conversationWorkState(conversation) : void 0;
|
|
2118
|
+
}
|
|
2119
|
+
async function appendInboundMessage(args) {
|
|
2120
|
+
const nowMs = args.nowMs ?? now();
|
|
2121
|
+
return await withConversationMutation(
|
|
2122
|
+
{ conversationId: args.message.conversationId, state: args.state },
|
|
2123
|
+
async (state, lock) => {
|
|
2124
|
+
const current = await readConversation(state, args.message.conversationId) ?? emptyConversation({
|
|
2125
|
+
conversationId: args.message.conversationId,
|
|
2126
|
+
destination: args.message.destination,
|
|
2127
|
+
nowMs,
|
|
2128
|
+
source: args.message.source
|
|
2129
|
+
});
|
|
2130
|
+
assertSameConversationDestination({
|
|
2131
|
+
conversationId: args.message.conversationId,
|
|
2132
|
+
current: current.destination,
|
|
2133
|
+
next: args.message.destination
|
|
2134
|
+
});
|
|
2135
|
+
const existingPending = current.execution.pendingMessages.some(
|
|
2136
|
+
(message) => message.inboundMessageId === args.message.inboundMessageId
|
|
2137
|
+
);
|
|
2138
|
+
const existing = current.execution.inboundMessageIds.includes(
|
|
2139
|
+
args.message.inboundMessageId
|
|
2140
|
+
);
|
|
2141
|
+
if (existing) {
|
|
2142
|
+
if (!existingPending) {
|
|
2143
|
+
return { status: "duplicate" };
|
|
2144
|
+
}
|
|
2145
|
+
const nextStatus = current.execution.status === "idle" ? "pending" : current.execution.status;
|
|
2146
|
+
await writeConversation(
|
|
2147
|
+
state,
|
|
2148
|
+
lock,
|
|
2149
|
+
withExecutionUpdate(
|
|
2150
|
+
current,
|
|
2151
|
+
{
|
|
2152
|
+
...current.execution,
|
|
2153
|
+
status: nextStatus,
|
|
2154
|
+
pendingMessages: current.execution.pendingMessages.map(
|
|
2155
|
+
(message) => message.inboundMessageId === args.message.inboundMessageId ? upgradedPendingMessage(message, args.message) : message
|
|
2156
|
+
)
|
|
2157
|
+
},
|
|
2158
|
+
nowMs
|
|
2159
|
+
)
|
|
2160
|
+
);
|
|
2161
|
+
return { status: "duplicate" };
|
|
2162
|
+
}
|
|
2163
|
+
const status = current.execution.lease && current.execution.status === "running" ? "running" : current.execution.lease ? "awaiting_resume" : "pending";
|
|
2164
|
+
const next = {
|
|
2165
|
+
...current,
|
|
2166
|
+
destination: current.destination ?? args.message.destination,
|
|
2167
|
+
source: current.source ?? args.message.source,
|
|
2168
|
+
lastActivityAtMs: nowMs
|
|
2169
|
+
};
|
|
2170
|
+
await writeConversation(
|
|
2171
|
+
state,
|
|
2172
|
+
lock,
|
|
2173
|
+
withExecutionUpdate(
|
|
2174
|
+
next,
|
|
2175
|
+
{
|
|
2176
|
+
...current.execution,
|
|
2177
|
+
status,
|
|
2178
|
+
inboundMessageIds: [
|
|
2179
|
+
...current.execution.inboundMessageIds,
|
|
2180
|
+
args.message.inboundMessageId
|
|
2181
|
+
],
|
|
2182
|
+
pendingMessages: [
|
|
2183
|
+
...current.execution.pendingMessages,
|
|
2184
|
+
args.message
|
|
2185
|
+
].sort(compareMessages)
|
|
2186
|
+
},
|
|
2187
|
+
nowMs
|
|
2188
|
+
)
|
|
2189
|
+
);
|
|
2190
|
+
return { status: "appended" };
|
|
2191
|
+
}
|
|
2192
|
+
);
|
|
2193
|
+
}
|
|
2194
|
+
async function requestConversationWork(args) {
|
|
2195
|
+
const nowMs = args.nowMs ?? now();
|
|
2196
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2197
|
+
const existing = await readConversation(state, args.conversationId);
|
|
2198
|
+
if (existing) {
|
|
2199
|
+
assertSameConversationDestination({
|
|
2200
|
+
conversationId: args.conversationId,
|
|
2201
|
+
current: existing.destination,
|
|
2202
|
+
next: args.destination
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2205
|
+
const current = existing ?? emptyConversation({
|
|
2206
|
+
conversationId: args.conversationId,
|
|
2207
|
+
destination: args.destination,
|
|
2208
|
+
nowMs
|
|
2209
|
+
});
|
|
2210
|
+
const status = current.execution.lease ? "awaiting_resume" : "pending";
|
|
2211
|
+
await writeConversation(
|
|
2212
|
+
state,
|
|
2213
|
+
lock,
|
|
2214
|
+
withExecutionUpdate(
|
|
2215
|
+
{
|
|
2216
|
+
...current,
|
|
2217
|
+
destination: current.destination ?? args.destination
|
|
2218
|
+
},
|
|
2219
|
+
{
|
|
2220
|
+
...current.execution,
|
|
2221
|
+
status
|
|
2222
|
+
},
|
|
2223
|
+
nowMs
|
|
2224
|
+
)
|
|
2225
|
+
);
|
|
2226
|
+
return { status: existing === void 0 ? "created" : "updated" };
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
async function markConversationWorkEnqueued(args) {
|
|
2230
|
+
const nowMs = args.nowMs ?? now();
|
|
2231
|
+
await withConversationMutation(args, async (state, lock) => {
|
|
2232
|
+
const current = await readConversation(state, args.conversationId);
|
|
2233
|
+
if (!current) {
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
await writeConversation(
|
|
2237
|
+
state,
|
|
2238
|
+
lock,
|
|
2239
|
+
withExecutionUpdate(
|
|
2240
|
+
current,
|
|
2241
|
+
{
|
|
2242
|
+
...current.execution,
|
|
2243
|
+
lastEnqueuedAtMs: nowMs
|
|
2244
|
+
},
|
|
2245
|
+
nowMs
|
|
2246
|
+
)
|
|
2247
|
+
);
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2250
|
+
async function clearConsumedConversationWake(args) {
|
|
2251
|
+
const nowMs = args.nowMs ?? now();
|
|
2252
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2253
|
+
const current = await readConversation(state, args.conversationId);
|
|
2254
|
+
if (!current || hasRunnableWork(current) || current.execution.lastEnqueuedAtMs === void 0) {
|
|
2255
|
+
return false;
|
|
2256
|
+
}
|
|
2257
|
+
await writeConversation(
|
|
2258
|
+
state,
|
|
2259
|
+
lock,
|
|
2260
|
+
withExecutionUpdate(
|
|
2261
|
+
current,
|
|
2262
|
+
{
|
|
2263
|
+
...current.execution,
|
|
2264
|
+
lastEnqueuedAtMs: void 0
|
|
2265
|
+
},
|
|
2266
|
+
nowMs
|
|
2267
|
+
)
|
|
2268
|
+
);
|
|
2269
|
+
return true;
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
async function startConversationWork(args) {
|
|
2273
|
+
const nowMs = args.nowMs ?? now();
|
|
2274
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2275
|
+
const current = await readConversation(state, args.conversationId);
|
|
2276
|
+
if (!current) {
|
|
2277
|
+
return { status: "no_work" };
|
|
2278
|
+
}
|
|
2279
|
+
if (isLeaseActive(current.execution.lease, nowMs)) {
|
|
2280
|
+
return {
|
|
2281
|
+
status: "active",
|
|
2282
|
+
leaseExpiresAtMs: current.execution.lease.expiresAtMs
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
if (!hasRunnableWork(current)) {
|
|
2286
|
+
return { status: "no_work" };
|
|
2287
|
+
}
|
|
2288
|
+
const lease = {
|
|
2289
|
+
token: randomUUID(),
|
|
2290
|
+
acquiredAtMs: nowMs,
|
|
2291
|
+
lastCheckInAtMs: nowMs,
|
|
2292
|
+
expiresAtMs: nowMs + CONVERSATION_WORK_LEASE_TTL_MS
|
|
2293
|
+
};
|
|
2294
|
+
await writeConversation(
|
|
2295
|
+
state,
|
|
2296
|
+
lock,
|
|
2297
|
+
withExecutionUpdate(
|
|
2298
|
+
current,
|
|
2299
|
+
{
|
|
2300
|
+
...current.execution,
|
|
2301
|
+
lease,
|
|
2302
|
+
status: current.execution.status === "awaiting_resume" ? "awaiting_resume" : "running",
|
|
2303
|
+
runId: current.execution.runId ?? randomUUID(),
|
|
2304
|
+
lastEnqueuedAtMs: void 0
|
|
2305
|
+
},
|
|
2306
|
+
nowMs
|
|
2307
|
+
)
|
|
2308
|
+
);
|
|
2309
|
+
return {
|
|
2310
|
+
status: "acquired",
|
|
2311
|
+
leaseToken: lease.token,
|
|
2312
|
+
leaseExpiresAtMs: lease.expiresAtMs
|
|
2313
|
+
};
|
|
2314
|
+
});
|
|
2315
|
+
}
|
|
2316
|
+
async function checkInConversationWork(args) {
|
|
2317
|
+
const nowMs = args.nowMs ?? now();
|
|
2318
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2319
|
+
const current = await readConversation(state, args.conversationId);
|
|
2320
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) {
|
|
2321
|
+
return false;
|
|
2322
|
+
}
|
|
2323
|
+
await writeConversation(
|
|
2324
|
+
state,
|
|
2325
|
+
lock,
|
|
2326
|
+
withExecutionUpdate(
|
|
2327
|
+
current,
|
|
2328
|
+
{
|
|
2329
|
+
...current.execution,
|
|
2330
|
+
lease: {
|
|
2331
|
+
...current.execution.lease,
|
|
2332
|
+
lastCheckInAtMs: nowMs,
|
|
2333
|
+
expiresAtMs: nowMs + CONVERSATION_WORK_LEASE_TTL_MS
|
|
2334
|
+
}
|
|
2335
|
+
},
|
|
2336
|
+
nowMs
|
|
2337
|
+
)
|
|
2338
|
+
);
|
|
2339
|
+
return true;
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
async function drainConversationMailbox(args) {
|
|
2343
|
+
const nowMs = args.nowMs ?? now();
|
|
2344
|
+
const pending = await withConversationMutation(args, async (state) => {
|
|
2345
|
+
const current = await readConversation(state, args.conversationId);
|
|
2346
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) {
|
|
2347
|
+
throw new Error(
|
|
2348
|
+
`Conversation lease is not held for ${args.conversationId}`
|
|
2349
|
+
);
|
|
2350
|
+
}
|
|
2351
|
+
return pendingMessages(current);
|
|
2352
|
+
});
|
|
2353
|
+
if (pending.length === 0) {
|
|
2354
|
+
return { changed: false, messages: [] };
|
|
2355
|
+
}
|
|
2356
|
+
const result = await args.handle(pending);
|
|
2357
|
+
const pendingIds = new Set(
|
|
2358
|
+
pending.map((message) => message.inboundMessageId)
|
|
2359
|
+
);
|
|
2360
|
+
const acknowledgedIds = new Set(
|
|
2361
|
+
result ?? pending.map((message) => message.inboundMessageId)
|
|
2362
|
+
);
|
|
2363
|
+
for (const inboundMessageId of acknowledgedIds) {
|
|
2364
|
+
if (!pendingIds.has(inboundMessageId)) {
|
|
2365
|
+
throw new Error(
|
|
2366
|
+
`Conversation mailbox drain result is not pending for ${args.conversationId}`
|
|
2367
|
+
);
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
if (acknowledgedIds.size === 0) {
|
|
2371
|
+
return { changed: false, messages: [] };
|
|
2372
|
+
}
|
|
2373
|
+
await withConversationMutation(args, async (state, lock) => {
|
|
2374
|
+
const current = await readConversation(state, args.conversationId);
|
|
2375
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) {
|
|
2376
|
+
throw new Error(
|
|
2377
|
+
`Conversation lease is not held for ${args.conversationId}`
|
|
2378
|
+
);
|
|
2379
|
+
}
|
|
2380
|
+
const pendingMessages2 = current.execution.pendingMessages.filter(
|
|
2381
|
+
(message) => !acknowledgedIds.has(message.inboundMessageId)
|
|
2382
|
+
);
|
|
2383
|
+
await writeConversation(
|
|
2384
|
+
state,
|
|
2385
|
+
lock,
|
|
2386
|
+
withExecutionUpdate(
|
|
2387
|
+
current,
|
|
2388
|
+
{
|
|
2389
|
+
...current.execution,
|
|
2390
|
+
status: current.execution.status === "pending" && pendingMessages2.length === 0 ? "running" : current.execution.status,
|
|
2391
|
+
pendingMessages: pendingMessages2
|
|
2392
|
+
},
|
|
2393
|
+
nowMs
|
|
2394
|
+
)
|
|
2395
|
+
);
|
|
2396
|
+
});
|
|
2397
|
+
return {
|
|
2398
|
+
changed: true,
|
|
2399
|
+
messages: pending.filter(
|
|
2400
|
+
(message) => acknowledgedIds.has(message.inboundMessageId)
|
|
2401
|
+
)
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
async function ackMessages(args) {
|
|
2405
|
+
const nowMs = args.nowMs ?? now();
|
|
2406
|
+
const inboundMessageIds = new Set(args.inboundMessageIds);
|
|
2407
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2408
|
+
const current = await readConversation(state, args.conversationId);
|
|
2409
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) {
|
|
2410
|
+
return false;
|
|
2411
|
+
}
|
|
2412
|
+
if (inboundMessageIds.size === 0) {
|
|
2413
|
+
return true;
|
|
2414
|
+
}
|
|
2415
|
+
const pendingMessages2 = current.execution.pendingMessages.filter(
|
|
2416
|
+
(message) => !inboundMessageIds.has(message.inboundMessageId)
|
|
2417
|
+
);
|
|
2418
|
+
if (pendingMessages2.length === current.execution.pendingMessages.length) {
|
|
2419
|
+
return true;
|
|
2420
|
+
}
|
|
2421
|
+
await writeConversation(
|
|
2422
|
+
state,
|
|
2423
|
+
lock,
|
|
2424
|
+
withExecutionUpdate(
|
|
2425
|
+
current,
|
|
2426
|
+
{
|
|
2427
|
+
...current.execution,
|
|
2428
|
+
pendingMessages: pendingMessages2
|
|
2429
|
+
},
|
|
2430
|
+
nowMs
|
|
2431
|
+
)
|
|
2432
|
+
);
|
|
2433
|
+
return true;
|
|
2434
|
+
});
|
|
2435
|
+
}
|
|
2436
|
+
async function requestConversationContinuation(args) {
|
|
2437
|
+
const nowMs = args.nowMs ?? now();
|
|
2438
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2439
|
+
const current = await readConversation(state, args.conversationId);
|
|
2440
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) {
|
|
2441
|
+
return false;
|
|
2442
|
+
}
|
|
2443
|
+
assertSameConversationDestination({
|
|
2444
|
+
conversationId: args.conversationId,
|
|
2445
|
+
current: current.destination,
|
|
2446
|
+
next: args.destination
|
|
2447
|
+
});
|
|
2448
|
+
await writeConversation(
|
|
2449
|
+
state,
|
|
2450
|
+
lock,
|
|
2451
|
+
withExecutionUpdate(
|
|
2452
|
+
current,
|
|
2453
|
+
{
|
|
2454
|
+
...current.execution,
|
|
2455
|
+
status: "awaiting_resume"
|
|
2456
|
+
},
|
|
2457
|
+
nowMs
|
|
2458
|
+
)
|
|
2459
|
+
);
|
|
2460
|
+
return true;
|
|
2461
|
+
});
|
|
2462
|
+
}
|
|
2463
|
+
async function beginConversationResume(args) {
|
|
2464
|
+
const nowMs = args.nowMs ?? now();
|
|
2465
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2466
|
+
const current = await readConversation(state, args.conversationId);
|
|
2467
|
+
if (!current || current.execution.lease?.token !== args.leaseToken || current.execution.status !== "awaiting_resume") {
|
|
2468
|
+
return false;
|
|
2469
|
+
}
|
|
2470
|
+
await writeConversation(
|
|
2471
|
+
state,
|
|
2472
|
+
lock,
|
|
2473
|
+
withExecutionUpdate(
|
|
2474
|
+
current,
|
|
2475
|
+
{
|
|
2476
|
+
...current.execution,
|
|
2477
|
+
status: "running"
|
|
2478
|
+
},
|
|
2479
|
+
nowMs
|
|
2480
|
+
)
|
|
2481
|
+
);
|
|
2482
|
+
return true;
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
async function releaseConversationWork(args) {
|
|
2486
|
+
const nowMs = args.nowMs ?? now();
|
|
2487
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2488
|
+
const current = await readConversation(state, args.conversationId);
|
|
2489
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) {
|
|
2490
|
+
return false;
|
|
2491
|
+
}
|
|
2492
|
+
await writeConversation(
|
|
2493
|
+
state,
|
|
2494
|
+
lock,
|
|
2495
|
+
withExecutionUpdate(
|
|
2496
|
+
current,
|
|
2497
|
+
{
|
|
2498
|
+
...current.execution,
|
|
2499
|
+
lease: void 0,
|
|
2500
|
+
status: current.execution.status === "running" ? "pending" : current.execution.status
|
|
2501
|
+
},
|
|
2502
|
+
nowMs
|
|
2503
|
+
)
|
|
2504
|
+
);
|
|
2505
|
+
return true;
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
async function completeConversationWork(args) {
|
|
2509
|
+
const nowMs = args.nowMs ?? now();
|
|
2510
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2511
|
+
const current = await readConversation(state, args.conversationId);
|
|
2512
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) {
|
|
2513
|
+
return "lost_lease";
|
|
2514
|
+
}
|
|
2515
|
+
const hasPending = pendingMessages(current).length > 0;
|
|
2516
|
+
const needsRun = current.execution.status === "awaiting_resume";
|
|
2517
|
+
const runnable = needsRun || hasPending;
|
|
2518
|
+
await writeConversation(
|
|
2519
|
+
state,
|
|
2520
|
+
lock,
|
|
2521
|
+
withExecutionUpdate(
|
|
2522
|
+
current,
|
|
2523
|
+
{
|
|
2524
|
+
...current.execution,
|
|
2525
|
+
lease: void 0,
|
|
2526
|
+
status: needsRun ? "awaiting_resume" : hasPending ? "pending" : "idle",
|
|
2527
|
+
runId: runnable ? current.execution.runId : void 0
|
|
2528
|
+
},
|
|
2529
|
+
nowMs
|
|
2530
|
+
)
|
|
2531
|
+
);
|
|
2532
|
+
return runnable ? "pending" : "completed";
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
async function recordAttemptFailure(args) {
|
|
2536
|
+
const nowMs = args.nowMs ?? now();
|
|
2537
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2538
|
+
const current = await readConversation(state, args.conversationId);
|
|
2539
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) {
|
|
2540
|
+
return {
|
|
2541
|
+
status: "lost_lease",
|
|
2542
|
+
pendingCount: 0,
|
|
2543
|
+
deadLetteredMessages: []
|
|
2544
|
+
};
|
|
2545
|
+
}
|
|
2546
|
+
const pendingIds = new Set(
|
|
2547
|
+
current.execution.pendingMessages.map(
|
|
2548
|
+
(message) => message.inboundMessageId
|
|
2549
|
+
)
|
|
2550
|
+
);
|
|
2551
|
+
if (args.inboundMessageIds.length === 0 || args.inboundMessageIds.some((id) => !pendingIds.has(id))) {
|
|
2552
|
+
return {
|
|
2553
|
+
status: "skipped",
|
|
2554
|
+
pendingCount: current.execution.pendingMessages.length,
|
|
2555
|
+
deadLetteredMessages: []
|
|
2556
|
+
};
|
|
2557
|
+
}
|
|
2558
|
+
const attemptedIds = new Set(args.inboundMessageIds);
|
|
2559
|
+
const deadLetteredMessages = [];
|
|
2560
|
+
const pendingMessages2 = [];
|
|
2561
|
+
for (const message of current.execution.pendingMessages) {
|
|
2562
|
+
if (!attemptedIds.has(message.inboundMessageId)) {
|
|
2563
|
+
pendingMessages2.push(message);
|
|
2564
|
+
continue;
|
|
2565
|
+
}
|
|
2566
|
+
const attempted = {
|
|
2567
|
+
...message,
|
|
2568
|
+
attemptCount: (message.attemptCount ?? 0) + 1
|
|
2569
|
+
};
|
|
2570
|
+
if (attempted.attemptCount >= CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS) {
|
|
2571
|
+
deadLetteredMessages.push(attempted);
|
|
2572
|
+
continue;
|
|
2573
|
+
}
|
|
2574
|
+
pendingMessages2.push(attempted);
|
|
2575
|
+
}
|
|
2576
|
+
await writeConversation(
|
|
2577
|
+
state,
|
|
2578
|
+
lock,
|
|
2579
|
+
withExecutionUpdate(
|
|
2580
|
+
current,
|
|
2581
|
+
{
|
|
2582
|
+
...current.execution,
|
|
2583
|
+
pendingMessages: pendingMessages2
|
|
2584
|
+
},
|
|
2585
|
+
nowMs
|
|
2586
|
+
)
|
|
2587
|
+
);
|
|
2588
|
+
return {
|
|
2589
|
+
status: "recorded",
|
|
2590
|
+
pendingCount: pendingMessages2.length,
|
|
2591
|
+
deadLetteredMessages
|
|
2592
|
+
};
|
|
2593
|
+
});
|
|
2594
|
+
}
|
|
2595
|
+
async function deadLetterAttempt(args) {
|
|
2596
|
+
const nowMs = args.nowMs ?? now();
|
|
2597
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2598
|
+
const current = await readConversation(state, args.conversationId);
|
|
2599
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) {
|
|
2600
|
+
return "lost_lease";
|
|
2601
|
+
}
|
|
2602
|
+
const runnable = pendingMessages(current).length > 0;
|
|
2603
|
+
await writeConversation(
|
|
2604
|
+
state,
|
|
2605
|
+
lock,
|
|
2606
|
+
withExecutionUpdate(
|
|
2607
|
+
current,
|
|
2608
|
+
{
|
|
2609
|
+
...current.execution,
|
|
2610
|
+
lease: void 0,
|
|
2611
|
+
status: runnable ? "pending" : "failed",
|
|
2612
|
+
runId: runnable ? current.execution.runId : void 0
|
|
2613
|
+
},
|
|
2614
|
+
nowMs
|
|
2615
|
+
)
|
|
2616
|
+
);
|
|
2617
|
+
return runnable ? "pending" : "failed";
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
async function clearExpiredConversationLease(args) {
|
|
2621
|
+
const nowMs = args.nowMs ?? now();
|
|
2622
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2623
|
+
const current = await readConversation(state, args.conversationId);
|
|
2624
|
+
if (!current?.execution.lease || current.execution.lease.expiresAtMs > nowMs) {
|
|
2625
|
+
return false;
|
|
2626
|
+
}
|
|
2627
|
+
await writeConversation(
|
|
2628
|
+
state,
|
|
2629
|
+
lock,
|
|
2630
|
+
withExecutionUpdate(
|
|
2631
|
+
current,
|
|
2632
|
+
{
|
|
2633
|
+
...current.execution,
|
|
2634
|
+
lease: void 0,
|
|
2635
|
+
status: "awaiting_resume"
|
|
2636
|
+
},
|
|
2637
|
+
nowMs
|
|
2638
|
+
)
|
|
2639
|
+
);
|
|
2640
|
+
return true;
|
|
2641
|
+
});
|
|
2642
|
+
}
|
|
2643
|
+
async function removeActiveConversation(args) {
|
|
2644
|
+
const state = await getConnectedState(args.state);
|
|
2645
|
+
await removeIndexEntry({
|
|
2646
|
+
state,
|
|
2647
|
+
indexKey: CONVERSATION_ACTIVE_INDEX_KEY,
|
|
2648
|
+
conversationId: args.conversationId
|
|
2649
|
+
});
|
|
2650
|
+
}
|
|
2651
|
+
async function listActiveConversationIds(args = {}) {
|
|
2652
|
+
const state = await getConnectedState(args.state);
|
|
2653
|
+
const index = await getConversationIndexStore(state);
|
|
2654
|
+
const entries = await index.list({
|
|
2655
|
+
indexKey: CONVERSATION_ACTIVE_INDEX_KEY,
|
|
2656
|
+
limit: args.limit,
|
|
2657
|
+
order: "asc",
|
|
2658
|
+
scoreMax: args.staleBeforeMs
|
|
2659
|
+
});
|
|
2660
|
+
return entries.map((entry) => entry.conversationId);
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
// src/chat/task-execution/store.ts
|
|
2664
|
+
function metadataStore(options) {
|
|
2665
|
+
return options.conversationStore ?? getConversationStore();
|
|
2666
|
+
}
|
|
2667
|
+
function duplicateInboundNudgeIdempotencyKey(message, nowMs) {
|
|
2668
|
+
return `duplicate:${message.conversationId}:${message.inboundMessageId}:${nowMs}`;
|
|
2669
|
+
}
|
|
2670
|
+
function hasRecentEnqueueMarker(conversation, nowMs) {
|
|
2671
|
+
const lastEnqueuedAtMs = conversation.execution.lastEnqueuedAtMs;
|
|
2672
|
+
return typeof lastEnqueuedAtMs === "number" && lastEnqueuedAtMs + CONVERSATION_WORK_STALE_ENQUEUE_MS > nowMs;
|
|
2673
|
+
}
|
|
2674
|
+
function now2() {
|
|
2675
|
+
return Date.now();
|
|
2676
|
+
}
|
|
2677
|
+
async function recordExecutionMetadata(args) {
|
|
2678
|
+
const conversation = await getConversation({
|
|
2679
|
+
conversationId: args.conversationId,
|
|
2680
|
+
state: args.state
|
|
2681
|
+
});
|
|
2682
|
+
if (!conversation) {
|
|
2683
|
+
return;
|
|
2684
|
+
}
|
|
2685
|
+
await metadataStore(args).recordExecution({
|
|
2686
|
+
channelName: conversation.channelName,
|
|
2687
|
+
conversationId: conversation.conversationId,
|
|
2688
|
+
createdAtMs: conversation.createdAtMs,
|
|
2689
|
+
destination: conversation.destination,
|
|
2690
|
+
execution: {
|
|
2691
|
+
lastCheckpointAtMs: conversation.execution.lastCheckpointAtMs,
|
|
2692
|
+
lastEnqueuedAtMs: conversation.execution.lastEnqueuedAtMs,
|
|
2693
|
+
runId: conversation.execution.runId,
|
|
2694
|
+
status: conversation.execution.status,
|
|
2695
|
+
updatedAtMs: conversation.execution.updatedAtMs
|
|
2696
|
+
},
|
|
2697
|
+
lastActivityAtMs: conversation.lastActivityAtMs,
|
|
2698
|
+
metrics: null,
|
|
2699
|
+
actor: conversation.actor,
|
|
2700
|
+
source: conversation.source,
|
|
2701
|
+
title: conversation.title,
|
|
2702
|
+
updatedAtMs: conversation.updatedAtMs
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
async function getConversationWorkState2(args) {
|
|
2706
|
+
return await getConversationWorkState(args);
|
|
2707
|
+
}
|
|
2708
|
+
function countPendingConversationMessages(conversation) {
|
|
2709
|
+
return conversation.execution.pendingMessages.length;
|
|
2710
|
+
}
|
|
2711
|
+
function hasRunnableConversationWork(conversation) {
|
|
2712
|
+
return conversation.execution.status !== "idle" || countPendingConversationMessages(conversation) > 0;
|
|
2713
|
+
}
|
|
2714
|
+
async function ensureConversationWake(args) {
|
|
2715
|
+
const nowMs = args.nowMs ?? now2();
|
|
2716
|
+
const conversation = await getConversation({
|
|
2717
|
+
conversationId: args.conversationId,
|
|
2718
|
+
state: args.state
|
|
2719
|
+
});
|
|
2720
|
+
if (!conversation || !hasRunnableConversationWork(conversation)) {
|
|
2721
|
+
return { status: "no_work" };
|
|
2722
|
+
}
|
|
2723
|
+
if (!conversation.destination) {
|
|
2724
|
+
return { status: "no_work" };
|
|
2725
|
+
}
|
|
2726
|
+
if (args.replaceExistingWake !== true && conversation.execution.lease && conversation.execution.lease.expiresAtMs > nowMs) {
|
|
2727
|
+
return { status: "lease_active" };
|
|
2728
|
+
}
|
|
2729
|
+
if (args.replaceExistingWake !== true && hasRecentEnqueueMarker(conversation, nowMs)) {
|
|
2730
|
+
return { status: "already_enqueued" };
|
|
2731
|
+
}
|
|
2732
|
+
const queueResult = await args.queue.send(
|
|
2733
|
+
{
|
|
2734
|
+
conversationId: args.conversationId
|
|
2735
|
+
},
|
|
2736
|
+
{
|
|
2737
|
+
delayMs: args.delayMs,
|
|
2738
|
+
idempotencyKey: args.idempotencyKey
|
|
2739
|
+
}
|
|
2740
|
+
);
|
|
2741
|
+
await markConversationWorkEnqueued2({
|
|
2742
|
+
conversationId: args.conversationId,
|
|
2743
|
+
conversationStore: args.conversationStore,
|
|
2744
|
+
nowMs,
|
|
2745
|
+
state: args.state
|
|
2746
|
+
});
|
|
2747
|
+
return { status: "enqueued", queueMessageId: queueResult?.messageId };
|
|
2748
|
+
}
|
|
2749
|
+
async function appendAndEnqueueInboundMessage(args) {
|
|
2750
|
+
const nowMs = args.nowMs ?? now2();
|
|
2751
|
+
const appendResult = await appendInboundMessage({
|
|
2752
|
+
message: args.message,
|
|
2753
|
+
nowMs,
|
|
2754
|
+
state: args.state
|
|
2755
|
+
});
|
|
2756
|
+
let idempotencyKey = args.message.inboundMessageId;
|
|
2757
|
+
if (appendResult.status === "duplicate") {
|
|
2758
|
+
const conversation = await getConversation({
|
|
2759
|
+
conversationId: args.message.conversationId,
|
|
2760
|
+
state: args.state
|
|
2761
|
+
});
|
|
2762
|
+
if (!conversation) {
|
|
2763
|
+
return appendResult;
|
|
2764
|
+
}
|
|
2765
|
+
if (hasRecentEnqueueMarker(conversation, nowMs)) {
|
|
2766
|
+
await recordExecutionMetadata({
|
|
2767
|
+
conversationId: args.message.conversationId,
|
|
2768
|
+
conversationStore: args.conversationStore,
|
|
2769
|
+
state: args.state
|
|
2770
|
+
});
|
|
2771
|
+
return appendResult;
|
|
2772
|
+
}
|
|
2773
|
+
const duplicateStillPending = conversation.execution.pendingMessages.some(
|
|
2774
|
+
(message) => message.inboundMessageId === args.message.inboundMessageId
|
|
2775
|
+
);
|
|
2776
|
+
if (!duplicateStillPending) {
|
|
2777
|
+
await recordExecutionMetadata({
|
|
2778
|
+
conversationId: args.message.conversationId,
|
|
2779
|
+
conversationStore: args.conversationStore,
|
|
2780
|
+
state: args.state
|
|
2781
|
+
});
|
|
2782
|
+
return appendResult;
|
|
2783
|
+
}
|
|
2784
|
+
idempotencyKey = duplicateInboundNudgeIdempotencyKey(args.message, nowMs);
|
|
2785
|
+
}
|
|
2786
|
+
const wake = await ensureConversationWake({
|
|
2787
|
+
conversationId: args.message.conversationId,
|
|
2788
|
+
conversationStore: args.conversationStore,
|
|
2789
|
+
idempotencyKey,
|
|
2790
|
+
nowMs,
|
|
2791
|
+
queue: args.queue,
|
|
2792
|
+
state: args.state
|
|
2793
|
+
});
|
|
2794
|
+
if (wake.status !== "enqueued") {
|
|
2795
|
+
await recordExecutionMetadata({
|
|
2796
|
+
conversationId: args.message.conversationId,
|
|
2797
|
+
conversationStore: args.conversationStore,
|
|
2798
|
+
state: args.state
|
|
2799
|
+
});
|
|
2800
|
+
}
|
|
2801
|
+
return {
|
|
2802
|
+
...appendResult,
|
|
2803
|
+
...wake.status === "enqueued" ? { queueMessageId: wake.queueMessageId } : {}
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
async function clearConsumedConversationWake2(args) {
|
|
2807
|
+
const result = await clearConsumedConversationWake(args);
|
|
2808
|
+
if (result) {
|
|
2809
|
+
await recordExecutionMetadata(args);
|
|
2810
|
+
}
|
|
2811
|
+
return result;
|
|
2812
|
+
}
|
|
2813
|
+
async function requestConversationWork2(args) {
|
|
2814
|
+
const result = await requestConversationWork(args);
|
|
2815
|
+
await recordExecutionMetadata({
|
|
2816
|
+
conversationId: args.conversationId,
|
|
2817
|
+
conversationStore: args.conversationStore,
|
|
2818
|
+
state: args.state
|
|
2819
|
+
});
|
|
2820
|
+
return result;
|
|
2821
|
+
}
|
|
2822
|
+
async function markConversationWorkEnqueued2(args) {
|
|
2823
|
+
await markConversationWorkEnqueued(args);
|
|
2824
|
+
await recordExecutionMetadata(args);
|
|
2825
|
+
}
|
|
2826
|
+
async function startConversationWork2(args) {
|
|
2827
|
+
const result = await startConversationWork(args);
|
|
2828
|
+
await recordExecutionMetadata(args);
|
|
2829
|
+
return result;
|
|
2830
|
+
}
|
|
2831
|
+
async function checkInConversationWork2(args) {
|
|
2832
|
+
const result = await checkInConversationWork(args);
|
|
2833
|
+
if (result) {
|
|
2834
|
+
await recordExecutionMetadata(args);
|
|
2835
|
+
}
|
|
2836
|
+
return result;
|
|
2837
|
+
}
|
|
2838
|
+
async function drainConversationMailbox2(args) {
|
|
2839
|
+
const result = await drainConversationMailbox(args);
|
|
2840
|
+
if (result.changed) {
|
|
2841
|
+
await recordExecutionMetadata(args);
|
|
2842
|
+
}
|
|
2843
|
+
return result.messages;
|
|
2844
|
+
}
|
|
2845
|
+
async function ackMessages2(args) {
|
|
2846
|
+
const result = await ackMessages(args);
|
|
2847
|
+
await recordExecutionMetadata(args);
|
|
2848
|
+
return result;
|
|
2849
|
+
}
|
|
2850
|
+
async function requestConversationContinuation2(args) {
|
|
2851
|
+
const result = await requestConversationContinuation(args);
|
|
2852
|
+
await recordExecutionMetadata(args);
|
|
2853
|
+
return result;
|
|
2854
|
+
}
|
|
2855
|
+
async function beginConversationResume2(args) {
|
|
2856
|
+
const result = await beginConversationResume(args);
|
|
2857
|
+
if (result) {
|
|
2858
|
+
await recordExecutionMetadata(args);
|
|
2859
|
+
}
|
|
2860
|
+
return result;
|
|
2861
|
+
}
|
|
2862
|
+
async function releaseConversationWork2(args) {
|
|
2863
|
+
const result = await releaseConversationWork(args);
|
|
2864
|
+
await recordExecutionMetadata(args);
|
|
2865
|
+
return result;
|
|
2866
|
+
}
|
|
2867
|
+
async function completeConversationWork2(args) {
|
|
2868
|
+
const result = await completeConversationWork(args);
|
|
2869
|
+
await recordExecutionMetadata(args);
|
|
2870
|
+
return result;
|
|
2871
|
+
}
|
|
2872
|
+
async function recordAttemptFailure2(args) {
|
|
2873
|
+
const result = await recordAttemptFailure(args);
|
|
2874
|
+
if (result.status === "recorded") {
|
|
2875
|
+
await recordExecutionMetadata(args);
|
|
2876
|
+
}
|
|
2877
|
+
return result;
|
|
2878
|
+
}
|
|
2879
|
+
async function deadLetterAttempt2(args) {
|
|
2880
|
+
const result = await deadLetterAttempt(args);
|
|
2881
|
+
await recordExecutionMetadata(args);
|
|
2882
|
+
return result;
|
|
2883
|
+
}
|
|
2884
|
+
async function clearExpiredConversationLease2(args) {
|
|
2885
|
+
const result = await clearExpiredConversationLease(args);
|
|
2886
|
+
await recordExecutionMetadata(args);
|
|
2887
|
+
return result;
|
|
2888
|
+
}
|
|
2889
|
+
async function removeActiveConversation2(args) {
|
|
2890
|
+
return await removeActiveConversation(args);
|
|
2891
|
+
}
|
|
2892
|
+
async function listActiveConversationIds2(args = {}) {
|
|
2893
|
+
return await listActiveConversationIds(args);
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
// src/chat/services/agent-continue.ts
|
|
2897
|
+
async function getAwaitingAgentContinueRequest(args) {
|
|
2898
|
+
const sessionRecord = await getAgentTurnSessionRecord(
|
|
2899
|
+
args.conversationId,
|
|
2900
|
+
args.sessionId
|
|
2901
|
+
);
|
|
2902
|
+
if (!sessionRecord || sessionRecord.state !== "awaiting_resume" || sessionRecord.resumeReason !== "timeout" && sessionRecord.resumeReason !== "yield" && sessionRecord.resumeReason !== "retry" || sessionRecord.resumeReason === "timeout" && sessionRecord.sliceId < 2) {
|
|
2903
|
+
return void 0;
|
|
2904
|
+
}
|
|
2905
|
+
if (!sessionRecord.destination) {
|
|
2906
|
+
return void 0;
|
|
2907
|
+
}
|
|
2908
|
+
return {
|
|
2909
|
+
conversationId: args.conversationId,
|
|
2910
|
+
destination: sessionRecord.destination,
|
|
2911
|
+
sessionId: args.sessionId,
|
|
2912
|
+
expectedVersion: sessionRecord.version
|
|
2913
|
+
};
|
|
2914
|
+
}
|
|
2915
|
+
async function scheduleAgentContinue(request, options = {}) {
|
|
2916
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
2917
|
+
await requestConversationWork2({
|
|
2918
|
+
conversationId: request.conversationId,
|
|
2919
|
+
destination: request.destination,
|
|
2920
|
+
nowMs,
|
|
2921
|
+
state: options.state
|
|
2922
|
+
});
|
|
2923
|
+
const queue = options.queue ?? getVercelConversationWorkQueue();
|
|
2924
|
+
await ensureConversationWake({
|
|
2925
|
+
conversationId: request.conversationId,
|
|
2926
|
+
idempotencyKey: [
|
|
2927
|
+
"agent-continue",
|
|
2928
|
+
request.conversationId,
|
|
2929
|
+
request.sessionId,
|
|
2930
|
+
request.expectedVersion,
|
|
2931
|
+
nowMs
|
|
2932
|
+
].join(":"),
|
|
2933
|
+
nowMs,
|
|
2934
|
+
queue,
|
|
2935
|
+
state: options.state
|
|
2936
|
+
});
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
// src/handlers/oauth-html.ts
|
|
2940
|
+
function htmlCallbackResponse(title, message, status) {
|
|
2941
|
+
const html = `<!DOCTYPE html>
|
|
2942
|
+
<html>
|
|
2943
|
+
<head><title>${title}</title></head>
|
|
2944
|
+
<body style="font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0;">
|
|
2945
|
+
<div style="text-align: center; max-width: 480px;">
|
|
2946
|
+
<h1>${title}</h1>
|
|
2947
|
+
<p>${message}</p>
|
|
2948
|
+
<p style="margin-top: 2rem; color: #666; font-size: 0.9em;">You can close this tab and return to Junior.</p>
|
|
2949
|
+
</div>
|
|
2950
|
+
</body>
|
|
2951
|
+
</html>`;
|
|
2952
|
+
return new Response(html, {
|
|
2953
|
+
status,
|
|
2954
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
2955
|
+
});
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
// src/handlers/mcp-oauth-callback.ts
|
|
2959
|
+
var CALLBACK_PAGES = {
|
|
2960
|
+
missing_state: {
|
|
2961
|
+
title: "Authorization failed",
|
|
2962
|
+
message: "Missing state parameter.",
|
|
2963
|
+
status: 400
|
|
2964
|
+
},
|
|
2965
|
+
provider_error: {
|
|
2966
|
+
title: "Authorization failed",
|
|
2967
|
+
message: "The provider returned an authorization error.",
|
|
2968
|
+
status: 400
|
|
2969
|
+
},
|
|
2970
|
+
missing_code: {
|
|
2971
|
+
title: "Authorization failed",
|
|
2972
|
+
message: "Missing code parameter.",
|
|
2973
|
+
status: 400
|
|
2974
|
+
},
|
|
2975
|
+
expired: {
|
|
2976
|
+
title: "Authorization expired",
|
|
2977
|
+
message: "This authorization link is no longer active. Return to Junior and retry the original request.",
|
|
2978
|
+
status: 400
|
|
2979
|
+
},
|
|
2980
|
+
success: {
|
|
2981
|
+
title: "Authorization complete",
|
|
2982
|
+
message: "Your MCP access is connected. Junior will continue the paused request in Slack.",
|
|
2983
|
+
status: 200
|
|
2984
|
+
},
|
|
2985
|
+
failure: {
|
|
2986
|
+
title: "Authorization failed",
|
|
2987
|
+
message: "Junior could not finish the authorization callback. Return to Junior and retry the original request.",
|
|
2988
|
+
status: 500
|
|
2989
|
+
}
|
|
2990
|
+
};
|
|
2991
|
+
var McpOAuthAttemptExpiredError = class extends Error {
|
|
2992
|
+
constructor() {
|
|
2993
|
+
super("MCP OAuth authorization attempt is no longer current");
|
|
2994
|
+
this.name = "McpOAuthAttemptExpiredError";
|
|
2995
|
+
}
|
|
2996
|
+
};
|
|
2997
|
+
function mcpAuthorizationId(args) {
|
|
2998
|
+
return `${args.sessionId}:mcp:${args.provider}`;
|
|
2999
|
+
}
|
|
3000
|
+
function htmlResponse(kind, options) {
|
|
3001
|
+
const page = kind === "success" && options?.local ? {
|
|
3002
|
+
...CALLBACK_PAGES.success,
|
|
3003
|
+
message: "Your MCP access is connected. Junior will continue the paused request in the local client."
|
|
3004
|
+
} : CALLBACK_PAGES[kind];
|
|
3005
|
+
return htmlCallbackResponse(page.title, page.message, page.status);
|
|
3006
|
+
}
|
|
3007
|
+
async function persistCompletedReplyState(channelId, threadTs, sessionId, reply) {
|
|
3008
|
+
const threadId = `slack:${channelId}:${threadTs}`;
|
|
3009
|
+
const currentState = await getPersistedThreadState(threadId);
|
|
3010
|
+
const conversation = coerceThreadConversationState(currentState);
|
|
3011
|
+
await hydrateConversationMessages({ conversation, conversationId: threadId });
|
|
3012
|
+
const artifacts = coerceThreadArtifactsState(currentState);
|
|
3013
|
+
const userMessage = getTurnUserMessage(conversation, sessionId);
|
|
3014
|
+
const statePatch = buildDeliveredTurnStatePatch({
|
|
3015
|
+
artifacts,
|
|
3016
|
+
conversation,
|
|
3017
|
+
reply,
|
|
3018
|
+
sessionId,
|
|
3019
|
+
userMessageId: userMessage?.id
|
|
3020
|
+
});
|
|
3021
|
+
await persistThreadStateById(threadId, {
|
|
3022
|
+
...statePatch
|
|
3023
|
+
});
|
|
3024
|
+
}
|
|
3025
|
+
async function failSessionRecordBestEffort(args) {
|
|
3026
|
+
try {
|
|
3027
|
+
await failAgentTurnSessionRecord({
|
|
3028
|
+
conversationId: args.conversationId,
|
|
3029
|
+
sessionId: args.sessionId,
|
|
3030
|
+
errorMessage: args.errorMessage,
|
|
3031
|
+
expectedVersion: args.expectedVersion
|
|
3032
|
+
});
|
|
3033
|
+
} catch (error) {
|
|
3034
|
+
logException(
|
|
3035
|
+
error,
|
|
3036
|
+
"mcp_oauth_callback_session_record_fail_persist_failed",
|
|
3037
|
+
{},
|
|
3038
|
+
{
|
|
3039
|
+
"app.ai.conversation_id": args.conversationId,
|
|
3040
|
+
"app.ai.session_id": args.sessionId
|
|
3041
|
+
},
|
|
3042
|
+
"Failed to mark MCP OAuth-resumed turn session record failed"
|
|
3043
|
+
);
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
async function persistFailedReplyState(channelId, threadTs, sessionId, expectedVersion) {
|
|
3047
|
+
const threadId = `slack:${channelId}:${threadTs}`;
|
|
3048
|
+
const currentState = await getPersistedThreadState(threadId);
|
|
3049
|
+
const conversation = coerceThreadConversationState(currentState);
|
|
3050
|
+
await hydrateConversationMessages({ conversation, conversationId: threadId });
|
|
3051
|
+
clearPendingAuth(conversation, sessionId);
|
|
3052
|
+
markTurnFailed({
|
|
3053
|
+
conversation,
|
|
3054
|
+
nowMs: Date.now(),
|
|
3055
|
+
sessionId,
|
|
3056
|
+
userMessageId: getTurnUserMessageId(conversation, sessionId),
|
|
3057
|
+
markConversationMessage,
|
|
3058
|
+
updateConversationStats
|
|
3059
|
+
});
|
|
3060
|
+
await failSessionRecordBestEffort({
|
|
3061
|
+
conversationId: threadId,
|
|
3062
|
+
sessionId,
|
|
3063
|
+
errorMessage: "OAuth-resumed MCP turn failed",
|
|
3064
|
+
expectedVersion
|
|
3065
|
+
});
|
|
3066
|
+
await persistThreadStateById(threadId, {
|
|
3067
|
+
conversation
|
|
3068
|
+
});
|
|
3069
|
+
}
|
|
3070
|
+
async function resumeAuthorizedMcpTurn(args) {
|
|
3071
|
+
const { authSession, agentRunner, provider } = args;
|
|
3072
|
+
if (!authSession.channelId || !authSession.destination || !authSession.threadTs) {
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
3075
|
+
const destination = requireSlackDestination(
|
|
3076
|
+
authSession.destination,
|
|
3077
|
+
"MCP OAuth resume"
|
|
3078
|
+
);
|
|
3079
|
+
const threadId = `slack:${authSession.channelId}:${authSession.threadTs}`;
|
|
3080
|
+
const currentState = await getPersistedThreadState(threadId);
|
|
3081
|
+
const conversation = coerceThreadConversationState(currentState);
|
|
3082
|
+
await hydrateConversationMessages({ conversation, conversationId: threadId });
|
|
3083
|
+
const pendingAuth = getConversationPendingAuth({
|
|
3084
|
+
conversation,
|
|
3085
|
+
kind: "mcp",
|
|
3086
|
+
provider,
|
|
3087
|
+
actorId: authSession.userId
|
|
3088
|
+
});
|
|
3089
|
+
if (pendingAuth?.authSessionId !== authSession.authSessionId) {
|
|
3090
|
+
return;
|
|
3091
|
+
}
|
|
3092
|
+
const resolvedSessionId = pendingAuth.sessionId;
|
|
3093
|
+
const userMessage = getTurnUserMessage(conversation, resolvedSessionId);
|
|
3094
|
+
if (!isPendingAuthLatestRequest(conversation, pendingAuth)) {
|
|
3095
|
+
clearPendingAuth(conversation, pendingAuth.sessionId);
|
|
3096
|
+
await persistThreadStateById(threadId, { conversation });
|
|
3097
|
+
await abandonAgentTurnSessionRecord({
|
|
3098
|
+
conversationId: authSession.conversationId,
|
|
3099
|
+
sessionId: pendingAuth.sessionId,
|
|
3100
|
+
errorMessage: "Auth completed after a newer thread message abandoned this blocked request."
|
|
3101
|
+
});
|
|
3102
|
+
return;
|
|
3103
|
+
}
|
|
3104
|
+
if (!userMessage) {
|
|
3105
|
+
return;
|
|
3106
|
+
}
|
|
3107
|
+
await resumeSlackTurn({
|
|
3108
|
+
messageText: userMessage.text,
|
|
3109
|
+
conversationId: authSession.conversationId,
|
|
3110
|
+
turnId: resolvedSessionId,
|
|
3111
|
+
channelId: authSession.channelId,
|
|
3112
|
+
threadTs: authSession.threadTs,
|
|
3113
|
+
messageTs: getTurnUserSlackMessageTs(userMessage),
|
|
3114
|
+
lockKey: threadId,
|
|
3115
|
+
initialText: "",
|
|
3116
|
+
agentRunner,
|
|
3117
|
+
beforeStart: async () => {
|
|
3118
|
+
const lockedState = await getPersistedThreadState(threadId);
|
|
3119
|
+
const lockedConversation = coerceThreadConversationState(lockedState);
|
|
3120
|
+
await hydrateConversationMessages({
|
|
3121
|
+
conversation: lockedConversation,
|
|
3122
|
+
conversationId: threadId
|
|
3123
|
+
});
|
|
3124
|
+
const lockedArtifacts = coerceThreadArtifactsState(lockedState);
|
|
3125
|
+
const lockedPendingAuth = getConversationPendingAuth({
|
|
3126
|
+
conversation: lockedConversation,
|
|
3127
|
+
kind: "mcp",
|
|
3128
|
+
provider,
|
|
3129
|
+
actorId: authSession.userId
|
|
3130
|
+
});
|
|
3131
|
+
if (lockedPendingAuth?.authSessionId !== authSession.authSessionId) {
|
|
3132
|
+
return false;
|
|
3133
|
+
}
|
|
3134
|
+
const lockedSessionId = lockedPendingAuth.sessionId;
|
|
3135
|
+
if (lockedSessionId !== resolvedSessionId) {
|
|
3136
|
+
return false;
|
|
3137
|
+
}
|
|
3138
|
+
if (!isPendingAuthLatestRequest(lockedConversation, lockedPendingAuth)) {
|
|
3139
|
+
clearPendingAuth(lockedConversation, lockedPendingAuth.sessionId);
|
|
3140
|
+
await persistThreadStateById(threadId, {
|
|
3141
|
+
conversation: lockedConversation
|
|
3142
|
+
});
|
|
3143
|
+
await abandonAgentTurnSessionRecord({
|
|
3144
|
+
conversationId: authSession.conversationId,
|
|
3145
|
+
sessionId: lockedPendingAuth.sessionId,
|
|
3146
|
+
errorMessage: "Auth completed after a newer thread message abandoned this blocked request."
|
|
3147
|
+
});
|
|
3148
|
+
return false;
|
|
3149
|
+
}
|
|
3150
|
+
const lockedUserMessage = getTurnUserMessage(
|
|
3151
|
+
lockedConversation,
|
|
3152
|
+
lockedSessionId
|
|
3153
|
+
);
|
|
3154
|
+
if (!lockedUserMessage) {
|
|
3155
|
+
return false;
|
|
3156
|
+
}
|
|
3157
|
+
const lockedSessionRecord = await getAgentTurnSessionRecord(
|
|
3158
|
+
authSession.conversationId,
|
|
3159
|
+
lockedSessionId
|
|
3160
|
+
);
|
|
3161
|
+
if (!lockedSessionRecord || lockedSessionRecord.state !== "awaiting_resume" || lockedSessionRecord.resumeReason !== "auth") {
|
|
3162
|
+
return false;
|
|
3163
|
+
}
|
|
3164
|
+
const lockedConversationContext = buildConversationContext(
|
|
3165
|
+
lockedConversation,
|
|
3166
|
+
{
|
|
3167
|
+
excludeMessageId: lockedUserMessage.id
|
|
3168
|
+
}
|
|
3169
|
+
);
|
|
3170
|
+
const lockedChannelConfiguration = getChannelConfigurationServiceById(
|
|
3171
|
+
authSession.channelId
|
|
3172
|
+
);
|
|
3173
|
+
let actor;
|
|
3174
|
+
try {
|
|
3175
|
+
actor = createSlackResumeActor({
|
|
3176
|
+
actor: isUserActor(lockedSessionRecord.actor) ? lockedSessionRecord.actor : void 0,
|
|
3177
|
+
teamId: destination.teamId,
|
|
3178
|
+
userId: authSession.userId
|
|
3179
|
+
});
|
|
3180
|
+
} catch {
|
|
3181
|
+
await failAgentTurnSessionRecord({
|
|
3182
|
+
conversationId: authSession.conversationId,
|
|
3183
|
+
expectedVersion: lockedSessionRecord.version,
|
|
3184
|
+
sessionId: lockedSessionId,
|
|
3185
|
+
errorMessage: "Stored Slack actor identity did not match OAuth actor"
|
|
3186
|
+
});
|
|
3187
|
+
return false;
|
|
3188
|
+
}
|
|
3189
|
+
if (!lockedSessionRecord.source) {
|
|
3190
|
+
await failAgentTurnSessionRecord({
|
|
3191
|
+
conversationId: authSession.conversationId,
|
|
3192
|
+
expectedVersion: lockedSessionRecord.version,
|
|
3193
|
+
sessionId: lockedSessionId,
|
|
3194
|
+
errorMessage: "Stored Slack source missing for MCP OAuth resume"
|
|
3195
|
+
});
|
|
3196
|
+
return false;
|
|
3197
|
+
}
|
|
3198
|
+
await recordAuthorizationCompleted({
|
|
3199
|
+
conversationId: authSession.conversationId,
|
|
3200
|
+
kind: "mcp",
|
|
3201
|
+
provider,
|
|
3202
|
+
actorId: authSession.userId,
|
|
3203
|
+
authorizationId: mcpAuthorizationId({
|
|
3204
|
+
provider,
|
|
3205
|
+
sessionId: lockedSessionId
|
|
3206
|
+
})
|
|
3207
|
+
});
|
|
3208
|
+
const lockedMessageTs = getTurnUserSlackMessageTs(lockedUserMessage);
|
|
3209
|
+
return {
|
|
3210
|
+
messageText: lockedUserMessage.text,
|
|
3211
|
+
messageTs: lockedMessageTs,
|
|
3212
|
+
inputMessageIds: [lockedUserMessage.id],
|
|
3213
|
+
replyContext: {
|
|
3214
|
+
input: {
|
|
3215
|
+
conversationContext: lockedConversationContext,
|
|
3216
|
+
// Pi history is SQL-authoritative: the resumed run reads its
|
|
3217
|
+
// session record first and falls back to the step projection.
|
|
3218
|
+
piMessages: await loadProjection({ conversationId: threadId }),
|
|
3219
|
+
...getTurnUserReplyAttachmentContext(lockedUserMessage)
|
|
3220
|
+
},
|
|
3221
|
+
routing: {
|
|
3222
|
+
credentialContext: {
|
|
3223
|
+
actor: { type: "user", userId: actor.userId }
|
|
3224
|
+
},
|
|
3225
|
+
actor,
|
|
3226
|
+
destination,
|
|
3227
|
+
source: lockedSessionRecord.source,
|
|
3228
|
+
toolChannelId: authSession.toolChannelId ?? lockedArtifacts.assistantContextChannelId ?? authSession.channelId
|
|
3229
|
+
},
|
|
3230
|
+
policy: {
|
|
3231
|
+
configuration: authSession.configuration,
|
|
3232
|
+
channelConfiguration: lockedChannelConfiguration
|
|
3233
|
+
},
|
|
3234
|
+
state: {
|
|
3235
|
+
artifactState: lockedArtifacts,
|
|
3236
|
+
pendingAuth: lockedPendingAuth,
|
|
3237
|
+
sandboxRef: getPersistedSandboxState(lockedState)
|
|
3238
|
+
},
|
|
3239
|
+
durability: {
|
|
3240
|
+
recordPendingAuth: async (nextPendingAuth) => {
|
|
3241
|
+
lockedConversation.processing.pendingAuth = nextPendingAuth;
|
|
3242
|
+
await persistThreadStateById(threadId, {
|
|
3243
|
+
conversation: lockedConversation
|
|
3244
|
+
});
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
},
|
|
3248
|
+
commitResult: async (reply) => {
|
|
3249
|
+
await persistCompletedReplyState(
|
|
3250
|
+
authSession.channelId,
|
|
3251
|
+
authSession.threadTs,
|
|
3252
|
+
lockedSessionId,
|
|
3253
|
+
reply
|
|
3254
|
+
);
|
|
3255
|
+
},
|
|
3256
|
+
onPostDeliveryCommitFailure: async () => {
|
|
3257
|
+
await failAgentTurnSessionRecord({
|
|
3258
|
+
conversationId: authSession.conversationId,
|
|
3259
|
+
expectedVersion: lockedSessionRecord.version,
|
|
3260
|
+
sessionId: lockedSessionId,
|
|
3261
|
+
errorMessage: "OAuth-resumed MCP reply was delivered but completion state did not persist"
|
|
3262
|
+
});
|
|
3263
|
+
},
|
|
3264
|
+
onFailure: async () => {
|
|
3265
|
+
try {
|
|
3266
|
+
await persistFailedReplyState(
|
|
3267
|
+
authSession.channelId,
|
|
3268
|
+
authSession.threadTs,
|
|
3269
|
+
lockedSessionId,
|
|
3270
|
+
lockedSessionRecord.version
|
|
3271
|
+
);
|
|
3272
|
+
} catch (persistError) {
|
|
3273
|
+
logException(
|
|
3274
|
+
persistError,
|
|
3275
|
+
"mcp_oauth_callback_resume_failure_persist_failed",
|
|
3276
|
+
{},
|
|
3277
|
+
{ "app.credential.provider": provider },
|
|
3278
|
+
"Failed to persist failed MCP resume state"
|
|
3279
|
+
);
|
|
3280
|
+
}
|
|
3281
|
+
},
|
|
3282
|
+
onAuthPause: async () => {
|
|
3283
|
+
await persistAuthPauseTurnState({
|
|
3284
|
+
sessionId: lockedSessionId,
|
|
3285
|
+
threadStateId: threadId
|
|
3286
|
+
});
|
|
3287
|
+
logWarn(
|
|
3288
|
+
"mcp_oauth_callback_resume_reparked_for_auth",
|
|
3289
|
+
{},
|
|
3290
|
+
{ "app.credential.provider": provider },
|
|
3291
|
+
"Resumed MCP turn requested another authorization flow"
|
|
3292
|
+
);
|
|
3293
|
+
},
|
|
3294
|
+
onSuspend: async (resumeVersion) => {
|
|
3295
|
+
await scheduleAgentContinue({
|
|
3296
|
+
conversationId: authSession.conversationId,
|
|
3297
|
+
destination,
|
|
3298
|
+
sessionId: lockedSessionId,
|
|
3299
|
+
expectedVersion: resumeVersion
|
|
3300
|
+
});
|
|
3301
|
+
}
|
|
3302
|
+
};
|
|
3303
|
+
}
|
|
3304
|
+
});
|
|
3305
|
+
}
|
|
3306
|
+
async function isCurrentMcpAuthorizationAttempt(authSession, provider) {
|
|
3307
|
+
const conversationId = mcpConversationId(authSession);
|
|
3308
|
+
if (!conversationId) {
|
|
3309
|
+
return false;
|
|
3310
|
+
}
|
|
3311
|
+
const currentState = await getPersistedThreadState(conversationId);
|
|
3312
|
+
const conversation = coerceThreadConversationState(currentState);
|
|
3313
|
+
const pendingAuth = getConversationPendingAuth({
|
|
3314
|
+
conversation,
|
|
3315
|
+
kind: "mcp",
|
|
3316
|
+
provider,
|
|
3317
|
+
actorId: authSession.userId
|
|
3318
|
+
});
|
|
3319
|
+
return pendingAuth?.authSessionId === authSession.authSessionId && pendingAuth.sessionId === authSession.sessionId;
|
|
3320
|
+
}
|
|
3321
|
+
function mcpConversationId(authSession) {
|
|
3322
|
+
if (authSession.destination?.platform === "local") {
|
|
3323
|
+
return authSession.conversationId;
|
|
3324
|
+
}
|
|
3325
|
+
if (authSession.channelId && authSession.threadTs) {
|
|
3326
|
+
return `slack:${authSession.channelId}:${authSession.threadTs}`;
|
|
3327
|
+
}
|
|
3328
|
+
return void 0;
|
|
3329
|
+
}
|
|
3330
|
+
async function runCurrentMcpCredentialMutation(authSession, provider, mutation) {
|
|
3331
|
+
const conversationId = mcpConversationId(authSession);
|
|
3332
|
+
if (!conversationId) {
|
|
3333
|
+
throw new McpOAuthAttemptExpiredError();
|
|
3334
|
+
}
|
|
3335
|
+
const stateAdapter = getStateAdapter();
|
|
3336
|
+
await stateAdapter.connect();
|
|
3337
|
+
const lock = await acquireActiveLock(stateAdapter, conversationId);
|
|
3338
|
+
if (!lock) {
|
|
3339
|
+
throw new Error(
|
|
3340
|
+
`Could not acquire MCP OAuth callback lock for ${conversationId}`
|
|
3341
|
+
);
|
|
3342
|
+
}
|
|
3343
|
+
try {
|
|
3344
|
+
if (!await isCurrentMcpAuthorizationAttempt(authSession, provider)) {
|
|
3345
|
+
throw new McpOAuthAttemptExpiredError();
|
|
3346
|
+
}
|
|
3347
|
+
return await mutation();
|
|
3348
|
+
} finally {
|
|
3349
|
+
await stateAdapter.releaseLock(lock);
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
async function GET(request, provider, waitUntil, options) {
|
|
3353
|
+
const localRelay = await relayLocalOAuthCallback(request);
|
|
3354
|
+
if (localRelay) {
|
|
3355
|
+
return localRelay;
|
|
3356
|
+
}
|
|
3357
|
+
const url = new URL(request.url);
|
|
3358
|
+
const state = url.searchParams.get("state")?.trim();
|
|
3359
|
+
const code = url.searchParams.get("code")?.trim();
|
|
3360
|
+
const error = url.searchParams.get("error")?.trim();
|
|
3361
|
+
if (!state) {
|
|
3362
|
+
return htmlResponse("missing_state");
|
|
3363
|
+
}
|
|
3364
|
+
if (error) {
|
|
3365
|
+
return htmlResponse("provider_error");
|
|
3366
|
+
}
|
|
3367
|
+
if (!code) {
|
|
3368
|
+
return htmlResponse("missing_code");
|
|
3369
|
+
}
|
|
3370
|
+
try {
|
|
3371
|
+
const pendingSession = await getMcpAuthSession(state);
|
|
3372
|
+
if (!pendingSession || pendingSession.provider !== provider || !await isCurrentMcpAuthorizationAttempt(pendingSession, provider)) {
|
|
3373
|
+
if (pendingSession) {
|
|
3374
|
+
await deleteMcpAuthSession(pendingSession.authSessionId);
|
|
3375
|
+
}
|
|
3376
|
+
return htmlResponse("expired");
|
|
3377
|
+
}
|
|
3378
|
+
const authSession = await finalizeMcpAuthorization(
|
|
3379
|
+
provider,
|
|
3380
|
+
state,
|
|
3381
|
+
code,
|
|
3382
|
+
async (mutation) => await runCurrentMcpCredentialMutation(
|
|
3383
|
+
pendingSession,
|
|
3384
|
+
provider,
|
|
3385
|
+
mutation
|
|
3386
|
+
)
|
|
3387
|
+
);
|
|
3388
|
+
try {
|
|
3389
|
+
await deleteMcpAuthSession(authSession.authSessionId);
|
|
3390
|
+
} catch (cleanupError) {
|
|
3391
|
+
logException(
|
|
3392
|
+
cleanupError,
|
|
3393
|
+
"mcp_oauth_callback_session_cleanup_failed",
|
|
3394
|
+
{},
|
|
3395
|
+
{ "app.credential.provider": provider },
|
|
3396
|
+
"Failed to delete completed MCP auth session"
|
|
3397
|
+
);
|
|
3398
|
+
}
|
|
3399
|
+
if (authSession.destination?.platform !== "local") {
|
|
3400
|
+
waitUntil(
|
|
3401
|
+
() => resumeAuthorizedMcpTurn({
|
|
3402
|
+
authSession,
|
|
3403
|
+
agentRunner: options.agentRunner,
|
|
3404
|
+
provider
|
|
3405
|
+
})
|
|
3406
|
+
);
|
|
3407
|
+
}
|
|
3408
|
+
return htmlResponse("success", {
|
|
3409
|
+
local: authSession.destination?.platform === "local"
|
|
3410
|
+
});
|
|
3411
|
+
} catch (callbackError) {
|
|
3412
|
+
if (callbackError instanceof McpOAuthAttemptExpiredError) {
|
|
3413
|
+
await deleteMcpAuthSession(state);
|
|
3414
|
+
return htmlResponse("expired");
|
|
3415
|
+
}
|
|
3416
|
+
logException(
|
|
3417
|
+
callbackError,
|
|
3418
|
+
"mcp_oauth_callback_failed",
|
|
3419
|
+
{},
|
|
3420
|
+
{ "app.credential.provider": provider },
|
|
3421
|
+
"Failed to process MCP OAuth callback"
|
|
3422
|
+
);
|
|
3423
|
+
return htmlResponse("failure");
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
// src/chat/slack/app-home.ts
|
|
3428
|
+
import fs from "fs";
|
|
3429
|
+
import path from "path";
|
|
3430
|
+
var DEFAULT_DESCRIPTION_TEXT = "I help your team investigate, summarize, and act on work in Slack.";
|
|
3431
|
+
var MAX_HOME_SKILLS = 6;
|
|
3432
|
+
var MAX_SECTION_TEXT_CHARS = 3e3;
|
|
3433
|
+
var HIDDEN_HOME_SKILLS = /* @__PURE__ */ new Set(["jr-rpc"]);
|
|
3434
|
+
function clampSectionText(text) {
|
|
3435
|
+
if (text.length <= MAX_SECTION_TEXT_CHARS) {
|
|
3436
|
+
return text;
|
|
3437
|
+
}
|
|
3438
|
+
return `${text.slice(0, MAX_SECTION_TEXT_CHARS - 1)}\u2026`;
|
|
3439
|
+
}
|
|
3440
|
+
function loadDescriptionText() {
|
|
3441
|
+
const descriptionPath = path.join(homeDir(), "DESCRIPTION.md");
|
|
3442
|
+
try {
|
|
3443
|
+
const raw = fs.readFileSync(descriptionPath, "utf8").trim();
|
|
3444
|
+
if (raw.length > 0) {
|
|
3445
|
+
return clampSectionText(raw);
|
|
3446
|
+
}
|
|
3447
|
+
} catch {
|
|
3448
|
+
}
|
|
3449
|
+
return DEFAULT_DESCRIPTION_TEXT;
|
|
3450
|
+
}
|
|
3451
|
+
async function buildSkillsSummaryText() {
|
|
3452
|
+
const skills = (await discoverSkills()).filter(
|
|
3453
|
+
(skill) => !HIDDEN_HOME_SKILLS.has(skill.name)
|
|
3454
|
+
);
|
|
3455
|
+
if (skills.length === 0) {
|
|
3456
|
+
return "No skills installed.";
|
|
3457
|
+
}
|
|
3458
|
+
const visible = skills.slice(0, MAX_HOME_SKILLS);
|
|
3459
|
+
const lines = visible.map(
|
|
3460
|
+
(skill) => `\u2022 *${skill.name}* \u2014 ${skill.description}`
|
|
3461
|
+
);
|
|
3462
|
+
if (skills.length > visible.length) {
|
|
3463
|
+
lines.push(`\u2022 \u2026and ${skills.length - visible.length} more`);
|
|
3464
|
+
}
|
|
3465
|
+
return lines.join("\n");
|
|
3466
|
+
}
|
|
3467
|
+
function accountLabel(account) {
|
|
3468
|
+
const label = account.label ?? account.id;
|
|
3469
|
+
return account.url ? `<${account.url}|${label}>` : label;
|
|
3470
|
+
}
|
|
3471
|
+
function connectedAccountText(plugin, account) {
|
|
3472
|
+
return account ? `*${plugin.manifest.name}*
|
|
3473
|
+
Connected as ${accountLabel(account)}` : `*${plugin.manifest.name}*
|
|
3474
|
+
${plugin.manifest.description}`;
|
|
3475
|
+
}
|
|
3476
|
+
async function connectedOAuthTokens(userId, plugin, userTokenStore) {
|
|
3477
|
+
if (plugin.manifest.oauth || plugin.manifest.credentials) {
|
|
3478
|
+
const stored = await userTokenStore.get(userId, plugin.manifest.name);
|
|
3479
|
+
return stored && hasRequiredOAuthScope(stored.scope, plugin.manifest.oauth?.scope) ? stored : void 0;
|
|
3480
|
+
}
|
|
3481
|
+
return void 0;
|
|
3482
|
+
}
|
|
3483
|
+
async function hasConnectedMcpAccount(userId, plugin) {
|
|
3484
|
+
if (plugin.manifest.mcp) {
|
|
3485
|
+
return Boolean(
|
|
3486
|
+
(await getMcpStoredOAuthCredentials(userId, plugin.manifest.name))?.tokens
|
|
3487
|
+
);
|
|
3488
|
+
}
|
|
3489
|
+
return false;
|
|
3490
|
+
}
|
|
3491
|
+
async function buildHomeView(userId, userTokenStore) {
|
|
3492
|
+
const runtimeMetadata = getRuntimeMetadata();
|
|
3493
|
+
const descriptionText = loadDescriptionText();
|
|
3494
|
+
const skillsSummaryText = await buildSkillsSummaryText();
|
|
3495
|
+
const providers = pluginCatalogRuntime.getProviders();
|
|
3496
|
+
const connectedSections = [];
|
|
3497
|
+
for (const plugin of providers) {
|
|
3498
|
+
const tokens = await connectedOAuthTokens(userId, plugin, userTokenStore);
|
|
3499
|
+
if (!tokens && !await hasConnectedMcpAccount(userId, plugin)) continue;
|
|
3500
|
+
connectedSections.push({
|
|
3501
|
+
type: "section",
|
|
3502
|
+
text: {
|
|
3503
|
+
type: "mrkdwn",
|
|
3504
|
+
text: connectedAccountText(plugin, tokens?.account)
|
|
3505
|
+
},
|
|
3506
|
+
accessory: {
|
|
3507
|
+
type: "button",
|
|
3508
|
+
text: { type: "plain_text", text: "Unlink" },
|
|
3509
|
+
action_id: "app_home_disconnect",
|
|
3510
|
+
value: plugin.manifest.name,
|
|
3511
|
+
style: "danger"
|
|
3512
|
+
}
|
|
3513
|
+
});
|
|
3514
|
+
}
|
|
3515
|
+
const accountBlocks = connectedSections.length > 0 ? connectedSections : [
|
|
3516
|
+
{
|
|
3517
|
+
type: "section",
|
|
3518
|
+
text: {
|
|
3519
|
+
type: "mrkdwn",
|
|
3520
|
+
text: "No connected accounts"
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
];
|
|
3524
|
+
return {
|
|
3525
|
+
type: "home",
|
|
3526
|
+
blocks: [
|
|
3527
|
+
{
|
|
3528
|
+
type: "header",
|
|
3529
|
+
text: {
|
|
3530
|
+
type: "plain_text",
|
|
3531
|
+
text: "Junior"
|
|
3532
|
+
}
|
|
3533
|
+
},
|
|
3534
|
+
{
|
|
3535
|
+
type: "section",
|
|
3536
|
+
text: {
|
|
3537
|
+
type: "mrkdwn",
|
|
3538
|
+
text: descriptionText
|
|
3539
|
+
}
|
|
3540
|
+
},
|
|
3541
|
+
{ type: "divider" },
|
|
3542
|
+
{
|
|
3543
|
+
type: "header",
|
|
3544
|
+
text: {
|
|
3545
|
+
type: "plain_text",
|
|
3546
|
+
text: "What I can help with"
|
|
3547
|
+
}
|
|
3548
|
+
},
|
|
3549
|
+
{
|
|
3550
|
+
type: "section",
|
|
3551
|
+
text: {
|
|
3552
|
+
type: "mrkdwn",
|
|
3553
|
+
text: skillsSummaryText
|
|
3554
|
+
}
|
|
3555
|
+
},
|
|
3556
|
+
{ type: "divider" },
|
|
3557
|
+
{
|
|
3558
|
+
type: "header",
|
|
3559
|
+
text: {
|
|
3560
|
+
type: "plain_text",
|
|
3561
|
+
text: "Connected accounts"
|
|
3562
|
+
}
|
|
3563
|
+
},
|
|
3564
|
+
...accountBlocks,
|
|
3565
|
+
{
|
|
3566
|
+
type: "context",
|
|
3567
|
+
elements: [
|
|
3568
|
+
{
|
|
3569
|
+
type: "mrkdwn",
|
|
3570
|
+
text: `*junior version:* \`${runtimeMetadata.version ?? "unknown"}\``
|
|
3571
|
+
}
|
|
3572
|
+
]
|
|
3573
|
+
}
|
|
3574
|
+
]
|
|
3575
|
+
};
|
|
3576
|
+
}
|
|
3577
|
+
async function publishAppHomeView(slackClient, userId, userTokenStore) {
|
|
3578
|
+
const view = await buildHomeView(userId, userTokenStore);
|
|
3579
|
+
await slackClient.views.publish({ user_id: userId, view });
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
// src/handlers/oauth-callback.ts
|
|
3583
|
+
function htmlErrorResponse(title, message, status) {
|
|
3584
|
+
return htmlCallbackResponse(escapeXml(title), escapeXml(message), status);
|
|
3585
|
+
}
|
|
3586
|
+
async function persistCompletedOAuthReplyState(args) {
|
|
3587
|
+
const currentState = await getPersistedThreadState(args.conversationId);
|
|
3588
|
+
const conversation = coerceThreadConversationState(currentState);
|
|
3589
|
+
await hydrateConversationMessages({
|
|
3590
|
+
conversation,
|
|
3591
|
+
conversationId: args.conversationId
|
|
3592
|
+
});
|
|
3593
|
+
const artifacts = coerceThreadArtifactsState(currentState);
|
|
3594
|
+
const userMessage = getTurnUserMessage(conversation, args.sessionId);
|
|
3595
|
+
const statePatch = buildDeliveredTurnStatePatch({
|
|
3596
|
+
artifacts,
|
|
3597
|
+
conversation,
|
|
3598
|
+
reply: args.reply,
|
|
3599
|
+
sessionId: args.sessionId,
|
|
3600
|
+
userMessageId: userMessage?.id
|
|
3601
|
+
});
|
|
3602
|
+
await persistThreadStateById(args.conversationId, {
|
|
3603
|
+
...statePatch
|
|
3604
|
+
});
|
|
3605
|
+
}
|
|
3606
|
+
function pluginAuthorizationId(args) {
|
|
3607
|
+
return `${args.sessionId}:plugin:${args.provider}`;
|
|
3608
|
+
}
|
|
3609
|
+
async function failSessionRecordBestEffort2(args) {
|
|
3610
|
+
try {
|
|
3611
|
+
await failAgentTurnSessionRecord({
|
|
3612
|
+
conversationId: args.conversationId,
|
|
3613
|
+
expectedVersion: args.expectedVersion,
|
|
3614
|
+
sessionId: args.sessionId,
|
|
3615
|
+
errorMessage: args.errorMessage
|
|
3616
|
+
});
|
|
3617
|
+
} catch (error) {
|
|
3618
|
+
logException(
|
|
3619
|
+
error,
|
|
3620
|
+
"oauth_callback_session_record_fail_persist_failed",
|
|
3621
|
+
{},
|
|
3622
|
+
{
|
|
3623
|
+
"app.ai.conversation_id": args.conversationId,
|
|
3624
|
+
"app.ai.session_id": args.sessionId
|
|
3625
|
+
},
|
|
3626
|
+
"Failed to mark OAuth-resumed turn session record failed"
|
|
3627
|
+
);
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
async function persistFailedOAuthReplyState(args) {
|
|
3631
|
+
const currentState = await getPersistedThreadState(args.conversationId);
|
|
3632
|
+
const conversation = coerceThreadConversationState(currentState);
|
|
3633
|
+
await hydrateConversationMessages({
|
|
3634
|
+
conversation,
|
|
3635
|
+
conversationId: args.conversationId
|
|
3636
|
+
});
|
|
3637
|
+
clearPendingAuth(conversation, args.sessionId);
|
|
3638
|
+
markTurnFailed({
|
|
3639
|
+
conversation,
|
|
3640
|
+
nowMs: Date.now(),
|
|
3641
|
+
sessionId: args.sessionId,
|
|
3642
|
+
userMessageId: getTurnUserMessage(conversation, args.sessionId)?.id,
|
|
3643
|
+
markConversationMessage,
|
|
3644
|
+
updateConversationStats
|
|
3645
|
+
});
|
|
3646
|
+
await failSessionRecordBestEffort2({
|
|
3647
|
+
conversationId: args.conversationId,
|
|
3648
|
+
expectedVersion: args.expectedVersion,
|
|
3649
|
+
sessionId: args.sessionId,
|
|
3650
|
+
errorMessage: "OAuth-resumed turn failed"
|
|
3651
|
+
});
|
|
3652
|
+
await persistThreadStateById(args.conversationId, {
|
|
3653
|
+
conversation
|
|
3654
|
+
});
|
|
3655
|
+
}
|
|
3656
|
+
async function resumeOAuthSessionRecordTurn(stored, options) {
|
|
3657
|
+
if (!stored.resumeConversationId || !stored.resumeSessionId || !stored.channelId || !stored.destination || !stored.source || !stored.threadTs) {
|
|
3658
|
+
return;
|
|
3659
|
+
}
|
|
3660
|
+
const destination = requireSlackDestination(
|
|
3661
|
+
stored.destination,
|
|
3662
|
+
"OAuth resume"
|
|
3663
|
+
);
|
|
3664
|
+
const currentState = await getPersistedThreadState(
|
|
3665
|
+
stored.resumeConversationId
|
|
3666
|
+
);
|
|
3667
|
+
const conversation = coerceThreadConversationState(currentState);
|
|
3668
|
+
await hydrateConversationMessages({
|
|
3669
|
+
conversation,
|
|
3670
|
+
conversationId: stored.resumeConversationId
|
|
3671
|
+
});
|
|
3672
|
+
const pendingAuth = getConversationPendingAuth({
|
|
3673
|
+
conversation,
|
|
3674
|
+
kind: "plugin",
|
|
3675
|
+
provider: stored.provider,
|
|
3676
|
+
actorId: stored.userId,
|
|
3677
|
+
...stored.scope ? { scope: stored.scope } : {}
|
|
3678
|
+
});
|
|
3679
|
+
const resolvedSessionId = pendingAuth?.sessionId ?? stored.resumeSessionId;
|
|
3680
|
+
const userMessage = resolvedSessionId ? getTurnUserMessage(conversation, resolvedSessionId) : void 0;
|
|
3681
|
+
if (pendingAuth) {
|
|
3682
|
+
if (!isPendingAuthLatestRequest(conversation, pendingAuth)) {
|
|
3683
|
+
clearPendingAuth(conversation, pendingAuth.sessionId);
|
|
3684
|
+
await persistThreadStateById(stored.resumeConversationId, {
|
|
3685
|
+
conversation
|
|
3686
|
+
});
|
|
3687
|
+
await abandonAgentTurnSessionRecord({
|
|
3688
|
+
conversationId: stored.resumeConversationId,
|
|
3689
|
+
sessionId: pendingAuth.sessionId,
|
|
3690
|
+
errorMessage: "Auth completed after a newer thread message abandoned this blocked request."
|
|
3691
|
+
});
|
|
3692
|
+
return;
|
|
3693
|
+
}
|
|
3694
|
+
}
|
|
3695
|
+
const sessionRecord = await getAgentTurnSessionRecord(
|
|
3696
|
+
stored.resumeConversationId,
|
|
3697
|
+
resolvedSessionId
|
|
3698
|
+
);
|
|
3699
|
+
if (!sessionRecord) {
|
|
3700
|
+
return;
|
|
3701
|
+
}
|
|
3702
|
+
if (sessionRecord.state === "completed" || sessionRecord.state === "failed" || sessionRecord.state === "abandoned") {
|
|
3703
|
+
return;
|
|
3704
|
+
}
|
|
3705
|
+
if (sessionRecord.state !== "awaiting_resume" || sessionRecord.resumeReason !== "auth") {
|
|
3706
|
+
return;
|
|
3707
|
+
}
|
|
3708
|
+
if (!userMessage?.author?.userId) {
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
if (!pendingAuth && conversation.processing.activeTurnId !== stored.resumeSessionId) {
|
|
3712
|
+
return;
|
|
3713
|
+
}
|
|
3714
|
+
await resumeSlackTurn({
|
|
3715
|
+
messageText: userMessage.text,
|
|
3716
|
+
conversationId: stored.resumeConversationId,
|
|
3717
|
+
turnId: resolvedSessionId,
|
|
3718
|
+
channelId: stored.channelId,
|
|
3719
|
+
threadTs: stored.threadTs,
|
|
3720
|
+
messageTs: getTurnUserSlackMessageTs(userMessage),
|
|
3721
|
+
lockKey: stored.resumeConversationId,
|
|
3722
|
+
initialText: "",
|
|
3723
|
+
agentRunner: options.agentRunner,
|
|
3724
|
+
beforeStart: async () => {
|
|
3725
|
+
const lockedState = await getPersistedThreadState(
|
|
3726
|
+
stored.resumeConversationId
|
|
3727
|
+
);
|
|
3728
|
+
const lockedConversation = coerceThreadConversationState(lockedState);
|
|
3729
|
+
await hydrateConversationMessages({
|
|
3730
|
+
conversation: lockedConversation,
|
|
3731
|
+
conversationId: stored.resumeConversationId
|
|
3732
|
+
});
|
|
3733
|
+
const lockedArtifacts = coerceThreadArtifactsState(lockedState);
|
|
3734
|
+
const lockedPendingAuth = getConversationPendingAuth({
|
|
3735
|
+
conversation: lockedConversation,
|
|
3736
|
+
kind: "plugin",
|
|
3737
|
+
provider: stored.provider,
|
|
3738
|
+
actorId: stored.userId,
|
|
3739
|
+
...stored.scope ? { scope: stored.scope } : {}
|
|
3740
|
+
});
|
|
3741
|
+
const lockedSessionId = lockedPendingAuth?.sessionId ?? stored.resumeSessionId;
|
|
3742
|
+
if (lockedSessionId !== resolvedSessionId) {
|
|
3743
|
+
return false;
|
|
3744
|
+
}
|
|
3745
|
+
const lockedSessionRecord = await getAgentTurnSessionRecord(
|
|
3746
|
+
stored.resumeConversationId,
|
|
3747
|
+
lockedSessionId
|
|
3748
|
+
);
|
|
3749
|
+
if (!lockedSessionRecord || lockedSessionRecord.state !== "awaiting_resume" || lockedSessionRecord.resumeReason !== "auth") {
|
|
3750
|
+
return false;
|
|
3751
|
+
}
|
|
3752
|
+
if (lockedPendingAuth) {
|
|
3753
|
+
if (!isPendingAuthLatestRequest(lockedConversation, lockedPendingAuth)) {
|
|
3754
|
+
clearPendingAuth(lockedConversation, lockedPendingAuth.sessionId);
|
|
3755
|
+
await persistThreadStateById(stored.resumeConversationId, {
|
|
3756
|
+
conversation: lockedConversation
|
|
3757
|
+
});
|
|
3758
|
+
await abandonAgentTurnSessionRecord({
|
|
3759
|
+
conversationId: stored.resumeConversationId,
|
|
3760
|
+
sessionId: lockedPendingAuth.sessionId,
|
|
3761
|
+
errorMessage: "Auth completed after a newer thread message abandoned this blocked request."
|
|
3762
|
+
});
|
|
3763
|
+
return false;
|
|
3764
|
+
}
|
|
3765
|
+
} else if (lockedConversation.processing.activeTurnId !== stored.resumeSessionId) {
|
|
3766
|
+
return false;
|
|
3767
|
+
}
|
|
3768
|
+
const lockedUserMessage = getTurnUserMessage(
|
|
3769
|
+
lockedConversation,
|
|
3770
|
+
lockedSessionId
|
|
3771
|
+
);
|
|
3772
|
+
if (!lockedUserMessage?.author?.userId) {
|
|
3773
|
+
return false;
|
|
3774
|
+
}
|
|
3775
|
+
const lockedConversationContext = buildConversationContext(
|
|
3776
|
+
lockedConversation,
|
|
3777
|
+
{
|
|
3778
|
+
excludeMessageId: lockedUserMessage.id
|
|
3779
|
+
}
|
|
3780
|
+
);
|
|
3781
|
+
const lockedChannelConfiguration = getChannelConfigurationServiceById(
|
|
3782
|
+
stored.channelId
|
|
3783
|
+
);
|
|
3784
|
+
let actor;
|
|
3785
|
+
try {
|
|
3786
|
+
actor = createSlackResumeActor({
|
|
3787
|
+
actor: isUserActor(lockedSessionRecord.actor) ? lockedSessionRecord.actor : void 0,
|
|
3788
|
+
teamId: destination.teamId,
|
|
3789
|
+
userId: lockedUserMessage.author.userId
|
|
3790
|
+
});
|
|
3791
|
+
} catch {
|
|
3792
|
+
await failAgentTurnSessionRecord({
|
|
3793
|
+
conversationId: stored.resumeConversationId,
|
|
3794
|
+
expectedVersion: lockedSessionRecord.version,
|
|
3795
|
+
sessionId: lockedSessionId,
|
|
3796
|
+
errorMessage: "Stored Slack actor identity did not match OAuth actor"
|
|
3797
|
+
});
|
|
3798
|
+
return false;
|
|
3799
|
+
}
|
|
3800
|
+
if (!lockedSessionRecord.source) {
|
|
3801
|
+
await failAgentTurnSessionRecord({
|
|
3802
|
+
conversationId: stored.resumeConversationId,
|
|
3803
|
+
expectedVersion: lockedSessionRecord.version,
|
|
3804
|
+
sessionId: lockedSessionId,
|
|
3805
|
+
errorMessage: "Stored Slack source missing for OAuth resume"
|
|
3806
|
+
});
|
|
3807
|
+
return false;
|
|
3808
|
+
}
|
|
3809
|
+
await recordAuthorizationCompleted({
|
|
3810
|
+
conversationId: stored.resumeConversationId,
|
|
3811
|
+
kind: "plugin",
|
|
3812
|
+
provider: stored.provider,
|
|
3813
|
+
actorId: stored.userId,
|
|
3814
|
+
authorizationId: pluginAuthorizationId({
|
|
3815
|
+
provider: stored.provider,
|
|
3816
|
+
sessionId: lockedSessionId
|
|
3817
|
+
})
|
|
3818
|
+
});
|
|
3819
|
+
const lockedMessageTs = getTurnUserSlackMessageTs(lockedUserMessage);
|
|
3820
|
+
return {
|
|
3821
|
+
messageText: lockedUserMessage.text,
|
|
3822
|
+
messageTs: lockedMessageTs,
|
|
3823
|
+
inputMessageIds: [lockedUserMessage.id],
|
|
3824
|
+
replyContext: {
|
|
3825
|
+
input: {
|
|
3826
|
+
conversationContext: lockedConversationContext,
|
|
3827
|
+
// Pi history is SQL-authoritative: the resumed run reads its
|
|
3828
|
+
// session record first and falls back to the step projection.
|
|
3829
|
+
piMessages: await loadProjection({
|
|
3830
|
+
conversationId: stored.resumeConversationId
|
|
3831
|
+
}),
|
|
3832
|
+
...getTurnUserReplyAttachmentContext(lockedUserMessage)
|
|
3833
|
+
},
|
|
3834
|
+
routing: {
|
|
3835
|
+
credentialContext: {
|
|
3836
|
+
actor: {
|
|
3837
|
+
type: "user",
|
|
3838
|
+
userId: actor.userId
|
|
3839
|
+
}
|
|
3840
|
+
},
|
|
3841
|
+
actor,
|
|
3842
|
+
destination,
|
|
3843
|
+
source: lockedSessionRecord.source,
|
|
3844
|
+
toolChannelId: lockedArtifacts.assistantContextChannelId ?? stored.channelId
|
|
3845
|
+
},
|
|
3846
|
+
policy: {
|
|
3847
|
+
channelConfiguration: lockedChannelConfiguration
|
|
3848
|
+
},
|
|
3849
|
+
state: {
|
|
3850
|
+
artifactState: lockedArtifacts,
|
|
3851
|
+
pendingAuth: lockedPendingAuth,
|
|
3852
|
+
sandboxRef: getPersistedSandboxState(lockedState)
|
|
3853
|
+
},
|
|
3854
|
+
durability: {
|
|
3855
|
+
recordPendingAuth: async (nextPendingAuth) => {
|
|
3856
|
+
lockedConversation.processing.pendingAuth = nextPendingAuth;
|
|
3857
|
+
await persistThreadStateById(stored.resumeConversationId, {
|
|
3858
|
+
conversation: lockedConversation
|
|
3859
|
+
});
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
},
|
|
3863
|
+
commitResult: async (reply) => {
|
|
3864
|
+
logInfo(
|
|
3865
|
+
"oauth_callback_resume_complete",
|
|
3866
|
+
{},
|
|
3867
|
+
{
|
|
3868
|
+
"app.credential.provider": stored.provider,
|
|
3869
|
+
"app.ai.outcome": reply.diagnostics.outcome,
|
|
3870
|
+
"app.ai.tool_calls": reply.diagnostics.toolCalls.length
|
|
3871
|
+
},
|
|
3872
|
+
"OAuth callback auto-resumed session record finished replying"
|
|
3873
|
+
);
|
|
3874
|
+
await persistCompletedOAuthReplyState({
|
|
3875
|
+
conversationId: stored.resumeConversationId,
|
|
3876
|
+
sessionId: lockedSessionId,
|
|
3877
|
+
reply
|
|
3878
|
+
});
|
|
3879
|
+
},
|
|
3880
|
+
onPostDeliveryCommitFailure: async () => {
|
|
3881
|
+
await failAgentTurnSessionRecord({
|
|
3882
|
+
conversationId: stored.resumeConversationId,
|
|
3883
|
+
expectedVersion: lockedSessionRecord.version,
|
|
3884
|
+
sessionId: lockedSessionId,
|
|
3885
|
+
errorMessage: "OAuth-resumed reply was delivered but completion state did not persist"
|
|
3886
|
+
});
|
|
3887
|
+
},
|
|
3888
|
+
onFailure: async () => {
|
|
3889
|
+
await persistFailedOAuthReplyState({
|
|
3890
|
+
conversationId: stored.resumeConversationId,
|
|
3891
|
+
expectedVersion: lockedSessionRecord.version,
|
|
3892
|
+
sessionId: lockedSessionId
|
|
3893
|
+
});
|
|
3894
|
+
},
|
|
3895
|
+
onAuthPause: async () => {
|
|
3896
|
+
await persistAuthPauseTurnState({
|
|
3897
|
+
sessionId: lockedSessionId,
|
|
3898
|
+
threadStateId: stored.resumeConversationId
|
|
3899
|
+
});
|
|
3900
|
+
},
|
|
3901
|
+
onSuspend: async (resumeVersion) => {
|
|
3902
|
+
await scheduleAgentContinue({
|
|
3903
|
+
conversationId: stored.resumeConversationId,
|
|
3904
|
+
destination,
|
|
3905
|
+
sessionId: lockedSessionId,
|
|
3906
|
+
expectedVersion: resumeVersion
|
|
3907
|
+
});
|
|
3908
|
+
}
|
|
3909
|
+
};
|
|
3910
|
+
}
|
|
3911
|
+
});
|
|
3912
|
+
}
|
|
3913
|
+
async function GET2(request, provider, waitUntil, options) {
|
|
3914
|
+
const localRelay = await relayLocalOAuthCallback(request);
|
|
3915
|
+
if (localRelay) {
|
|
3916
|
+
return localRelay;
|
|
3917
|
+
}
|
|
3918
|
+
const providerConfig = pluginCatalogRuntime.getOAuthConfig(provider);
|
|
3919
|
+
if (!providerConfig) {
|
|
3920
|
+
return htmlErrorResponse(
|
|
3921
|
+
"Unknown provider",
|
|
3922
|
+
"The OAuth provider in this link is not recognized.",
|
|
3923
|
+
404
|
|
3924
|
+
);
|
|
3925
|
+
}
|
|
3926
|
+
const providerLabel = formatProviderLabel(provider);
|
|
3927
|
+
const url = new URL(request.url);
|
|
3928
|
+
const errorParam = url.searchParams.get("error");
|
|
3929
|
+
const code = url.searchParams.get("code");
|
|
3930
|
+
const state = url.searchParams.get("state");
|
|
3931
|
+
if (errorParam) {
|
|
3932
|
+
if (state) {
|
|
3933
|
+
const cleanupAdapter = getStateAdapter();
|
|
3934
|
+
await cleanupAdapter.delete(`oauth-state:${state}`);
|
|
3935
|
+
}
|
|
3936
|
+
if (errorParam === "access_denied") {
|
|
3937
|
+
return htmlErrorResponse(
|
|
3938
|
+
"Authorization declined",
|
|
3939
|
+
`You declined the ${providerLabel} authorization request. Return to Junior and connect your ${providerLabel} account again if you change your mind.`,
|
|
3940
|
+
400
|
|
3941
|
+
);
|
|
3942
|
+
}
|
|
3943
|
+
return htmlErrorResponse(
|
|
3944
|
+
"Authorization failed",
|
|
3945
|
+
`${providerLabel} returned an error: ${errorParam}. Return to Junior and try again.`,
|
|
3946
|
+
400
|
|
3947
|
+
);
|
|
3948
|
+
}
|
|
3949
|
+
if (!code || !state) {
|
|
3950
|
+
return htmlErrorResponse(
|
|
3951
|
+
"Invalid request",
|
|
3952
|
+
"This authorization link is missing required parameters.",
|
|
3953
|
+
400
|
|
3954
|
+
);
|
|
3955
|
+
}
|
|
3956
|
+
const stateAdapter = getStateAdapter();
|
|
3957
|
+
const stateKey = `oauth-state:${state}`;
|
|
3958
|
+
const stored = parseOAuthStatePayload(await stateAdapter.get(stateKey));
|
|
3959
|
+
if (!stored) {
|
|
3960
|
+
return htmlErrorResponse(
|
|
3961
|
+
"Link expired",
|
|
3962
|
+
`This authorization link has expired (links are valid for 10 minutes). Return to Junior and connect your ${providerLabel} account again to get a new link.`,
|
|
3963
|
+
400
|
|
3964
|
+
);
|
|
3965
|
+
}
|
|
3966
|
+
if (stored.provider !== provider) {
|
|
3967
|
+
return htmlErrorResponse(
|
|
3968
|
+
"Provider mismatch",
|
|
3969
|
+
"This authorization link does not match the expected provider.",
|
|
3970
|
+
400
|
|
3971
|
+
);
|
|
3972
|
+
}
|
|
3973
|
+
await stateAdapter.delete(stateKey);
|
|
3974
|
+
const clientId = process.env[providerConfig.clientIdEnv]?.trim();
|
|
3975
|
+
const clientSecret = process.env[providerConfig.clientSecretEnv]?.trim();
|
|
3976
|
+
if (!clientId || !clientSecret) {
|
|
3977
|
+
return htmlErrorResponse(
|
|
3978
|
+
"Configuration error",
|
|
3979
|
+
"OAuth client credentials are not configured on the server.",
|
|
3980
|
+
500
|
|
3981
|
+
);
|
|
3982
|
+
}
|
|
3983
|
+
const baseUrl = resolveBaseUrl();
|
|
3984
|
+
if (!baseUrl) {
|
|
3985
|
+
return htmlErrorResponse(
|
|
3986
|
+
"Configuration error",
|
|
3987
|
+
"The server cannot determine its base URL.",
|
|
3988
|
+
500
|
|
3989
|
+
);
|
|
3990
|
+
}
|
|
3991
|
+
const redirectUri = `${baseUrl}${providerConfig.callbackPath}`;
|
|
3992
|
+
const requestedScope = stored.scope ?? providerConfig.scope;
|
|
3993
|
+
let tokenResponse;
|
|
3994
|
+
try {
|
|
3995
|
+
const tokenRequest = buildOAuthTokenRequest({
|
|
3996
|
+
clientId,
|
|
3997
|
+
clientSecret,
|
|
3998
|
+
payload: {
|
|
3999
|
+
grant_type: "authorization_code",
|
|
4000
|
+
code,
|
|
4001
|
+
redirect_uri: redirectUri
|
|
4002
|
+
},
|
|
4003
|
+
tokenAuthMethod: providerConfig.tokenAuthMethod,
|
|
4004
|
+
tokenExtraHeaders: providerConfig.tokenExtraHeaders
|
|
4005
|
+
});
|
|
4006
|
+
tokenResponse = await fetch(providerConfig.tokenEndpoint, {
|
|
4007
|
+
method: "POST",
|
|
4008
|
+
headers: tokenRequest.headers,
|
|
4009
|
+
body: tokenRequest.body
|
|
4010
|
+
});
|
|
4011
|
+
} catch {
|
|
4012
|
+
return htmlErrorResponse(
|
|
4013
|
+
"Connection failed",
|
|
4014
|
+
"Failed to exchange the authorization code. Please try again.",
|
|
4015
|
+
500
|
|
4016
|
+
);
|
|
4017
|
+
}
|
|
4018
|
+
if (!tokenResponse.ok) {
|
|
4019
|
+
return htmlErrorResponse(
|
|
4020
|
+
"Connection failed",
|
|
4021
|
+
"The token exchange with the provider failed. Please try again.",
|
|
4022
|
+
500
|
|
4023
|
+
);
|
|
4024
|
+
}
|
|
4025
|
+
let parsedTokenResponse;
|
|
4026
|
+
try {
|
|
4027
|
+
const tokenData = await tokenResponse.json();
|
|
4028
|
+
parsedTokenResponse = parseOAuthTokenResponse(tokenData, requestedScope, {
|
|
4029
|
+
treatEmptyScopeAsUnreported: providerConfig.treatEmptyScopeAsUnreported
|
|
4030
|
+
});
|
|
4031
|
+
} catch {
|
|
4032
|
+
return htmlErrorResponse(
|
|
4033
|
+
"Connection failed",
|
|
4034
|
+
"The provider returned an incomplete token response. Please try again.",
|
|
4035
|
+
500
|
|
4036
|
+
);
|
|
4037
|
+
}
|
|
4038
|
+
if (!hasRequiredOAuthScope(parsedTokenResponse.scope, requestedScope)) {
|
|
4039
|
+
return htmlErrorResponse(
|
|
4040
|
+
"Connection failed",
|
|
4041
|
+
`The ${providerLabel} authorization did not grant the access Junior requires. Return to Junior and connect your ${providerLabel} account again.`,
|
|
4042
|
+
400
|
|
4043
|
+
);
|
|
4044
|
+
}
|
|
4045
|
+
const userTokenStore = createUserTokenStore();
|
|
4046
|
+
let account;
|
|
4047
|
+
try {
|
|
4048
|
+
account = await resolvePluginOAuthAccount({
|
|
4049
|
+
provider,
|
|
4050
|
+
tokens: parsedTokenResponse
|
|
4051
|
+
});
|
|
4052
|
+
} catch {
|
|
4053
|
+
return htmlErrorResponse(
|
|
4054
|
+
"Connection failed",
|
|
4055
|
+
`Junior could not verify the connected ${providerLabel} account. Please try again.`,
|
|
4056
|
+
500
|
|
4057
|
+
);
|
|
4058
|
+
}
|
|
4059
|
+
await userTokenStore.set(stored.userId, provider, {
|
|
4060
|
+
...parsedTokenResponse,
|
|
4061
|
+
...account ? { account } : {}
|
|
4062
|
+
});
|
|
4063
|
+
if (stored.destination?.platform !== "local") {
|
|
4064
|
+
waitUntil(async () => {
|
|
4065
|
+
try {
|
|
4066
|
+
await publishAppHomeView(
|
|
4067
|
+
getSlackClient(),
|
|
4068
|
+
stored.userId,
|
|
4069
|
+
userTokenStore
|
|
4070
|
+
);
|
|
4071
|
+
} catch {
|
|
4072
|
+
}
|
|
4073
|
+
});
|
|
4074
|
+
}
|
|
4075
|
+
const resumesAgentTurn = Boolean(
|
|
4076
|
+
stored.destination?.platform !== "local" && stored.resumeConversationId && stored.resumeSessionId
|
|
4077
|
+
);
|
|
4078
|
+
if (resumesAgentTurn) {
|
|
4079
|
+
waitUntil(async () => {
|
|
4080
|
+
try {
|
|
4081
|
+
await resumeOAuthSessionRecordTurn(stored, options);
|
|
4082
|
+
} catch (error) {
|
|
4083
|
+
if (error instanceof ResumeTurnBusyError) {
|
|
4084
|
+
logWarn(
|
|
4085
|
+
"oauth_callback_resume_busy",
|
|
4086
|
+
{
|
|
4087
|
+
...stored.resumeConversationId && {
|
|
4088
|
+
conversationId: stored.resumeConversationId
|
|
4089
|
+
},
|
|
4090
|
+
...stored.userId && { actorId: stored.userId },
|
|
4091
|
+
...stored.channelId && { channelId: stored.channelId }
|
|
4092
|
+
},
|
|
4093
|
+
{
|
|
4094
|
+
"app.credential.provider": stored.provider,
|
|
4095
|
+
...stored.resumeSessionId && {
|
|
4096
|
+
"app.ai.session_id": stored.resumeSessionId
|
|
4097
|
+
}
|
|
4098
|
+
},
|
|
4099
|
+
"OAuth callback resume was busy; user must send another message to continue"
|
|
4100
|
+
);
|
|
4101
|
+
return;
|
|
4102
|
+
}
|
|
4103
|
+
throw error;
|
|
4104
|
+
}
|
|
4105
|
+
});
|
|
4106
|
+
} else if (stored.channelId && stored.threadTs) {
|
|
4107
|
+
const { channelId, threadTs } = stored;
|
|
4108
|
+
waitUntil(
|
|
4109
|
+
() => postSlackMessage({
|
|
4110
|
+
channelId,
|
|
4111
|
+
threadTs,
|
|
4112
|
+
text: `Your ${providerLabel} account is now connected. You can start using ${providerLabel} commands.`
|
|
4113
|
+
})
|
|
4114
|
+
);
|
|
4115
|
+
}
|
|
4116
|
+
const statusMessage = stored.destination?.platform === "local" ? "Your request is continuing in the local Junior client." : resumesAgentTurn ? "Your request is being processed in Slack." : "You can close this tab and return to Slack.";
|
|
4117
|
+
const html = `<!DOCTYPE html>
|
|
4118
|
+
<html>
|
|
4119
|
+
<head><title>${providerLabel} Connected</title></head>
|
|
4120
|
+
<body style="font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0;">
|
|
4121
|
+
<div style="text-align: center;">
|
|
4122
|
+
<h1>${providerLabel} account connected</h1>
|
|
4123
|
+
<p>${statusMessage}</p>
|
|
4124
|
+
</div>
|
|
4125
|
+
</body>
|
|
4126
|
+
</html>`;
|
|
4127
|
+
return new Response(html, {
|
|
4128
|
+
status: 200,
|
|
4129
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
4130
|
+
});
|
|
4131
|
+
}
|
|
4132
|
+
|
|
4133
|
+
// src/chat/local/credential-sync.ts
|
|
4134
|
+
import { createHmac, createSecretKey, timingSafeEqual } from "crypto";
|
|
4135
|
+
import {
|
|
4136
|
+
pluginStoredTokensSchema
|
|
4137
|
+
} from "@sentry/junior-plugin-api";
|
|
4138
|
+
import { z as z2 } from "zod";
|
|
4139
|
+
var LOCAL_CREDENTIAL_SYNC_CONTEXT = "junior.local-credential-sync.v1";
|
|
4140
|
+
var LOCAL_CREDENTIAL_SYNC_MAX_AGE_MS = 6e4;
|
|
4141
|
+
var localCredentialSyncSchema = z2.object({
|
|
4142
|
+
createdAtMs: z2.number().finite(),
|
|
4143
|
+
provider: z2.string().regex(/^[a-z][a-z0-9-]*$/),
|
|
4144
|
+
tokens: pluginStoredTokensSchema
|
|
4145
|
+
}).strict();
|
|
4146
|
+
function syncSecret() {
|
|
4147
|
+
const secret = process.env.JUNIOR_SECRET?.trim();
|
|
4148
|
+
if (!secret) {
|
|
4149
|
+
throw new Error("Local credential sync requires JUNIOR_SECRET");
|
|
4150
|
+
}
|
|
4151
|
+
return secret;
|
|
4152
|
+
}
|
|
4153
|
+
function signature(body) {
|
|
4154
|
+
const signingKey = createSecretKey(Buffer.from(syncSecret(), "utf8"));
|
|
4155
|
+
return createHmac("sha256", signingKey).update(`${LOCAL_CREDENTIAL_SYNC_CONTEXT}:${body}`).digest("base64url");
|
|
4156
|
+
}
|
|
4157
|
+
function signaturesMatch(expected, actual) {
|
|
4158
|
+
const expectedBuffer = Buffer.from(expected);
|
|
4159
|
+
const actualBuffer = Buffer.from(actual);
|
|
4160
|
+
return expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer);
|
|
4161
|
+
}
|
|
4162
|
+
async function syncLocalOAuthCredential(provider, tokens) {
|
|
4163
|
+
const body = JSON.stringify({
|
|
4164
|
+
createdAtMs: Date.now(),
|
|
4165
|
+
provider,
|
|
4166
|
+
tokens
|
|
4167
|
+
});
|
|
4168
|
+
const port = process.env.PORT?.trim() || "3000";
|
|
4169
|
+
const response = await fetch(
|
|
4170
|
+
`http://127.0.0.1:${port}/api/internal/local-oauth-credentials`,
|
|
4171
|
+
{
|
|
4172
|
+
method: "POST",
|
|
4173
|
+
headers: {
|
|
4174
|
+
"content-type": "application/json",
|
|
4175
|
+
"x-junior-local-credential-signature": signature(body)
|
|
4176
|
+
},
|
|
4177
|
+
body
|
|
4178
|
+
}
|
|
4179
|
+
);
|
|
4180
|
+
if (!response.ok) {
|
|
4181
|
+
throw new Error(
|
|
4182
|
+
`Could not sync local OAuth credential: HTTP ${response.status}`
|
|
4183
|
+
);
|
|
4184
|
+
}
|
|
4185
|
+
}
|
|
4186
|
+
async function receiveLocalOAuthCredential(request) {
|
|
4187
|
+
const hostname = new URL(request.url).hostname;
|
|
4188
|
+
if (process.env.NODE_ENV !== "development" || hostname !== "127.0.0.1" && hostname !== "localhost") {
|
|
4189
|
+
return new Response(null, { status: 404 });
|
|
4190
|
+
}
|
|
4191
|
+
const body = await request.text();
|
|
4192
|
+
const actualSignature = request.headers.get(
|
|
4193
|
+
"x-junior-local-credential-signature"
|
|
4194
|
+
);
|
|
4195
|
+
if (!actualSignature || !signaturesMatch(signature(body), actualSignature)) {
|
|
4196
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
4197
|
+
}
|
|
4198
|
+
let value;
|
|
4199
|
+
try {
|
|
4200
|
+
value = JSON.parse(body);
|
|
4201
|
+
} catch {
|
|
4202
|
+
return Response.json({ error: "Invalid request" }, { status: 400 });
|
|
4203
|
+
}
|
|
4204
|
+
const payload = localCredentialSyncSchema.safeParse(value);
|
|
4205
|
+
if (!payload.success || Math.abs(Date.now() - payload.data.createdAtMs) > LOCAL_CREDENTIAL_SYNC_MAX_AGE_MS) {
|
|
4206
|
+
return Response.json({ error: "Invalid request" }, { status: 400 });
|
|
4207
|
+
}
|
|
4208
|
+
await createUserTokenStore().set(
|
|
4209
|
+
"local-cli",
|
|
4210
|
+
payload.data.provider,
|
|
4211
|
+
payload.data.tokens
|
|
4212
|
+
);
|
|
4213
|
+
return new Response(null, { status: 204 });
|
|
4214
|
+
}
|
|
4215
|
+
|
|
4216
|
+
export {
|
|
4217
|
+
sendSlackReply,
|
|
4218
|
+
getSlackApiErrorCode,
|
|
4219
|
+
getSlackErrorObservabilityAttributes,
|
|
4220
|
+
isRetryableSlackPostError,
|
|
4221
|
+
isSlackTitlePermissionError,
|
|
4222
|
+
isInvalidConversationRecordError,
|
|
4223
|
+
CONVERSATION_WORK_CHECK_IN_INTERVAL_MS,
|
|
4224
|
+
CONVERSATION_WORK_STALE_ENQUEUE_MS,
|
|
4225
|
+
isFinalAttempt,
|
|
4226
|
+
getConversationWorkState2 as getConversationWorkState,
|
|
4227
|
+
countPendingConversationMessages,
|
|
4228
|
+
hasRunnableConversationWork,
|
|
4229
|
+
ensureConversationWake,
|
|
4230
|
+
appendAndEnqueueInboundMessage,
|
|
4231
|
+
clearConsumedConversationWake2 as clearConsumedConversationWake,
|
|
4232
|
+
startConversationWork2 as startConversationWork,
|
|
4233
|
+
checkInConversationWork2 as checkInConversationWork,
|
|
4234
|
+
drainConversationMailbox2 as drainConversationMailbox,
|
|
4235
|
+
ackMessages2 as ackMessages,
|
|
4236
|
+
requestConversationContinuation2 as requestConversationContinuation,
|
|
4237
|
+
beginConversationResume2 as beginConversationResume,
|
|
4238
|
+
releaseConversationWork2 as releaseConversationWork,
|
|
4239
|
+
completeConversationWork2 as completeConversationWork,
|
|
4240
|
+
recordAttemptFailure2 as recordAttemptFailure,
|
|
4241
|
+
deadLetterAttempt2 as deadLetterAttempt,
|
|
4242
|
+
clearExpiredConversationLease2 as clearExpiredConversationLease,
|
|
4243
|
+
removeActiveConversation2 as removeActiveConversation,
|
|
4244
|
+
listActiveConversationIds2 as listActiveConversationIds,
|
|
4245
|
+
createSlackAdapterAssistantStatusSession,
|
|
4246
|
+
stripLeadingBotMention,
|
|
4247
|
+
getThreadId,
|
|
4248
|
+
getRunId,
|
|
4249
|
+
getChannelId,
|
|
4250
|
+
getThreadTs,
|
|
4251
|
+
getAssistantThreadContext,
|
|
4252
|
+
getMessageTs,
|
|
4253
|
+
shouldKeepProcessingReactionForToolInvocation,
|
|
4254
|
+
startSlackProcessingReaction,
|
|
4255
|
+
buildAuthPauseResponse,
|
|
4256
|
+
getTurnRequestDeadline,
|
|
4257
|
+
runWithTurnRequestDeadline,
|
|
4258
|
+
ResumeTurnBusyError,
|
|
4259
|
+
resumeSlackTurn,
|
|
4260
|
+
getAwaitingAgentContinueRequest,
|
|
4261
|
+
scheduleAgentContinue,
|
|
4262
|
+
GET,
|
|
4263
|
+
publishAppHomeView,
|
|
4264
|
+
GET2,
|
|
4265
|
+
syncLocalOAuthCredential,
|
|
4266
|
+
receiveLocalOAuthCredential
|
|
4267
|
+
};
|