@sentry/junior 0.203.0 → 0.204.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-CMyTD4DD.js +17509 -0
- package/dist/agent-hooks-Bbcp9e33.js +4040 -0
- package/dist/agent-runner-DNb1A49M.js +678 -0
- package/dist/api/schema.d.ts +2728 -2639
- package/dist/api/schema.js +4 -169
- package/dist/api.d.ts +23 -17
- package/dist/api.js +3881 -5326
- package/dist/app-B1TOqBI8.d.ts +246 -0
- package/dist/app.d.ts +4 -520
- package/dist/app.js +9522 -11875
- package/dist/auth-pause-state-DosEalIG.js +168 -0
- package/dist/catalog-runtime-BReos-D4.js +7 -0
- package/dist/checkpoint-CNrPyRJN.js +2450 -0
- package/dist/cli/chat.d.ts +20 -0
- package/dist/cli/chat.js +229 -336
- package/dist/cli/check.d.ts +8 -0
- package/dist/cli/check.js +513 -806
- package/dist/cli/env.d.ts +13 -0
- package/dist/cli/env.js +63 -12
- package/dist/cli/init.d.ts +3 -0
- package/dist/cli/init.js +90 -159
- package/dist/cli/main.d.ts +6 -0
- package/dist/cli/main.js +43 -64
- package/dist/cli/plugins.d.ts +13 -0
- package/dist/cli/plugins.js +148 -227
- package/dist/cli/run.d.ts +16 -0
- package/dist/cli/run.js +52 -8
- package/dist/cli/snapshot-create.d.ts +4 -0
- package/dist/cli/snapshot-create.js +71 -125
- package/dist/cli/upgrade.d.ts +15 -0
- package/dist/cli/upgrade.js +232 -335
- package/dist/config-nGCW1OTG.js +941 -0
- package/dist/context-B45_WqVX.js +298 -0
- package/dist/conversation-privacy-B4voFRSm.js +182 -0
- package/dist/conversation-work-C6y3T5Hx.js +51 -0
- package/dist/credential-sync-kXr1hvO1.js +2660 -0
- package/dist/db-BeyXKJOI.d.ts +5 -0
- package/dist/destinations-BeBiJeEM.js +29 -0
- package/dist/execution-stats-DWfSihcH.js +373 -0
- package/dist/executor-DcMSlI5c.js +721 -0
- package/dist/experimental-CIXf7Uye.js +46 -0
- package/dist/history-ClE4toxe.d.ts +32 -0
- package/dist/history-CqTuSAdI.js +441 -0
- package/dist/instrumentation.d.ts +3 -3
- package/dist/instrumentation.js +130 -176
- package/dist/job-queue-Drn_LXlM.js +63 -0
- package/dist/logging-BzAk1S_s.js +1210 -0
- package/dist/model-profile-CZmVoGdX.js +49 -0
- package/dist/nitro.d.ts +31 -36
- package/dist/nitro.js +275 -456
- package/dist/oauth-callback-server-JiftokvA.js +130 -0
- package/dist/oauth-relay-CdCnu80W.js +62 -0
- package/dist/package-discovery-7a9YqLhE.js +455 -0
- package/dist/pending-auth-CWmSdZVg.js +185 -0
- package/dist/plugin-module-BKGOXca2.js +81 -0
- package/dist/plugins-BI9A1vKA.d.ts +95 -0
- package/dist/plugins-egKWjgue.js +99 -0
- package/dist/private-trace-result-BLvBjy_e.js +16 -0
- package/dist/profile-CrT5nXbw.js +861 -0
- package/dist/registry-DH1xUo2N.js +1419 -0
- package/dist/reporting-schema-DosTN__Q.js +105 -0
- package/dist/resolve-CZHnfxaS.js +546 -0
- package/dist/rolldown-runtime-B4FMCO8f.js +28 -0
- package/dist/runner-CZV2sC46.js +392 -0
- package/dist/sandbox-egress-signals-bHZfmhbd.js +46 -0
- package/dist/schema-Rn8-XEaN.js +1140 -0
- package/dist/sentry-CD3SdJl-.js +26 -0
- package/dist/skills-CIcDQSgM.js +316 -0
- package/dist/spawn-DdkUFRmq.js +29 -0
- package/dist/stats-DTUbAb88.js +2634 -0
- package/dist/store-C5qxF2ac.d.ts +208 -0
- package/dist/task-queue-CJIR4RF7.js +136 -0
- package/dist/task-runner-D-O3A-Cp.js +639 -0
- package/dist/thread-state-CRc9jTJS.js +431 -0
- package/dist/turn-execution-BkinIdJF.js +47 -0
- package/dist/turn-lifecycle-DQV19VTW.js +54 -0
- package/dist/usage-D3EbSO7w.js +296 -0
- package/dist/usage-schema-CKaDkH_j.js +20 -0
- package/dist/validation-C2CR-OX3.js +40 -0
- package/dist/vercel-queue-DIkVcfK4.js +167 -0
- package/dist/vercel.d.ts +6 -6
- package/dist/vercel.js +14 -6
- package/dist/version-CnXZ614E.d.ts +5 -0
- package/dist/version-DxcWdoKa.js +8 -0
- package/dist/version.d.ts +2 -4
- package/dist/version.js +2 -6
- package/dist/web-authorization-RMBxAR_t.js +76 -0
- package/dist/web-input-3EPfpf55.js +494 -0
- package/dist/work-fGofI7Op.js +509 -0
- package/dist/worker-CsOLq-mR.js +625 -0
- package/package.json +8 -8
- package/dist/agent-hooks-GB2VPTNA.js +0 -51
- package/dist/catalog-runtime-UBLIWD3O.js +0 -14
- package/dist/chunk-2LYHWMR4.js +0 -46
- package/dist/chunk-352OWAZH.js +0 -320
- package/dist/chunk-3Y47VMHV.js +0 -6
- package/dist/chunk-3YJSKOWF.js +0 -164
- package/dist/chunk-4KSSKTQ5.js +0 -229
- package/dist/chunk-4UO6FK4G.js +0 -64
- package/dist/chunk-5AHX53NQ.js +0 -717
- package/dist/chunk-5J3E5FWA.js +0 -700
- package/dist/chunk-5PDAG6O3.js +0 -206
- package/dist/chunk-6Y5B5PEX.js +0 -80
- package/dist/chunk-7GWQ5S5R.js +0 -2086
- package/dist/chunk-7H2VZTWN.js +0 -103
- package/dist/chunk-7NL7T6VU.js +0 -406
- package/dist/chunk-A5L27LE4.js +0 -387
- package/dist/chunk-ADIAWL34.js +0 -81
- package/dist/chunk-ASTEMBKJ.js +0 -519
- package/dist/chunk-CEMMLV5L.js +0 -98
- package/dist/chunk-CM5HNV2W.js +0 -131
- package/dist/chunk-CRTRBBRI.js +0 -105
- package/dist/chunk-DA2KGZZI.js +0 -13
- package/dist/chunk-DIB4GX3G.js +0 -8
- package/dist/chunk-DKIJ2RP5.js +0 -68
- package/dist/chunk-DWPN6SCH.js +0 -172
- package/dist/chunk-EDKUFGPL.js +0 -64
- package/dist/chunk-ENY6SKIU.js +0 -1228
- package/dist/chunk-F4MGCZOX.js +0 -2943
- package/dist/chunk-FA6POGLH.js +0 -70
- package/dist/chunk-FSUR7GTG.js +0 -20475
- package/dist/chunk-GKEOHJL5.js +0 -1856
- package/dist/chunk-GMCB6W5Y.js +0 -333
- package/dist/chunk-IE2VPG7O.js +0 -1135
- package/dist/chunk-IZ5B5IHQ.js +0 -3656
- package/dist/chunk-JAL3CH4G.js +0 -27
- package/dist/chunk-KIIEUXD3.js +0 -1764
- package/dist/chunk-KXU4NIL7.js +0 -692
- package/dist/chunk-MTM5IFCY.js +0 -1190
- package/dist/chunk-N7TMSOZH.js +0 -235
- package/dist/chunk-NCEBK6PH.js +0 -36
- package/dist/chunk-NHJU2ZAL.js +0 -56
- package/dist/chunk-P6KTRNVC.js +0 -321
- package/dist/chunk-PDO5BLNM.js +0 -22
- package/dist/chunk-PDRNLLFT.js +0 -3295
- package/dist/chunk-PEQXYSSE.js +0 -45
- package/dist/chunk-PKEX2AQX.js +0 -198
- package/dist/chunk-PSJCGIGO.js +0 -1163
- package/dist/chunk-PZUSUL55.js +0 -506
- package/dist/chunk-R45M4TUS.js +0 -37
- package/dist/chunk-RC745GGZ.js +0 -122
- package/dist/chunk-SP3QKZXJ.js +0 -820
- package/dist/chunk-TXKUKMVO.js +0 -417
- package/dist/chunk-UE4GPWSD.js +0 -931
- package/dist/chunk-UIE3R5XU.js +0 -17
- package/dist/chunk-UXPG6ZIN.js +0 -84
- package/dist/chunk-VFUK3X5B.js +0 -50
- package/dist/chunk-VNMJWREK.js +0 -787
- package/dist/chunk-VZ7CSOX2.js +0 -706
- package/dist/chunk-WCNQJKFT.js +0 -10
- package/dist/chunk-WDZR3QQM.js +0 -135
- package/dist/chunk-WLYVJCHV.js +0 -1433
- package/dist/chunk-WS7NAFTM.js +0 -1520
- package/dist/chunk-X6GDDWQH.js +0 -60
- package/dist/chunk-XPDEYNQI.js +0 -102
- package/dist/chunk-XRW44VGM.js +0 -58
- package/dist/chunk-Y2CM7HXH.js +0 -111
- package/dist/chunk-Y7X25LFY.js +0 -15
- package/dist/conversation-work-VIOQ5ZF3.js +0 -54
- package/dist/db-NGDJV7YH.js +0 -26
- package/dist/discovery-DWVDM7UK.js +0 -44
- package/dist/experimental-P5FZM4CU.js +0 -12
- package/dist/oauth-callback-server-YFY7YEGK.js +0 -243
- package/dist/oauth-relay-5XPVRPYB.js +0 -8
- package/dist/plugins-YTYJMMJF.js +0 -14
- package/dist/runner-ORTNSB2Q.js +0 -522
- package/dist/sandbox-egress-signals-FIL7EGPR.js +0 -73
- package/dist/sentry-MB3NL3IR.js +0 -42
- package/dist/skills-H376WQOH.js +0 -27
- package/dist/spawn-T43WHLOS.js +0 -42
- package/dist/task-runner-LBZWF32V.js +0 -36
- package/dist/validation-REJLRKI6.js +0 -17
- package/dist/work-3KJ3RE76.js +0 -51
- package/dist/worker-QFEBU72E.js +0 -24
|
@@ -0,0 +1,2660 @@
|
|
|
1
|
+
import { N as getMcpProviderErrorAttributes, Q as createUserTokenStore, a as deleteMcpAuthSession, d as putMcpStoredOAuthCredentials, h as resolvePluginOAuthAccount, i as finalizeMcpAuthorization, l as getMcpAuthSession, u as getMcpStoredOAuthCredentials, y as lookupSlackUserProfile } from "./agent-CMyTD4DD.js";
|
|
2
|
+
import { O as escapeXml, a as getRuntimeMetadata, i as getChatConfig, m as normalizeSlackEmojiName, n as botConfig } from "./config-nGCW1OTG.js";
|
|
3
|
+
import { L as toOptionalString, S as withLogContext, d as logInfo, f as logWarn, m as runBestEffort, u as logException, y as setTags } from "./logging-BzAk1S_s.js";
|
|
4
|
+
import { n as defaultModelId } from "./model-profile-CZmVoGdX.js";
|
|
5
|
+
import { _ as parseSlackMessageTs, g as parseSlackUserId, l as isUserActor, s as createSlackResumeActor } from "./context-B45_WqVX.js";
|
|
6
|
+
import { B as getSlackClient, Dt as getSqlExecutor, H as isDmChannel, I as SlackActionError, Lt as upsertIdentity, Rt as upsertLinkedIdentity, Tt as getConversationStore, U as normalizeSlackConversationId, Vt as requireSlackDestination, dt as getDashboardConversationLink, o as getPluginSlackConversationLink, z as getHeaderString } from "./agent-hooks-Bbcp9e33.js";
|
|
7
|
+
import { i as homeDir } from "./package-discovery-7a9YqLhE.js";
|
|
8
|
+
import { l as hasRequiredOAuthScope, n as buildOAuthTokenRequest, r as parseOAuthTokenResponse } from "./registry-DH1xUo2N.js";
|
|
9
|
+
import { n as pluginCatalogRuntime } from "./catalog-runtime-BReos-D4.js";
|
|
10
|
+
import { t as discoverSkills } from "./skills-CIcDQSgM.js";
|
|
11
|
+
import { P as commitAcceptedReply, Q as hydrateConversationMessages, R as loadProjection, U as recordAuthenticationLinked, W as recordAuthorizationCompleted, d as persistWithRetry, dt as recordDeliveredAssistantMessage, ft as turnHasReply, i as abandonTurnRecord, it as buildConversationContext, lt as markConversationMessage, m as buildTurnErrorResponse, n as loadTurnCheckpoint, o as failTurnRecord, r as saveTurnCheckpoint, s as getTurnRecord, u as recordTurnSummary } from "./checkpoint-CNrPyRJN.js";
|
|
12
|
+
import { E as acquireActiveLock, w as getStateAdapter } from "./profile-CrT5nXbw.js";
|
|
13
|
+
import { Ct as ensureConversationWake, Nt as requestConversationWork, c as parseSlackThreadId, d as resolveSlackChannelIdFromThreadId, l as readSlackRawMessageContext, u as resolveSlackChannelIdFromMessage } from "./stats-DTUbAb88.js";
|
|
14
|
+
import { a as isPendingAuthLatestRequest, f as CooperativeTurnYieldError, g as isCooperativeTurnYieldError, i as getConversationPendingAuth, r as clearPendingAuth, s as getAssistantReplyText, x as markTurnFailed } from "./pending-auth-CWmSdZVg.js";
|
|
15
|
+
import { a as parseOAuthStatePayload, c as escapeSlackMrkdwnText, d as normalizeSlackStatusText, f as truncateStatusText, g as removeReactionFromMessage, h as postSlackMessage, i as formatProviderLabel, l as formatSlackLink, o as resolveBaseUrl, p as addReactionToMessage, u as normalizeSlackReplyMarkdown, v as RetryableDeliveryError } from "./agent-runner-DNb1A49M.js";
|
|
16
|
+
import { t as getVercelConversationWorkQueue } from "./vercel-queue-DIkVcfK4.js";
|
|
17
|
+
import { a as getTurnRequestDeadline } from "./job-queue-Drn_LXlM.js";
|
|
18
|
+
import { i as resolveTurnSessionRouting, n as scheduleSessionCompletedPluginTasks } from "./task-runner-D-O3A-Cp.js";
|
|
19
|
+
import { n as getTurnLifecycle } from "./turn-lifecycle-DQV19VTW.js";
|
|
20
|
+
import { n as executeTurn, t as AgentRunError } from "./turn-execution-BkinIdJF.js";
|
|
21
|
+
import { c as configValueSchema, n as getPersistedSandboxState, o as persistThreadStateById, r as getPersistedThreadState, s as coerceThreadConversationState, t as getLocationConfigurationService } from "./thread-state-CRc9jTJS.js";
|
|
22
|
+
import { a as requireTurnFailureEventId, c as getTurnUserMessageId, d as buildDeliveredTurnStatePatch, l as getTurnUserReplyAttachmentContext, n as persistAuthPauseTurnState, o as getInterruptionMarker, r as finalizeFailedTurnReplyWithEvent, s as getTurnUserMessage, u as getTurnUserSlackMessageTs } from "./auth-pause-state-DosEalIG.js";
|
|
23
|
+
import { i as buildSentryConversationUrl, n as deleteWebAuthorization } from "./web-authorization-RMBxAR_t.js";
|
|
24
|
+
import { n as relayLocalOAuthCallback } from "./oauth-relay-CdCnu80W.js";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
import { createHmac, createSecretKey, timingSafeEqual } from "node:crypto";
|
|
28
|
+
import { pluginStoredTokensSchema } from "@sentry/junior-plugin-api";
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
//#region src/chat/slack/assistant-thread/status-render.ts
|
|
31
|
+
const DEFAULT_STATUS_CONTEXTS = {
|
|
32
|
+
thinking: "…",
|
|
33
|
+
searching: "sources",
|
|
34
|
+
reading: "task",
|
|
35
|
+
reviewing: "results",
|
|
36
|
+
drafting: "reply",
|
|
37
|
+
running: "tasks"
|
|
38
|
+
};
|
|
39
|
+
function formatAssistantStatusText(verb, context) {
|
|
40
|
+
const normalizedVerb = normalizeSlackStatusText(verb).trim().toLowerCase();
|
|
41
|
+
const normalizedContext = normalizeSlackStatusText(context ?? "") || DEFAULT_STATUS_CONTEXTS[normalizedVerb] || "";
|
|
42
|
+
if (!normalizedVerb) return truncateStatusText(normalizedContext || "Working");
|
|
43
|
+
const displayVerb = `${normalizedVerb[0]?.toUpperCase() ?? ""}${normalizedVerb.slice(1)}`;
|
|
44
|
+
return truncateStatusText(normalizedContext ? `${displayVerb} ${normalizedContext}` : displayVerb);
|
|
45
|
+
}
|
|
46
|
+
/** Build assistant progress text from a verb and optional context. */
|
|
47
|
+
function makeAssistantStatus(verb, context) {
|
|
48
|
+
return { text: formatAssistantStatusText(verb, context) };
|
|
49
|
+
}
|
|
50
|
+
/** Normalize a progress update into the visible Slack loading copy. */
|
|
51
|
+
function renderAssistantStatus(args) {
|
|
52
|
+
const visible = truncateStatusText(normalizeSlackStatusText(args.status.text));
|
|
53
|
+
return {
|
|
54
|
+
key: visible,
|
|
55
|
+
visible
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Select and normalize the loading messages used for Slack status rotation. */
|
|
59
|
+
function selectAssistantLoadingMessages(args) {
|
|
60
|
+
const random = args.random ?? Math.random;
|
|
61
|
+
const normalized = Array.from(new Set(args.messages.map((message) => truncateStatusText(normalizeSlackStatusText(message))).filter((message) => message.length > 0)));
|
|
62
|
+
if (normalized.length === 0) return;
|
|
63
|
+
const shuffled = [...normalized];
|
|
64
|
+
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
|
65
|
+
const otherIndex = Math.floor(random() * (index + 1));
|
|
66
|
+
[shuffled[index], shuffled[otherIndex]] = [shuffled[otherIndex], shuffled[index]];
|
|
67
|
+
}
|
|
68
|
+
return shuffled.slice(0, 10);
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/chat/slack/assistant-thread/status-scheduler.ts
|
|
72
|
+
const STATUS_UPDATE_DEBOUNCE_MS = 1e3;
|
|
73
|
+
const STATUS_MIN_VISIBLE_MS = 1200;
|
|
74
|
+
const STATUS_ROTATION_INTERVAL_MS = 3e4;
|
|
75
|
+
/**
|
|
76
|
+
* Pace assistant loading-state writes for a single turn.
|
|
77
|
+
*
|
|
78
|
+
* This layer owns only local scheduling policy: debounce, minimum visible
|
|
79
|
+
* duration, refresh cadence, and write ordering. It deals in user-visible
|
|
80
|
+
* progress copy; the transport decides how that maps onto Slack's `status`
|
|
81
|
+
* versus `loading_messages` fields.
|
|
82
|
+
*/
|
|
83
|
+
function createAssistantStatusScheduler(args) {
|
|
84
|
+
const now = args.now ?? (() => Date.now());
|
|
85
|
+
const setTimer = args.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
86
|
+
const clearTimer = args.clearTimer ?? ((timer) => clearTimeout(timer));
|
|
87
|
+
const random = args.random ?? Math.random;
|
|
88
|
+
const loadingMessages = selectAssistantLoadingMessages({
|
|
89
|
+
messages: args.loadingMessages ?? [],
|
|
90
|
+
random
|
|
91
|
+
});
|
|
92
|
+
const defaultStatus = makeAssistantStatus("thinking");
|
|
93
|
+
let active = false;
|
|
94
|
+
let currentKey = "";
|
|
95
|
+
let currentVisibleStatus = "";
|
|
96
|
+
let currentLoadingMessages;
|
|
97
|
+
let lastStatusAt = 0;
|
|
98
|
+
let pendingStatus = null;
|
|
99
|
+
let pendingKey = "";
|
|
100
|
+
let pendingTimer = null;
|
|
101
|
+
let rotationTimer = null;
|
|
102
|
+
let inflightStatusUpdate = Promise.resolve();
|
|
103
|
+
const enqueueStatusUpdate = (task) => {
|
|
104
|
+
const request = inflightStatusUpdate.catch(() => void 0).then(async () => {
|
|
105
|
+
await task();
|
|
106
|
+
});
|
|
107
|
+
inflightStatusUpdate = request.catch(() => void 0);
|
|
108
|
+
return request;
|
|
109
|
+
};
|
|
110
|
+
const scheduleRotation = () => {
|
|
111
|
+
if (rotationTimer) {
|
|
112
|
+
clearTimer(rotationTimer);
|
|
113
|
+
rotationTimer = null;
|
|
114
|
+
}
|
|
115
|
+
if (!active || !currentVisibleStatus) return;
|
|
116
|
+
rotationTimer = setTimer(() => {
|
|
117
|
+
rotationTimer = null;
|
|
118
|
+
if (!active || !currentVisibleStatus) return;
|
|
119
|
+
postStatus(currentVisibleStatus, currentLoadingMessages);
|
|
120
|
+
}, STATUS_ROTATION_INTERVAL_MS);
|
|
121
|
+
};
|
|
122
|
+
const getLoadingMessagesForVisibleStatus = (visible) => visible ? [visible] : void 0;
|
|
123
|
+
const getInitialStatusText = () => {
|
|
124
|
+
if (loadingMessages?.length) return loadingMessages[0];
|
|
125
|
+
return defaultStatus.text;
|
|
126
|
+
};
|
|
127
|
+
const haveSameLoadingMessages = (left, right) => {
|
|
128
|
+
if (left === right) return true;
|
|
129
|
+
if (!left || !right || left.length !== right.length) return false;
|
|
130
|
+
return left.every((message, index) => message === right[index]);
|
|
131
|
+
};
|
|
132
|
+
const postStatus = async (text, nextLoadingMessages) => {
|
|
133
|
+
if (!text && !currentVisibleStatus) return;
|
|
134
|
+
currentVisibleStatus = text;
|
|
135
|
+
currentLoadingMessages = nextLoadingMessages;
|
|
136
|
+
lastStatusAt = now();
|
|
137
|
+
scheduleRotation();
|
|
138
|
+
await enqueueStatusUpdate(async () => {
|
|
139
|
+
await args.sendStatus(text, nextLoadingMessages);
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
const postRenderedStatus = async (status) => {
|
|
143
|
+
const presentation = renderAssistantStatus({ status });
|
|
144
|
+
const nextLoadingMessages = getLoadingMessagesForVisibleStatus(presentation.visible);
|
|
145
|
+
currentKey = presentation.key;
|
|
146
|
+
await postStatus(presentation.visible, nextLoadingMessages);
|
|
147
|
+
};
|
|
148
|
+
const clearPending = () => {
|
|
149
|
+
if (pendingTimer) {
|
|
150
|
+
clearTimer(pendingTimer);
|
|
151
|
+
pendingTimer = null;
|
|
152
|
+
}
|
|
153
|
+
pendingStatus = null;
|
|
154
|
+
pendingKey = "";
|
|
155
|
+
};
|
|
156
|
+
const flushPending = async () => {
|
|
157
|
+
if (!active || !pendingStatus) {
|
|
158
|
+
clearPending();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const next = pendingStatus;
|
|
162
|
+
clearPending();
|
|
163
|
+
if (renderAssistantStatus({ status: next }).key !== currentKey) await postRenderedStatus(next);
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
async clear() {
|
|
167
|
+
active = false;
|
|
168
|
+
clearPending();
|
|
169
|
+
if (rotationTimer) {
|
|
170
|
+
clearTimer(rotationTimer);
|
|
171
|
+
rotationTimer = null;
|
|
172
|
+
}
|
|
173
|
+
currentKey = "";
|
|
174
|
+
await postStatus("");
|
|
175
|
+
},
|
|
176
|
+
update(status) {
|
|
177
|
+
if (!active) {
|
|
178
|
+
active = true;
|
|
179
|
+
clearPending();
|
|
180
|
+
if (status) {
|
|
181
|
+
postRenderedStatus(status);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
currentKey = "initial";
|
|
185
|
+
postStatus(getInitialStatusText(), loadingMessages);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!status) {
|
|
189
|
+
clearPending();
|
|
190
|
+
currentKey = "initial";
|
|
191
|
+
postStatus(getInitialStatusText(), loadingMessages);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const presentation = renderAssistantStatus({ status });
|
|
195
|
+
if (!presentation.visible) return;
|
|
196
|
+
if (presentation.key === currentKey || presentation.key === pendingKey) return;
|
|
197
|
+
if (presentation.visible === currentVisibleStatus) {
|
|
198
|
+
clearPending();
|
|
199
|
+
currentKey = presentation.key;
|
|
200
|
+
const nextLoadingMessages = getLoadingMessagesForVisibleStatus(presentation.visible);
|
|
201
|
+
if (!haveSameLoadingMessages(currentLoadingMessages, nextLoadingMessages)) postStatus(presentation.visible, nextLoadingMessages);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const elapsed = now() - lastStatusAt;
|
|
205
|
+
const waitMs = Math.max(STATUS_UPDATE_DEBOUNCE_MS - elapsed, STATUS_MIN_VISIBLE_MS - elapsed, 0);
|
|
206
|
+
if (waitMs <= 0) {
|
|
207
|
+
clearPending();
|
|
208
|
+
postRenderedStatus(status);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
pendingStatus = status;
|
|
212
|
+
pendingKey = presentation.key;
|
|
213
|
+
if (pendingTimer) return;
|
|
214
|
+
pendingTimer = setTimer(() => {
|
|
215
|
+
pendingTimer = null;
|
|
216
|
+
flushPending();
|
|
217
|
+
}, Math.max(1, waitMs));
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
//#endregion
|
|
222
|
+
//#region src/chat/slack/assistant-thread/status-send.ts
|
|
223
|
+
/**
|
|
224
|
+
* Slack's assistant loading UI accepts both `status` and `loading_messages`,
|
|
225
|
+
* but product policy keeps `status` stable and generic. User-visible progress
|
|
226
|
+
* copy belongs in `loading_messages`.
|
|
227
|
+
*/
|
|
228
|
+
const SLACK_ASSISTANT_ACTIVE_STATUS = "is working on your request...";
|
|
229
|
+
/**
|
|
230
|
+
* Build a best-effort sender for Slack's assistant loading state.
|
|
231
|
+
*
|
|
232
|
+
* The `text` argument is internal progress copy. This transport maps it onto
|
|
233
|
+
* Slack's loading surface and keeps the raw Slack `status` field fixed.
|
|
234
|
+
*/
|
|
235
|
+
function createSlackAdapterStatusSender(args) {
|
|
236
|
+
const adapter = args.getSlackAdapter();
|
|
237
|
+
const boundToken = getSlackAdapterRequestToken(adapter);
|
|
238
|
+
return async (text, loadingMessages) => {
|
|
239
|
+
const channelId = args.channelId;
|
|
240
|
+
const threadTs = args.threadTs;
|
|
241
|
+
if (!channelId || !threadTs) return;
|
|
242
|
+
const normalizedChannelId = normalizeSlackConversationId(channelId);
|
|
243
|
+
if (!normalizedChannelId) return;
|
|
244
|
+
const nextLoadingMessages = text ? loadingMessages ?? [text] : void 0;
|
|
245
|
+
try {
|
|
246
|
+
await runWithBoundSlackToken(adapter, boundToken, () => adapter.setAssistantStatus(normalizedChannelId, threadTs, text ? SLACK_ASSISTANT_ACTIVE_STATUS : "", nextLoadingMessages));
|
|
247
|
+
} catch (error) {
|
|
248
|
+
logAssistantStatusFailure({
|
|
249
|
+
status: text,
|
|
250
|
+
error,
|
|
251
|
+
channelId,
|
|
252
|
+
normalizedChannelId,
|
|
253
|
+
threadTs
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Build a best-effort sender for Slack's assistant loading state over raw Web
|
|
260
|
+
* API calls. As with the adapter-backed path, the dynamic copy goes to
|
|
261
|
+
* `loading_messages` while `status` stays fixed and generic.
|
|
262
|
+
*/
|
|
263
|
+
function createSlackWebApiStatusSender(args) {
|
|
264
|
+
const getClient = args.getSlackClient ?? getSlackClient;
|
|
265
|
+
return async (text, loadingMessages) => {
|
|
266
|
+
const channelId = args.channelId;
|
|
267
|
+
const threadTs = args.threadTs;
|
|
268
|
+
if (!channelId || !threadTs) return;
|
|
269
|
+
const normalizedChannelId = normalizeSlackConversationId(channelId);
|
|
270
|
+
if (!normalizedChannelId) return;
|
|
271
|
+
const nextLoadingMessages = text ? loadingMessages ?? [text] : void 0;
|
|
272
|
+
try {
|
|
273
|
+
await getClient().assistant.threads.setStatus({
|
|
274
|
+
channel_id: normalizedChannelId,
|
|
275
|
+
thread_ts: threadTs,
|
|
276
|
+
status: text ? SLACK_ASSISTANT_ACTIVE_STATUS : "",
|
|
277
|
+
...nextLoadingMessages ? { loading_messages: nextLoadingMessages } : void 0
|
|
278
|
+
});
|
|
279
|
+
} catch (error) {
|
|
280
|
+
logAssistantStatusFailure({
|
|
281
|
+
status: text,
|
|
282
|
+
error,
|
|
283
|
+
channelId,
|
|
284
|
+
normalizedChannelId,
|
|
285
|
+
threadTs
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function getSlackAdapterRequestToken(adapter) {
|
|
291
|
+
const token = adapter.requestContext?.getStore()?.token;
|
|
292
|
+
if (typeof token !== "string") return;
|
|
293
|
+
return token.trim() || void 0;
|
|
294
|
+
}
|
|
295
|
+
async function runWithBoundSlackToken(adapter, token, task) {
|
|
296
|
+
if (!token) return await task();
|
|
297
|
+
return await adapter.withBotToken(token, task);
|
|
298
|
+
}
|
|
299
|
+
function logAssistantStatusFailure(args) {
|
|
300
|
+
logWarn("assistant.status.update.failed", {
|
|
301
|
+
"app.slack.status_text": args.status || "(clear)",
|
|
302
|
+
"app.slack.channel_id_raw": args.channelId,
|
|
303
|
+
"app.slack.channel_id": args.normalizedChannelId,
|
|
304
|
+
"app.slack.thread_ts": args.threadTs,
|
|
305
|
+
"exception.message": args.error instanceof Error ? args.error.message : String(args.error)
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
//#endregion
|
|
309
|
+
//#region src/chat/slack/assistant-thread/status.ts
|
|
310
|
+
/**
|
|
311
|
+
* Create a Slack adapter-backed session for Slack's assistant loading state.
|
|
312
|
+
*
|
|
313
|
+
* The session accepts internal progress updates and leaves it to the sender to
|
|
314
|
+
* map them onto Slack's fixed generic `status` field plus dynamic
|
|
315
|
+
* `loading_messages`.
|
|
316
|
+
*/
|
|
317
|
+
function createSlackAdapterAssistantStatusSession(args) {
|
|
318
|
+
return createAssistantStatusScheduler({
|
|
319
|
+
sendStatus: createSlackAdapterStatusSender({
|
|
320
|
+
channelId: args.channelId,
|
|
321
|
+
threadTs: args.threadTs,
|
|
322
|
+
getSlackAdapter: args.getSlackAdapter
|
|
323
|
+
}),
|
|
324
|
+
loadingMessages: args.loadingMessages ?? botConfig.loadingMessages,
|
|
325
|
+
now: args.now,
|
|
326
|
+
setTimer: args.setTimer,
|
|
327
|
+
clearTimer: args.clearTimer,
|
|
328
|
+
random: args.random
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Create a Web API-backed session for Slack's assistant loading state in
|
|
333
|
+
* resume/callback flows that do not use the adapter thread object.
|
|
334
|
+
*/
|
|
335
|
+
function createSlackWebApiAssistantStatusSession(args) {
|
|
336
|
+
return createAssistantStatusScheduler({
|
|
337
|
+
sendStatus: createSlackWebApiStatusSender({
|
|
338
|
+
channelId: args.channelId,
|
|
339
|
+
threadTs: args.threadTs,
|
|
340
|
+
getSlackClient: args.getSlackClient
|
|
341
|
+
}),
|
|
342
|
+
loadingMessages: args.loadingMessages ?? botConfig.loadingMessages,
|
|
343
|
+
now: args.now,
|
|
344
|
+
setTimer: args.setTimer,
|
|
345
|
+
clearTimer: args.clearTimer,
|
|
346
|
+
random: args.random
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
//#endregion
|
|
350
|
+
//#region src/chat/slack/footer.ts
|
|
351
|
+
/** Render compact reply attribution for the Slack footer. */
|
|
352
|
+
function formatReplyAttribution(attribution) {
|
|
353
|
+
return attribution.detail ? `${attribution.label} · ${attribution.detail}` : attribution.label;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Build the compact conversation footer for visible Slack reply surfaces.
|
|
357
|
+
*
|
|
358
|
+
* Detailed turn metrics stay in the dashboard instead of Slack-visible copy.
|
|
359
|
+
*/
|
|
360
|
+
function buildSlackReplyFooter(args) {
|
|
361
|
+
const items = [];
|
|
362
|
+
const conversationId = args.conversationId?.trim();
|
|
363
|
+
if (conversationId) {
|
|
364
|
+
const idItem = {
|
|
365
|
+
label: "ID",
|
|
366
|
+
value: conversationId
|
|
367
|
+
};
|
|
368
|
+
const conversationUrl = getPluginSlackConversationLink(conversationId)?.url ?? getDashboardConversationLink(conversationId) ?? buildSentryConversationUrl(conversationId);
|
|
369
|
+
if (conversationUrl) idItem.url = conversationUrl;
|
|
370
|
+
items.push(idItem);
|
|
371
|
+
}
|
|
372
|
+
return items.length > 0 || args.replyAttribution ? {
|
|
373
|
+
...args.replyAttribution ? { attribution: args.replyAttribution } : void 0,
|
|
374
|
+
items
|
|
375
|
+
} : void 0;
|
|
376
|
+
}
|
|
377
|
+
/** Build Slack blocks for a reply chunk using the Slack-flavored markdown block for the body. */
|
|
378
|
+
function buildSlackReplyBlocks(text, footer) {
|
|
379
|
+
if (!text.trim()) return;
|
|
380
|
+
const blocks = [{
|
|
381
|
+
type: "markdown",
|
|
382
|
+
text
|
|
383
|
+
}];
|
|
384
|
+
if (footer && (footer.attribution || footer.items.length > 0)) {
|
|
385
|
+
const attributionElements = footer.attribution ? [{
|
|
386
|
+
type: "plain_text",
|
|
387
|
+
text: formatReplyAttribution(footer.attribution)
|
|
388
|
+
}] : [];
|
|
389
|
+
blocks.push({
|
|
390
|
+
type: "context",
|
|
391
|
+
elements: [...attributionElements, ...footer.items.map((item) => ({
|
|
392
|
+
type: "mrkdwn",
|
|
393
|
+
text: item.url ? `*${escapeSlackMrkdwnText(item.label)}:* ${formatSlackLink(item.url, item.value)}` : `*${escapeSlackMrkdwnText(item.label)}:* ${escapeSlackMrkdwnText(item.value)}`
|
|
394
|
+
}))]
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
return blocks;
|
|
398
|
+
}
|
|
399
|
+
//#endregion
|
|
400
|
+
//#region src/chat/slack/output.ts
|
|
401
|
+
const MAX_INLINE_CHARS = 2200;
|
|
402
|
+
const MAX_INLINE_LINES = 45;
|
|
403
|
+
const CONTINUED_MARKER = "\n\n[Continued below]";
|
|
404
|
+
function countSlackLines(text) {
|
|
405
|
+
if (!text) return 0;
|
|
406
|
+
return text.split("\n").length;
|
|
407
|
+
}
|
|
408
|
+
function fitsInlineBudget(text, maxChars = MAX_INLINE_CHARS, maxLines = MAX_INLINE_LINES) {
|
|
409
|
+
return text.length <= maxChars && countSlackLines(text) <= maxLines;
|
|
410
|
+
}
|
|
411
|
+
function findSplitIndex(text, maxChars) {
|
|
412
|
+
if (text.length <= maxChars) return text.length;
|
|
413
|
+
const bounded = text.slice(0, maxChars);
|
|
414
|
+
const newlineIndex = bounded.lastIndexOf("\n");
|
|
415
|
+
if (newlineIndex > 0) return newlineIndex;
|
|
416
|
+
const spaceIndex = bounded.lastIndexOf(" ");
|
|
417
|
+
if (spaceIndex > 0) return spaceIndex;
|
|
418
|
+
return maxChars;
|
|
419
|
+
}
|
|
420
|
+
function splitByLineBudget(text, maxLines) {
|
|
421
|
+
if (maxLines <= 0) return "";
|
|
422
|
+
const lines = text.split("\n");
|
|
423
|
+
if (lines.length <= maxLines) return text;
|
|
424
|
+
return lines.slice(0, maxLines).join("\n");
|
|
425
|
+
}
|
|
426
|
+
function reserveInlineBudgetForSuffix(suffix, maxChars = MAX_INLINE_CHARS, maxLines = MAX_INLINE_LINES) {
|
|
427
|
+
return {
|
|
428
|
+
maxChars: Math.max(1, maxChars - suffix.length),
|
|
429
|
+
maxLines: Math.max(1, maxLines - Math.max(0, countSlackLines(suffix) - 1))
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function forceSplitBudget(text, budget) {
|
|
433
|
+
const lineCount = countSlackLines(text);
|
|
434
|
+
return {
|
|
435
|
+
maxChars: text.length <= budget.maxChars ? Math.max(1, text.length - 1) : budget.maxChars,
|
|
436
|
+
maxLines: lineCount <= budget.maxLines ? Math.max(1, lineCount - 1) : budget.maxLines
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
function getFenceContinuation(text) {
|
|
440
|
+
let open;
|
|
441
|
+
for (const line of text.split("\n")) {
|
|
442
|
+
const trimmed = line.trimStart();
|
|
443
|
+
const openerMatch = trimmed.match(/^(`{3,}|~{3,})(.*)$/);
|
|
444
|
+
if (!openerMatch) continue;
|
|
445
|
+
if (!open) {
|
|
446
|
+
open = {
|
|
447
|
+
fence: openerMatch[1],
|
|
448
|
+
openerLine: trimmed
|
|
449
|
+
};
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (new RegExp(`^${open.fence}\\s*$`).test(trimmed)) open = void 0;
|
|
453
|
+
}
|
|
454
|
+
if (!open) return null;
|
|
455
|
+
return {
|
|
456
|
+
closeSuffix: text.endsWith("\n") ? open.fence : `\n${open.fence}`,
|
|
457
|
+
reopenPrefix: `${open.openerLine}\n`
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
function appendSlackSuffix(text, marker) {
|
|
461
|
+
return `${text}${getFenceContinuation(text)?.closeSuffix ?? ""}${marker}`;
|
|
462
|
+
}
|
|
463
|
+
function stripTrailingContinuationMarker(text) {
|
|
464
|
+
return text.endsWith(CONTINUED_MARKER) ? text.slice(0, -19) : text;
|
|
465
|
+
}
|
|
466
|
+
function takeSlackContinuationChunk(text, budget) {
|
|
467
|
+
let { prefix, rest } = takeSlackInlinePrefix(text, budget);
|
|
468
|
+
if (!rest) ({prefix, rest} = takeSlackInlinePrefix(text, forceSplitBudget(text, budget)));
|
|
469
|
+
let carryover = rest ? getFenceContinuation(prefix) : null;
|
|
470
|
+
if (!carryover) return {
|
|
471
|
+
prefix,
|
|
472
|
+
rest
|
|
473
|
+
};
|
|
474
|
+
const carryoverBudget = reserveInlineBudgetForSuffix(`${carryover.closeSuffix}${CONTINUED_MARKER}`);
|
|
475
|
+
({prefix, rest} = takeSlackInlinePrefix(text, carryoverBudget));
|
|
476
|
+
if (!rest) ({prefix, rest} = takeSlackInlinePrefix(text, forceSplitBudget(text, carryoverBudget)));
|
|
477
|
+
carryover = rest ? getFenceContinuation(prefix) : null;
|
|
478
|
+
if (!carryover) return {
|
|
479
|
+
prefix,
|
|
480
|
+
rest
|
|
481
|
+
};
|
|
482
|
+
return {
|
|
483
|
+
prefix,
|
|
484
|
+
rest: `${carryover.reopenPrefix}${rest}`
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/** Return the first continuation-safe chunk plus any remaining text. */
|
|
488
|
+
function takeSlackContinuationPrefix(text, options) {
|
|
489
|
+
const budget = {
|
|
490
|
+
maxChars: options?.maxChars ?? getSlackContinuationBudget().maxChars,
|
|
491
|
+
maxLines: options?.maxLines ?? getSlackContinuationBudget().maxLines
|
|
492
|
+
};
|
|
493
|
+
const { prefix, rest } = (() => {
|
|
494
|
+
if (options?.forceSplit) return takeSlackContinuationChunk(text, budget);
|
|
495
|
+
const initial = takeSlackInlinePrefix(text, budget);
|
|
496
|
+
return initial.rest ? takeSlackContinuationChunk(text, budget) : initial;
|
|
497
|
+
})();
|
|
498
|
+
return {
|
|
499
|
+
prefix,
|
|
500
|
+
renderedPrefix: rest ? appendSlackSuffix(prefix, CONTINUED_MARKER) : prefix,
|
|
501
|
+
rest
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Take the largest Slack-safe inline prefix from `text` under the configured
|
|
506
|
+
* character and line budgets. Returns the consumed prefix plus remaining text.
|
|
507
|
+
*/
|
|
508
|
+
function takeSlackInlinePrefix(text, options) {
|
|
509
|
+
const maxChars = options?.maxChars ?? MAX_INLINE_CHARS;
|
|
510
|
+
const maxLines = options?.maxLines ?? MAX_INLINE_LINES;
|
|
511
|
+
const normalized = text.replace(/\r\n?/g, "\n");
|
|
512
|
+
if (!normalized) return {
|
|
513
|
+
prefix: "",
|
|
514
|
+
rest: ""
|
|
515
|
+
};
|
|
516
|
+
if (fitsInlineBudget(normalized, maxChars, maxLines)) return {
|
|
517
|
+
prefix: normalized,
|
|
518
|
+
rest: ""
|
|
519
|
+
};
|
|
520
|
+
const lineBounded = splitByLineBudget(normalized, maxLines);
|
|
521
|
+
const cutIndex = findSplitIndex(lineBounded, maxChars);
|
|
522
|
+
const prefix = lineBounded.slice(0, cutIndex).trimEnd();
|
|
523
|
+
if (prefix) return {
|
|
524
|
+
prefix,
|
|
525
|
+
rest: normalized.slice(prefix.length).trimStart()
|
|
526
|
+
};
|
|
527
|
+
const hardPrefix = normalized.slice(0, Math.max(1, maxChars)).trimEnd();
|
|
528
|
+
return {
|
|
529
|
+
prefix: hardPrefix || normalized.slice(0, Math.max(1, maxChars)),
|
|
530
|
+
rest: normalized.slice(hardPrefix.length || Math.max(1, maxChars)).trimStart()
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Split a normalized Slack reply into multiple inline-safe thread messages.
|
|
535
|
+
*
|
|
536
|
+
* Replies split across more than two messages receive explicit continuation
|
|
537
|
+
* markers on non-final chunks. When
|
|
538
|
+
* `interrupted` is true, the final chunk receives an interruption marker.
|
|
539
|
+
*/
|
|
540
|
+
function splitSlackReplyText(text, options) {
|
|
541
|
+
const normalized = normalizeSlackReplyMarkdown(text);
|
|
542
|
+
if (!normalized) return [];
|
|
543
|
+
const chunks = [];
|
|
544
|
+
const continuationBudget = reserveInlineBudgetForSuffix(CONTINUED_MARKER);
|
|
545
|
+
let remaining = normalized;
|
|
546
|
+
while (remaining) {
|
|
547
|
+
if (options?.interrupted ? fitsInlineBudget(appendSlackSuffix(remaining, getInterruptionMarker())) : fitsInlineBudget(remaining)) {
|
|
548
|
+
chunks.push(options?.interrupted ? appendSlackSuffix(remaining, getInterruptionMarker()) : remaining);
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
const { renderedPrefix, rest } = takeSlackContinuationPrefix(remaining, {
|
|
552
|
+
...continuationBudget,
|
|
553
|
+
forceSplit: true
|
|
554
|
+
});
|
|
555
|
+
chunks.push(renderedPrefix);
|
|
556
|
+
remaining = rest;
|
|
557
|
+
}
|
|
558
|
+
if (chunks.length === 2) chunks[0] = stripTrailingContinuationMarker(chunks[0] ?? "");
|
|
559
|
+
return chunks;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Reserve enough inline budget for a continuation suffix on the current chunk.
|
|
563
|
+
*/
|
|
564
|
+
function getSlackContinuationBudget() {
|
|
565
|
+
return reserveInlineBudgetForSuffix(CONTINUED_MARKER);
|
|
566
|
+
}
|
|
567
|
+
/** Normalize text for Slack and wrap it as a PostableMessage. */
|
|
568
|
+
function buildSlackOutputMessage(text) {
|
|
569
|
+
const normalized = normalizeSlackReplyMarkdown(text);
|
|
570
|
+
if (!normalized) throw new Error(`Slack output normalized to empty content: original_length=${text.length} parsed_length=${normalized.length}`);
|
|
571
|
+
return { markdown: normalized };
|
|
572
|
+
}
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region src/chat/slack/reply.ts
|
|
575
|
+
/**
|
|
576
|
+
* Send one destination-visible Slack reply.
|
|
577
|
+
*
|
|
578
|
+
* Chunks oversized text, builds compact attribution and conversation footer
|
|
579
|
+
* context, and posts through the shared Slack outbound boundary.
|
|
580
|
+
*/
|
|
581
|
+
async function sendSlackReply(args) {
|
|
582
|
+
const chunks = splitSlackReplyText(args.text);
|
|
583
|
+
const footer = buildSlackReplyFooter({
|
|
584
|
+
conversationId: args.conversationId,
|
|
585
|
+
replyAttribution: args.replyAttribution
|
|
586
|
+
});
|
|
587
|
+
const messageTs = [];
|
|
588
|
+
let threadTs = args.threadTs;
|
|
589
|
+
for (const [index, text] of chunks.entries()) {
|
|
590
|
+
const isFinalChunk = index === chunks.length - 1;
|
|
591
|
+
const blocks = buildSlackReplyBlocks(text, isFinalChunk ? footer : void 0);
|
|
592
|
+
const fallbackText = isFinalChunk && args.replyAttribution ? `${text}\n\n${escapeSlackMrkdwnText(formatReplyAttribution(args.replyAttribution))}` : text;
|
|
593
|
+
const response = await postSlackMessage({
|
|
594
|
+
channelId: args.channelId,
|
|
595
|
+
threadTs,
|
|
596
|
+
text: fallbackText,
|
|
597
|
+
...blocks ? { blocks } : void 0
|
|
598
|
+
});
|
|
599
|
+
if (response.ts) {
|
|
600
|
+
messageTs.push(response.ts);
|
|
601
|
+
threadTs ??= response.ts;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return messageTs;
|
|
605
|
+
}
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region src/chat/slack/errors.ts
|
|
608
|
+
/** Extract Slack's stable API error code from Web API errors. */
|
|
609
|
+
function getSlackApiErrorCode(error) {
|
|
610
|
+
if (!error || typeof error !== "object") return;
|
|
611
|
+
const candidate = error;
|
|
612
|
+
if (typeof candidate.data?.error === "string" && candidate.data.error.trim().length > 0) return candidate.data.error;
|
|
613
|
+
if (typeof candidate.code === "string" && candidate.code.trim().length > 0) return candidate.code;
|
|
614
|
+
}
|
|
615
|
+
/** Convert Slack Web API error details into the repository's trace attributes. */
|
|
616
|
+
function getSlackErrorObservabilityAttributes(error) {
|
|
617
|
+
if (!error || typeof error !== "object") return {};
|
|
618
|
+
const candidate = error;
|
|
619
|
+
const attributes = {};
|
|
620
|
+
if (typeof candidate.code === "string" && candidate.code.trim().length > 0) attributes["app.slack.error_code"] = candidate.code;
|
|
621
|
+
if (typeof candidate.data?.error === "string" && candidate.data.error.trim().length > 0) attributes["app.slack.api_error"] = candidate.data.error;
|
|
622
|
+
const requestId = getHeaderString(candidate.headers, "x-slack-req-id");
|
|
623
|
+
if (requestId) attributes["app.slack.request_id"] = requestId;
|
|
624
|
+
if (typeof candidate.statusCode === "number" && Number.isFinite(candidate.statusCode)) attributes["http.response.status_code"] = candidate.statusCode;
|
|
625
|
+
return attributes;
|
|
626
|
+
}
|
|
627
|
+
const TRANSIENT_SLACK_API_ERRORS = /* @__PURE__ */ new Set([
|
|
628
|
+
"fatal_error",
|
|
629
|
+
"internal_error",
|
|
630
|
+
"rate_limited",
|
|
631
|
+
"ratelimited",
|
|
632
|
+
"request_timeout",
|
|
633
|
+
"service_unavailable"
|
|
634
|
+
]);
|
|
635
|
+
/** Recover transient or ambiguous Slack failures, but stop on explicit rejection. */
|
|
636
|
+
function isRetryableSlackPostError(error) {
|
|
637
|
+
if (!error || typeof error !== "object") return true;
|
|
638
|
+
const candidate = error;
|
|
639
|
+
if (typeof candidate.statusCode === "number") {
|
|
640
|
+
if (candidate.statusCode === 429 || candidate.statusCode >= 500) return true;
|
|
641
|
+
if (candidate.statusCode >= 400) return false;
|
|
642
|
+
}
|
|
643
|
+
const apiError = error instanceof SlackActionError ? error.apiError : typeof candidate.data?.error === "string" ? candidate.data.error : void 0;
|
|
644
|
+
if (apiError) return TRANSIENT_SLACK_API_ERRORS.has(apiError);
|
|
645
|
+
if (error instanceof SlackActionError) return error.code === "rate_limited" || error.code === "internal_error";
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
/** Report whether Slack rejected assistant title updates for stable auth reasons. */
|
|
649
|
+
function isSlackTitlePermissionError(error) {
|
|
650
|
+
const code = getSlackApiErrorCode(error);
|
|
651
|
+
return code === "no_permission" || code === "missing_scope" || code === "not_allowed_token_type";
|
|
652
|
+
}
|
|
653
|
+
//#endregion
|
|
654
|
+
//#region src/chat/runtime/thread-context.ts
|
|
655
|
+
function escapeRegExp(value) {
|
|
656
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
657
|
+
}
|
|
658
|
+
function readOptionalStringField(value, key) {
|
|
659
|
+
if (!(key in value)) return;
|
|
660
|
+
return toOptionalString(value[key]);
|
|
661
|
+
}
|
|
662
|
+
function stripLeadingBotMention(text, options = {}) {
|
|
663
|
+
if (!text.trim()) return text;
|
|
664
|
+
let next = text;
|
|
665
|
+
if (options.stripLeadingSlackMentionToken) {
|
|
666
|
+
if (options.botUserId) {
|
|
667
|
+
const botUserId = escapeRegExp(options.botUserId);
|
|
668
|
+
const mentionByBotUserIdRe = new RegExp(`^\\s*(?:<@${botUserId}(?:\\|[^>]+)?>|@${botUserId})[\\s,:-]*`, "i");
|
|
669
|
+
next = next.replace(mentionByBotUserIdRe, "").trim();
|
|
670
|
+
} else next = next.replace(/^\s*<@[^>]+>[\s,:-]*/, "").trim();
|
|
671
|
+
}
|
|
672
|
+
const mentionByNameRe = new RegExp(`^\\s*@${escapeRegExp(botConfig.userName)}\\b[\\s,:-]*`, "i");
|
|
673
|
+
next = next.replace(mentionByNameRe, "").trim();
|
|
674
|
+
const mentionByLabeledEntityRe = new RegExp(`^\\s*<@[^>|]+\\|${escapeRegExp(botConfig.userName)}>[\\s,:-]*`, "i");
|
|
675
|
+
next = next.replace(mentionByLabeledEntityRe, "").trim();
|
|
676
|
+
return next;
|
|
677
|
+
}
|
|
678
|
+
function getThreadId(thread, _message) {
|
|
679
|
+
return toOptionalString(thread.id);
|
|
680
|
+
}
|
|
681
|
+
function getRunId(thread, message) {
|
|
682
|
+
return readOptionalStringField(thread, "runId") ?? readOptionalStringField(message, "runId");
|
|
683
|
+
}
|
|
684
|
+
function getChannelId(thread, message) {
|
|
685
|
+
return resolveSlackChannelIdFromThreadId(toOptionalString(thread.id)) ?? normalizeSlackConversationId(toOptionalString(thread.channelId)) ?? resolveSlackChannelIdFromMessage(message);
|
|
686
|
+
}
|
|
687
|
+
function getThreadTs(threadId) {
|
|
688
|
+
return parseSlackThreadId(threadId)?.threadTs;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Resolve Slack assistant-thread API context for the current turn.
|
|
692
|
+
*
|
|
693
|
+
* Slack assistant-thread methods must use the live inbound thread context
|
|
694
|
+
* Slack provided on the current message. Slack's assistant utilities build
|
|
695
|
+
* `setStatus`/`setTitle` from `message.channel` plus `message.thread_ts ?? message.ts`
|
|
696
|
+
* for non-DM message events, while `message.im` still requires an explicit
|
|
697
|
+
* `thread_ts`. Do not synthesize assistant-thread roots from persisted state.
|
|
698
|
+
*/
|
|
699
|
+
function getAssistantThreadContext(message) {
|
|
700
|
+
const rawContext = readSlackRawMessageContext(message);
|
|
701
|
+
const channelId = rawContext?.channelId;
|
|
702
|
+
if (channelId) {
|
|
703
|
+
const threadTs = isDmChannel(channelId) ? rawContext.threadTs : rawContext.threadTs ?? rawContext.messageTs;
|
|
704
|
+
if (threadTs) return {
|
|
705
|
+
channelId,
|
|
706
|
+
threadTs
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
const parsedThreadId = parseSlackThreadId(readOptionalStringField(message, "threadId"));
|
|
710
|
+
if (!parsedThreadId || isDmChannel(parsedThreadId.channelId)) return;
|
|
711
|
+
return parsedThreadId;
|
|
712
|
+
}
|
|
713
|
+
/** Resolve the native Slack timestamp for a message that can target Slack APIs. */
|
|
714
|
+
function getMessageTs(message) {
|
|
715
|
+
const directTs = readOptionalStringField(message, "ts");
|
|
716
|
+
const parsedDirectTs = parseSlackMessageTs(directTs);
|
|
717
|
+
if (parsedDirectTs) return parsedDirectTs;
|
|
718
|
+
const rawContext = readSlackRawMessageContext(message);
|
|
719
|
+
if (!rawContext) return;
|
|
720
|
+
const candidates = [rawContext.messageTs, rawContext.nestedMessageTs];
|
|
721
|
+
for (const candidate of candidates) {
|
|
722
|
+
const ts = parseSlackMessageTs(candidate);
|
|
723
|
+
if (ts) return ts;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
//#endregion
|
|
727
|
+
//#region src/chat/providers/slack/processing-reaction.ts
|
|
728
|
+
const noProcessingReaction = {
|
|
729
|
+
complete: async () => void 0,
|
|
730
|
+
keep: () => void 0,
|
|
731
|
+
stop: async () => void 0
|
|
732
|
+
};
|
|
733
|
+
function isProcessingReactionEmoji(value) {
|
|
734
|
+
return typeof value === "string" && normalizeSlackEmojiName(value) === getChatConfig().slack.processingReactionEmoji;
|
|
735
|
+
}
|
|
736
|
+
/** Return true when a Slack reaction tool invocation keeps the processing reaction. */
|
|
737
|
+
function shouldKeepProcessingReactionForToolInvocation(input) {
|
|
738
|
+
return input.toolName === "addReaction" && isProcessingReactionEmoji(input.params.emoji);
|
|
739
|
+
}
|
|
740
|
+
/** Start Junior's automatic Slack processing reaction for one inbound message. */
|
|
741
|
+
async function startProcessingReaction(args) {
|
|
742
|
+
if (args.message.author.isMe) return noProcessingReaction;
|
|
743
|
+
const channelId = getChannelId(args.thread, args.message);
|
|
744
|
+
const messageTs = getMessageTs(args.message);
|
|
745
|
+
if (!channelId || !messageTs) return noProcessingReaction;
|
|
746
|
+
return startProcessingReactionForMessage({
|
|
747
|
+
channelId,
|
|
748
|
+
timestamp: messageTs
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
/** Start Junior's automatic Slack processing reaction for a known Slack message. */
|
|
752
|
+
async function startProcessingReactionForMessage(args) {
|
|
753
|
+
try {
|
|
754
|
+
await addReactionToMessage({
|
|
755
|
+
channelId: args.channelId,
|
|
756
|
+
timestamp: args.timestamp,
|
|
757
|
+
emoji: getChatConfig().slack.processingReactionEmoji
|
|
758
|
+
});
|
|
759
|
+
} catch (error) {
|
|
760
|
+
logException(error, "slack.processing.reaction_add.failed", {
|
|
761
|
+
"app.slack.action": "reactions.add",
|
|
762
|
+
"messaging.message.id": args.timestamp,
|
|
763
|
+
...getSlackErrorObservabilityAttributes(error)
|
|
764
|
+
});
|
|
765
|
+
return noProcessingReaction;
|
|
766
|
+
}
|
|
767
|
+
let shouldRemove = true;
|
|
768
|
+
const removeProcessingReaction = async () => {
|
|
769
|
+
if (!shouldRemove) return false;
|
|
770
|
+
try {
|
|
771
|
+
await removeReactionFromMessage({
|
|
772
|
+
channelId: args.channelId,
|
|
773
|
+
timestamp: args.timestamp,
|
|
774
|
+
emoji: getChatConfig().slack.processingReactionEmoji
|
|
775
|
+
});
|
|
776
|
+
return true;
|
|
777
|
+
} catch (error) {
|
|
778
|
+
logException(error, "slack.processing.reaction_remove.failed", {
|
|
779
|
+
"app.slack.action": "reactions.remove",
|
|
780
|
+
"messaging.message.id": args.timestamp,
|
|
781
|
+
...getSlackErrorObservabilityAttributes(error)
|
|
782
|
+
});
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
return {
|
|
787
|
+
complete: async () => {
|
|
788
|
+
const shouldAddCompleted = shouldRemove;
|
|
789
|
+
await removeProcessingReaction();
|
|
790
|
+
if (!shouldAddCompleted) return;
|
|
791
|
+
try {
|
|
792
|
+
await addReactionToMessage({
|
|
793
|
+
channelId: args.channelId,
|
|
794
|
+
timestamp: args.timestamp,
|
|
795
|
+
emoji: getChatConfig().slack.completedReactionEmoji
|
|
796
|
+
});
|
|
797
|
+
} catch (error) {
|
|
798
|
+
logException(error, "slack.processing.reaction_complete.failed", {
|
|
799
|
+
"app.slack.action": "reactions.add",
|
|
800
|
+
"messaging.message.id": args.timestamp,
|
|
801
|
+
...getSlackErrorObservabilityAttributes(error)
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
},
|
|
805
|
+
keep: () => {
|
|
806
|
+
shouldRemove = false;
|
|
807
|
+
},
|
|
808
|
+
stop: async () => {
|
|
809
|
+
await removeProcessingReaction();
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
//#endregion
|
|
814
|
+
//#region src/chat/services/auth-pause-response.ts
|
|
815
|
+
const MAX_AUTH_REQUEST_LENGTH = 200;
|
|
816
|
+
function formatAuthRequest(requestText) {
|
|
817
|
+
const normalized = requestText.replace(/\s+/g, " ").trim();
|
|
818
|
+
if (!normalized) return;
|
|
819
|
+
return (normalized.length > MAX_AUTH_REQUEST_LENGTH ? `${normalized.slice(0, 199).trimEnd()}…` : normalized).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
820
|
+
}
|
|
821
|
+
/** Build the visible Slack thread note for an auth-paused turn. */
|
|
822
|
+
function buildAuthPauseResponse(slackUserId, providerDisplayName, requestText) {
|
|
823
|
+
const mention = slackUserId ? `<@${slackUserId}> ` : "";
|
|
824
|
+
const request = requestText ? formatAuthRequest(requestText) : void 0;
|
|
825
|
+
if (!request) return `${mention}I'll need you to authorize ${providerDisplayName}. I sent you a link.`;
|
|
826
|
+
return `${mention}I need access to ${providerDisplayName} to continue.\n\n*Why:* ${request}\n\nI sent you a link.`;
|
|
827
|
+
}
|
|
828
|
+
//#endregion
|
|
829
|
+
//#region src/chat/providers/slack/resume.ts
|
|
830
|
+
function resolveReplyTimeoutMs() {
|
|
831
|
+
const raw = process.env.EVAL_AGENT_REPLY_TIMEOUT_MS?.trim();
|
|
832
|
+
if (!raw) return;
|
|
833
|
+
const parsed = Number.parseInt(raw, 10);
|
|
834
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
835
|
+
}
|
|
836
|
+
async function postSlackMessageBestEffort(channelId, threadTs, text, conversationId, replyAttribution) {
|
|
837
|
+
try {
|
|
838
|
+
if (conversationId) {
|
|
839
|
+
await sendSlackReply({
|
|
840
|
+
channelId,
|
|
841
|
+
conversationId,
|
|
842
|
+
replyAttribution,
|
|
843
|
+
text,
|
|
844
|
+
threadTs
|
|
845
|
+
});
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
await postSlackMessage({
|
|
849
|
+
channelId,
|
|
850
|
+
threadTs,
|
|
851
|
+
text
|
|
852
|
+
});
|
|
853
|
+
} catch {}
|
|
854
|
+
}
|
|
855
|
+
/** Create a read-only configuration service from persisted values. */
|
|
856
|
+
function createReadOnlyConfigService(rawValues) {
|
|
857
|
+
const values = Object.fromEntries(Object.entries(rawValues).map(([key, value]) => [key, configValueSchema.parse(value)]));
|
|
858
|
+
const entries = Object.entries(values).map(([key, value]) => ({
|
|
859
|
+
key,
|
|
860
|
+
value,
|
|
861
|
+
scope: "location",
|
|
862
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
863
|
+
}));
|
|
864
|
+
return {
|
|
865
|
+
get: async (key) => entries.find((entry) => entry.key === key),
|
|
866
|
+
set: async () => {
|
|
867
|
+
throw new Error("Configuration is read-only for a resumed Run");
|
|
868
|
+
},
|
|
869
|
+
unset: async () => false,
|
|
870
|
+
list: async ({ prefix } = {}) => entries.filter((entry) => !prefix || entry.key.startsWith(prefix)),
|
|
871
|
+
resolve: async (key) => values[key],
|
|
872
|
+
resolveValues: async ({ keys, prefix } = {}) => {
|
|
873
|
+
const filtered = {};
|
|
874
|
+
for (const [key, value] of Object.entries(values)) {
|
|
875
|
+
if (prefix && !key.startsWith(prefix)) continue;
|
|
876
|
+
if (keys && !keys.includes(key)) continue;
|
|
877
|
+
filtered[key] = value;
|
|
878
|
+
}
|
|
879
|
+
return filtered;
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
/** Error raised when another worker is already resuming the Slack Turn. */
|
|
884
|
+
var ResumeTurnBusyError = class extends Error {
|
|
885
|
+
constructor(lockKey) {
|
|
886
|
+
super(`Another worker is already resuming Slack Turn "${lockKey}"`);
|
|
887
|
+
this.name = "ResumeTurnBusyError";
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
function getDefaultLockKey(channelId, threadTs) {
|
|
891
|
+
return threadTs ? `slack:${channelId}:${threadTs}` : `slack:${channelId}`;
|
|
892
|
+
}
|
|
893
|
+
function getResumeLogContext(args, lockKey) {
|
|
894
|
+
const actor = args.run?.actor;
|
|
895
|
+
return {
|
|
896
|
+
conversationId: args.conversationId,
|
|
897
|
+
messageConversationId: lockKey,
|
|
898
|
+
userId: isUserActor(actor) ? actor.userId : void 0,
|
|
899
|
+
userName: isUserActor(actor) ? actor.userName : void 0,
|
|
900
|
+
destinationName: args.channelId,
|
|
901
|
+
assistantUserName: botConfig.userName,
|
|
902
|
+
modelId: defaultModelId(botConfig)
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
async function postResumeFailureReply(args) {
|
|
906
|
+
await postSlackMessage({
|
|
907
|
+
channelId: args.channelId,
|
|
908
|
+
threadTs: args.threadTs,
|
|
909
|
+
text: buildTurnErrorResponse(args.error, args.eventId)
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
async function handleResumeFailure(args) {
|
|
913
|
+
const turnLifecycle = getTurnLifecycle();
|
|
914
|
+
const capturedEventId = logException(args.error, args.eventName);
|
|
915
|
+
const eventId = requireTurnFailureEventId(capturedEventId, args.eventName);
|
|
916
|
+
let failureStatePersistError;
|
|
917
|
+
try {
|
|
918
|
+
await args.resume.onFailure?.(args.error);
|
|
919
|
+
} catch (persistError) {
|
|
920
|
+
const persistEventId = logException(persistError, "slack.resume.failure_state_persist.failed", { "app.error.original_event_id": eventId });
|
|
921
|
+
try {
|
|
922
|
+
await turnLifecycle.fail({
|
|
923
|
+
conversationId: args.resume.conversationId,
|
|
924
|
+
turnId: args.resume.turnId,
|
|
925
|
+
createdAtMs: Date.now(),
|
|
926
|
+
failureCode: "persistence_failed",
|
|
927
|
+
...persistEventId ? { eventId: persistEventId } : void 0
|
|
928
|
+
});
|
|
929
|
+
} catch (lifecycleError) {
|
|
930
|
+
logException(lifecycleError, "slack.resume.failure_lifecycle_persist.failed", { "app.error.original_event_id": eventId });
|
|
931
|
+
}
|
|
932
|
+
failureStatePersistError = persistError;
|
|
933
|
+
}
|
|
934
|
+
try {
|
|
935
|
+
await postResumeFailureReply({
|
|
936
|
+
channelId: args.resume.channelId,
|
|
937
|
+
threadTs: args.resume.threadTs,
|
|
938
|
+
eventId,
|
|
939
|
+
error: args.error
|
|
940
|
+
});
|
|
941
|
+
} catch (deliveryError) {
|
|
942
|
+
const deliveryEventId = logException(deliveryError, "slack.resume.failure_delivery.failed", { "app.error.original_event_id": eventId });
|
|
943
|
+
try {
|
|
944
|
+
await turnLifecycle.fail({
|
|
945
|
+
conversationId: args.resume.conversationId,
|
|
946
|
+
turnId: args.resume.turnId,
|
|
947
|
+
createdAtMs: Date.now(),
|
|
948
|
+
failureCode: "delivery_failed",
|
|
949
|
+
...deliveryEventId ? { eventId: deliveryEventId } : void 0
|
|
950
|
+
});
|
|
951
|
+
} catch (lifecycleError) {
|
|
952
|
+
logException(lifecycleError, "slack.resume.failure_lifecycle_persist.failed", { "app.error.original_event_id": eventId });
|
|
953
|
+
}
|
|
954
|
+
throw deliveryError;
|
|
955
|
+
}
|
|
956
|
+
if (failureStatePersistError) throw failureStatePersistError;
|
|
957
|
+
await turnLifecycle.fail({
|
|
958
|
+
conversationId: args.resume.conversationId,
|
|
959
|
+
turnId: args.resume.turnId,
|
|
960
|
+
createdAtMs: Date.now(),
|
|
961
|
+
failureCode: args.failureCode,
|
|
962
|
+
eventId
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
function buildResumedRun(args, statusSession, delivery) {
|
|
966
|
+
const savedRun = args.run;
|
|
967
|
+
if (!savedRun) throw new TypeError("Slack resume requires a saved Run");
|
|
968
|
+
if (!savedRun.source) throw new TypeError("Slack resume requires a Run Source");
|
|
969
|
+
const source = savedRun.source;
|
|
970
|
+
if (savedRun.destination.platform !== "slack") throw new TypeError("Slack resume requires a Slack Destination");
|
|
971
|
+
const requestDeadline = getTurnRequestDeadline();
|
|
972
|
+
const threadId = args.lockKey ?? getDefaultLockKey(args.channelId, args.threadTs);
|
|
973
|
+
const persistedLocationConfiguration = savedRun.environment?.locationConfiguration ?? (savedRun.environment?.configuration ? createReadOnlyConfigService(savedRun.environment.configuration) : void 0);
|
|
974
|
+
const priorOnEvent = savedRun.onEvent;
|
|
975
|
+
return {
|
|
976
|
+
...savedRun,
|
|
977
|
+
conversationId: args.conversationId,
|
|
978
|
+
turnId: args.turnId,
|
|
979
|
+
instruction: {
|
|
980
|
+
...savedRun.instruction ?? { text: args.messageText },
|
|
981
|
+
text: args.messageText
|
|
982
|
+
},
|
|
983
|
+
source: source.kind === "slack" ? {
|
|
984
|
+
...source,
|
|
985
|
+
channelId: args.channelId,
|
|
986
|
+
...args.threadTs ? { threadTs: args.threadTs } : void 0
|
|
987
|
+
} : source,
|
|
988
|
+
deadlineAtMs: savedRun.deadlineAtMs ?? requestDeadline?.deadlineAtMs,
|
|
989
|
+
environment: {
|
|
990
|
+
...savedRun.environment,
|
|
991
|
+
locationConfiguration: persistedLocationConfiguration
|
|
992
|
+
},
|
|
993
|
+
onEvent: async (event) => {
|
|
994
|
+
if (event.type === "status") statusSession.update({ text: event.text });
|
|
995
|
+
await priorOnEvent?.(event);
|
|
996
|
+
},
|
|
997
|
+
...savedRun.dispatch?.outcomes?.length === 0 ? void 0 : { delivery },
|
|
998
|
+
durability: {
|
|
999
|
+
...savedRun.durability,
|
|
1000
|
+
onSandboxRefChanged: async (sandboxRef) => {
|
|
1001
|
+
await persistThreadStateById(threadId, { sandboxRef });
|
|
1002
|
+
await savedRun.durability?.onSandboxRefChanged?.(sandboxRef);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Resume a paused Slack Turn.
|
|
1009
|
+
*
|
|
1010
|
+
* Queue continuations pass `ownsConversationLease` and skip the second lock
|
|
1011
|
+
* because the worker lease is already held. Authorization callbacks and other
|
|
1012
|
+
* independent resumes still take the thread lock. A started resume owns its
|
|
1013
|
+
* completion work.
|
|
1014
|
+
* Returns false only when `beforeStart` proves the resume is stale before
|
|
1015
|
+
* generation begins.
|
|
1016
|
+
*/
|
|
1017
|
+
async function resumeSlackTurn(args) {
|
|
1018
|
+
const lockKey = args.lockKey ?? getDefaultLockKey(args.channelId, args.threadTs);
|
|
1019
|
+
return withLogContext(getResumeLogContext(args, lockKey), () => resumeSlackTurnInContext(args));
|
|
1020
|
+
}
|
|
1021
|
+
async function resumeSlackTurnInContext(args) {
|
|
1022
|
+
const stateAdapter = getStateAdapter();
|
|
1023
|
+
await stateAdapter.connect();
|
|
1024
|
+
const lockKey = args.lockKey ?? getDefaultLockKey(args.channelId, args.threadTs);
|
|
1025
|
+
const lock = args.ownsConversationLease ? void 0 : await acquireActiveLock(stateAdapter, lockKey);
|
|
1026
|
+
if (!args.ownsConversationLease && !lock) throw new ResumeTurnBusyError(lockKey);
|
|
1027
|
+
const status = createSlackWebApiAssistantStatusSession({
|
|
1028
|
+
channelId: args.channelId,
|
|
1029
|
+
threadTs: args.threadTs
|
|
1030
|
+
});
|
|
1031
|
+
let processingReaction;
|
|
1032
|
+
let deferredAuthInfo;
|
|
1033
|
+
let deferredPauseHandler;
|
|
1034
|
+
let deferredFailureHandler;
|
|
1035
|
+
let runResultHandled = false;
|
|
1036
|
+
let assistantMessageDelivered = false;
|
|
1037
|
+
let shouldScheduleCompletedPluginTasks = false;
|
|
1038
|
+
let postDeliveryCommitError;
|
|
1039
|
+
const turnLifecycle = getTurnLifecycle();
|
|
1040
|
+
let failureCode = "agent_run_failed";
|
|
1041
|
+
let runArgs = args;
|
|
1042
|
+
try {
|
|
1043
|
+
const preparedArgs = await args.beforeStart?.();
|
|
1044
|
+
if (preparedArgs === false) return false;
|
|
1045
|
+
if (preparedArgs) runArgs = {
|
|
1046
|
+
...args,
|
|
1047
|
+
...preparedArgs
|
|
1048
|
+
};
|
|
1049
|
+
setTags(getResumeLogContext(runArgs, lockKey));
|
|
1050
|
+
const savedRun = runArgs.run;
|
|
1051
|
+
if (!savedRun) throw new Error("Resumed Turn requires a saved Run");
|
|
1052
|
+
const credentialContext = savedRun.credentialContext;
|
|
1053
|
+
if (!credentialContext) throw new Error("Resumed Run requires a credential context");
|
|
1054
|
+
const routingActor = savedRun.actor;
|
|
1055
|
+
let resumeActor;
|
|
1056
|
+
if ("type" in credentialContext.actor) {
|
|
1057
|
+
if (!isUserActor(routingActor) || credentialContext.actor.userId !== routingActor.userId) throw new Error("Resumed Run credential actor must match its Actor");
|
|
1058
|
+
resumeActor = routingActor;
|
|
1059
|
+
} else {
|
|
1060
|
+
if (routingActor && (routingActor.platform !== "system" || routingActor.name !== credentialContext.actor.name)) throw new Error("Resumed Run system credential actor must match its Actor");
|
|
1061
|
+
resumeActor = credentialContext.actor;
|
|
1062
|
+
}
|
|
1063
|
+
if (runArgs.messageTs) processingReaction = await startProcessingReactionForMessage({
|
|
1064
|
+
channelId: runArgs.channelId,
|
|
1065
|
+
timestamp: runArgs.messageTs
|
|
1066
|
+
});
|
|
1067
|
+
if (runArgs.initialText) await postSlackMessageBestEffort(runArgs.channelId, runArgs.threadTs, runArgs.initialText);
|
|
1068
|
+
status.update(runArgs.initialStatus);
|
|
1069
|
+
const conversationId = runArgs.conversationId;
|
|
1070
|
+
const visibility = (await getConversationStore().get({ conversationId }))?.visibility;
|
|
1071
|
+
const visibleConversationId = lockKey;
|
|
1072
|
+
const sessionId = runArgs.turnId;
|
|
1073
|
+
let deliveryConversation;
|
|
1074
|
+
let acceptedDeliveryId;
|
|
1075
|
+
/** Load the resumed conversation once for ordered delivery recording. */
|
|
1076
|
+
const getDeliveryConversation = async () => {
|
|
1077
|
+
if (deliveryConversation) {
|
|
1078
|
+
const userMessageId = getTurnUserMessage(deliveryConversation, sessionId)?.id;
|
|
1079
|
+
return {
|
|
1080
|
+
conversation: deliveryConversation,
|
|
1081
|
+
sessionId,
|
|
1082
|
+
...userMessageId ? { userMessageId } : void 0
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
const persistedState = await getPersistedThreadState(visibleConversationId);
|
|
1086
|
+
const conversation = coerceThreadConversationState(persistedState);
|
|
1087
|
+
await hydrateConversationMessages({
|
|
1088
|
+
conversation,
|
|
1089
|
+
conversationId
|
|
1090
|
+
});
|
|
1091
|
+
deliveryConversation = conversation;
|
|
1092
|
+
const userMessageId = getTurnUserMessage(conversation, sessionId)?.id;
|
|
1093
|
+
return {
|
|
1094
|
+
conversation,
|
|
1095
|
+
sessionId,
|
|
1096
|
+
...userMessageId ? { userMessageId } : void 0
|
|
1097
|
+
};
|
|
1098
|
+
};
|
|
1099
|
+
/** Post and record one completed assistant message for the resumed turn. */
|
|
1100
|
+
const deliverAssistantMessage = async (reply) => {
|
|
1101
|
+
const message = typeof reply === "string" ? void 0 : reply;
|
|
1102
|
+
const text = typeof reply === "string" ? reply : getAssistantReplyText(reply);
|
|
1103
|
+
if (!text?.trim()) return;
|
|
1104
|
+
failureCode = "delivery_failed";
|
|
1105
|
+
const deliveryState = await getDeliveryConversation();
|
|
1106
|
+
let slackMessageTs = [];
|
|
1107
|
+
try {
|
|
1108
|
+
const outcomes = runArgs.run?.dispatch?.outcomes;
|
|
1109
|
+
if (outcomes) for (const outcome of outcomes) slackMessageTs.push(...await sendSlackReply({
|
|
1110
|
+
channelId: outcome.destination.channelId,
|
|
1111
|
+
conversationId: runArgs.conversationId,
|
|
1112
|
+
replyAttribution: runArgs.run?.dispatch?.replyAttribution,
|
|
1113
|
+
text
|
|
1114
|
+
}));
|
|
1115
|
+
else slackMessageTs = await sendSlackReply({
|
|
1116
|
+
channelId: runArgs.channelId,
|
|
1117
|
+
conversationId: runArgs.conversationId,
|
|
1118
|
+
replyAttribution: runArgs.run?.dispatch?.replyAttribution,
|
|
1119
|
+
text,
|
|
1120
|
+
threadTs: runArgs.threadTs
|
|
1121
|
+
});
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
if (isRetryableSlackPostError(error)) throw new RetryableDeliveryError(error);
|
|
1124
|
+
throw error;
|
|
1125
|
+
}
|
|
1126
|
+
const messageTs = slackMessageTs.at(-1);
|
|
1127
|
+
assistantMessageDelivered = true;
|
|
1128
|
+
acceptedDeliveryId = messageTs;
|
|
1129
|
+
const recordedMessageId = recordDeliveredAssistantMessage({
|
|
1130
|
+
conversation: deliveryState.conversation,
|
|
1131
|
+
sessionId: deliveryState.sessionId,
|
|
1132
|
+
source: "slack",
|
|
1133
|
+
text,
|
|
1134
|
+
userMessageId: deliveryState.userMessageId
|
|
1135
|
+
});
|
|
1136
|
+
if (messageTs) markConversationMessage(deliveryState.conversation, recordedMessageId, { slackTs: messageTs });
|
|
1137
|
+
try {
|
|
1138
|
+
const destination = runArgs.run?.destination;
|
|
1139
|
+
const providerConversationIds = runArgs.threadTs ? [runArgs.threadTs] : slackMessageTs;
|
|
1140
|
+
await persistWithRetry(() => commitAcceptedReply({
|
|
1141
|
+
...message ? { agentMessage: message } : void 0,
|
|
1142
|
+
conversation: deliveryState.conversation,
|
|
1143
|
+
conversationMessageId: recordedMessageId,
|
|
1144
|
+
conversationId,
|
|
1145
|
+
...runArgs.run?.dispatch && destination?.platform === "slack" && providerConversationIds.length > 0 ? { providerConversationBindings: providerConversationIds.map((providerConversationId) => ({
|
|
1146
|
+
provider: "slack",
|
|
1147
|
+
providerDestinationId: destination.channelId,
|
|
1148
|
+
providerTenantId: destination.teamId,
|
|
1149
|
+
providerConversationId
|
|
1150
|
+
})) } : void 0
|
|
1151
|
+
}));
|
|
1152
|
+
} catch (error) {
|
|
1153
|
+
logException(/* @__PURE__ */ new Error("Accepted assistant message persistence failed"), "slack.resume.assistant_message_post_delivery_persist.failed", { "error.type": error instanceof Error ? error.name : typeof error });
|
|
1154
|
+
}
|
|
1155
|
+
const dispatchId = savedRun.dispatch?.id;
|
|
1156
|
+
if (messageTs && dispatchId) try {
|
|
1157
|
+
await persistWithRetry(() => recordTurnSummary({
|
|
1158
|
+
conversationId: runArgs.conversationId,
|
|
1159
|
+
destination: savedRun.destination,
|
|
1160
|
+
destinationVisibility: visibility,
|
|
1161
|
+
dispatchId,
|
|
1162
|
+
resultMessageId: messageTs,
|
|
1163
|
+
turnId: runArgs.turnId,
|
|
1164
|
+
sliceId: runArgs.sliceId ?? 1,
|
|
1165
|
+
source: savedRun.source,
|
|
1166
|
+
state: "running",
|
|
1167
|
+
surface: savedRun.surface ?? "slack"
|
|
1168
|
+
}));
|
|
1169
|
+
} catch (error) {
|
|
1170
|
+
logException(error, "agent.turn.delivery_receipt_persist.failed");
|
|
1171
|
+
}
|
|
1172
|
+
failureCode = "agent_run_failed";
|
|
1173
|
+
};
|
|
1174
|
+
const deliveryState = await getDeliveryConversation();
|
|
1175
|
+
assistantMessageDelivered = turnHasReply(deliveryState.conversation, sessionId);
|
|
1176
|
+
const run = buildResumedRun(runArgs, status, deliverAssistantMessage);
|
|
1177
|
+
if (runArgs.inputMessageIds?.length) await turnLifecycle.start({
|
|
1178
|
+
conversationId: runArgs.conversationId,
|
|
1179
|
+
turnId: runArgs.turnId,
|
|
1180
|
+
createdAtMs: Date.now(),
|
|
1181
|
+
inputMessageIds: [...new Set(runArgs.inputMessageIds)],
|
|
1182
|
+
surface: "slack"
|
|
1183
|
+
});
|
|
1184
|
+
const replyTimeoutMs = resolveReplyTimeoutMs();
|
|
1185
|
+
const outcome = await runArgs.executeTurn(run, async (result) => {
|
|
1186
|
+
const finalized = finalizeFailedTurnReplyWithEvent({
|
|
1187
|
+
reply: result,
|
|
1188
|
+
logException
|
|
1189
|
+
});
|
|
1190
|
+
const reply = finalized.reply;
|
|
1191
|
+
const dispatchErrorMessage = run.dispatch && reply.diagnostics.outcome !== "success" ? reply.diagnostics.errorMessage ?? `Agent turn ended with ${reply.diagnostics.outcome}.` : void 0;
|
|
1192
|
+
if (reply.diagnostics.outcome !== "success") await deliverAssistantMessage(reply.text);
|
|
1193
|
+
runResultHandled = true;
|
|
1194
|
+
await status.clear();
|
|
1195
|
+
failureCode = "persistence_failed";
|
|
1196
|
+
if (reply.piMessages?.length) await saveTurnCheckpoint({
|
|
1197
|
+
mode: "completed",
|
|
1198
|
+
conversationId: runArgs.conversationId,
|
|
1199
|
+
turnId: runArgs.turnId,
|
|
1200
|
+
messages: reply.piMessages,
|
|
1201
|
+
durationMs: reply.diagnostics.durationMs,
|
|
1202
|
+
usage: reply.diagnostics.usage,
|
|
1203
|
+
destination: run.destination,
|
|
1204
|
+
destinationVisibility: visibility,
|
|
1205
|
+
dispatchId: run.dispatch?.id,
|
|
1206
|
+
dispatchOutcome: reply.diagnostics.outcome === "success" ? "completed" : "failed",
|
|
1207
|
+
...dispatchErrorMessage ? { errorMessage: dispatchErrorMessage } : void 0,
|
|
1208
|
+
...acceptedDeliveryId ? { resultMessageId: acceptedDeliveryId } : void 0,
|
|
1209
|
+
source: run.source,
|
|
1210
|
+
actor: resumeActor,
|
|
1211
|
+
surface: run.surface ?? "slack",
|
|
1212
|
+
sliceId: runArgs.sliceId
|
|
1213
|
+
});
|
|
1214
|
+
else if (run.dispatch?.id) await recordTurnSummary({
|
|
1215
|
+
conversationId: runArgs.conversationId,
|
|
1216
|
+
destination: run.destination,
|
|
1217
|
+
destinationVisibility: visibility,
|
|
1218
|
+
dispatchId: run.dispatch?.id,
|
|
1219
|
+
dispatchOutcome: reply.diagnostics.outcome === "success" ? "completed" : "failed",
|
|
1220
|
+
...acceptedDeliveryId ? { resultMessageId: acceptedDeliveryId } : void 0,
|
|
1221
|
+
turnId: runArgs.turnId,
|
|
1222
|
+
sliceId: runArgs.sliceId ?? 1,
|
|
1223
|
+
source: run.source,
|
|
1224
|
+
state: reply.diagnostics.outcome === "success" ? "completed" : "failed",
|
|
1225
|
+
surface: run.surface ?? "slack"
|
|
1226
|
+
});
|
|
1227
|
+
await runArgs.commitResult?.(reply);
|
|
1228
|
+
if (reply.diagnostics.outcome === "success") {
|
|
1229
|
+
shouldScheduleCompletedPluginTasks = true;
|
|
1230
|
+
return { outcome: assistantMessageDelivered ? "success" : "no_reply" };
|
|
1231
|
+
}
|
|
1232
|
+
return {
|
|
1233
|
+
outcome: "failed",
|
|
1234
|
+
failureCode: "model_execution_failed",
|
|
1235
|
+
...finalized.eventId ? { eventId: finalized.eventId } : void 0,
|
|
1236
|
+
...finalized.failureReason ? { failureReason: finalized.failureReason } : void 0
|
|
1237
|
+
};
|
|
1238
|
+
}, replyTimeoutMs);
|
|
1239
|
+
if (outcome.status !== "completed") {
|
|
1240
|
+
await status.clear();
|
|
1241
|
+
const onAuthPause = runArgs.onAuthPause;
|
|
1242
|
+
const onSuspend = runArgs.onSuspend;
|
|
1243
|
+
if (outcome.status === "awaiting_auth" && onAuthPause) {
|
|
1244
|
+
deferredAuthInfo = {
|
|
1245
|
+
providerDisplayName: outcome.providerDisplayName,
|
|
1246
|
+
actorId: isUserActor(resumeActor) ? resumeActor.userId : void 0,
|
|
1247
|
+
...outcome.requestText ? { requestText: outcome.requestText } : void 0
|
|
1248
|
+
};
|
|
1249
|
+
deferredPauseHandler = async () => {
|
|
1250
|
+
await onAuthPause({ providerDisplayName: outcome.providerDisplayName });
|
|
1251
|
+
};
|
|
1252
|
+
} else if (outcome.status === "suspended") {
|
|
1253
|
+
if (runArgs.ownsConversationLease && (outcome.reason === "timeout" || savedRun.durability?.shouldYield?.())) throw new CooperativeTurnYieldError();
|
|
1254
|
+
if (onSuspend) deferredPauseHandler = async () => {
|
|
1255
|
+
await onSuspend(outcome.resumeVersion);
|
|
1256
|
+
};
|
|
1257
|
+
else deferredFailureHandler = async () => {
|
|
1258
|
+
await handleResumeFailure({
|
|
1259
|
+
error: /* @__PURE__ */ new Error("Resumed Run ended suspended without onSuspend"),
|
|
1260
|
+
eventName: "slack.resume.turn.failed",
|
|
1261
|
+
failureCode: "agent_run_failed",
|
|
1262
|
+
resume: runArgs
|
|
1263
|
+
});
|
|
1264
|
+
};
|
|
1265
|
+
} else deferredFailureHandler = async () => {
|
|
1266
|
+
await handleResumeFailure({
|
|
1267
|
+
error: /* @__PURE__ */ new Error(`Resumed Run ended ${outcome.status} without its required callback`),
|
|
1268
|
+
eventName: "slack.resume.turn.failed",
|
|
1269
|
+
failureCode: "agent_run_failed",
|
|
1270
|
+
resume: runArgs
|
|
1271
|
+
});
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
if (shouldScheduleCompletedPluginTasks) try {
|
|
1275
|
+
const params = {
|
|
1276
|
+
conversationId: runArgs.conversationId,
|
|
1277
|
+
sessionId: runArgs.turnId
|
|
1278
|
+
};
|
|
1279
|
+
await (runArgs.scheduleSessionCompletedPluginTasks ?? scheduleSessionCompletedPluginTasks)(params);
|
|
1280
|
+
} catch (scheduleError) {
|
|
1281
|
+
logException(scheduleError, "plugin.session.completed_task_schedule.failed");
|
|
1282
|
+
}
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
await status.clear();
|
|
1285
|
+
const runError = error instanceof AgentRunError ? error.cause : error;
|
|
1286
|
+
if (isCooperativeTurnYieldError(runError)) {
|
|
1287
|
+
if (lock) await stateAdapter.releaseLock(lock);
|
|
1288
|
+
await processingReaction?.stop();
|
|
1289
|
+
throw runError;
|
|
1290
|
+
}
|
|
1291
|
+
if (runResultHandled) {
|
|
1292
|
+
postDeliveryCommitError = error;
|
|
1293
|
+
try {
|
|
1294
|
+
await runArgs.onPostDeliveryCommitFailure?.(error);
|
|
1295
|
+
} catch (terminalizeError) {
|
|
1296
|
+
logException(terminalizeError, "slack.resume.post_delivery_terminalize.failed");
|
|
1297
|
+
}
|
|
1298
|
+
} else deferredFailureHandler = async () => {
|
|
1299
|
+
await handleResumeFailure({
|
|
1300
|
+
error: runError,
|
|
1301
|
+
eventName: "slack.resume.turn.failed",
|
|
1302
|
+
failureCode,
|
|
1303
|
+
resume: runArgs
|
|
1304
|
+
});
|
|
1305
|
+
};
|
|
1306
|
+
} finally {
|
|
1307
|
+
if (runResultHandled) await processingReaction?.complete();
|
|
1308
|
+
else await processingReaction?.stop();
|
|
1309
|
+
if (lock) await stateAdapter.releaseLock(lock);
|
|
1310
|
+
}
|
|
1311
|
+
if (postDeliveryCommitError) {
|
|
1312
|
+
const eventId = logException(postDeliveryCommitError, "slack.resume.success_handler.failed");
|
|
1313
|
+
await turnLifecycle.fail({
|
|
1314
|
+
conversationId: runArgs.conversationId,
|
|
1315
|
+
turnId: runArgs.turnId,
|
|
1316
|
+
createdAtMs: Date.now(),
|
|
1317
|
+
failureCode: "persistence_failed",
|
|
1318
|
+
...eventId ? { eventId } : void 0
|
|
1319
|
+
});
|
|
1320
|
+
throw postDeliveryCommitError;
|
|
1321
|
+
}
|
|
1322
|
+
if (deferredPauseHandler) try {
|
|
1323
|
+
await deferredPauseHandler();
|
|
1324
|
+
if (deferredAuthInfo) await postSlackMessageBestEffort(runArgs.channelId, runArgs.threadTs, buildAuthPauseResponse(deferredAuthInfo.actorId, deferredAuthInfo.providerDisplayName, deferredAuthInfo.requestText), runArgs.conversationId, runArgs.run?.dispatch?.replyAttribution);
|
|
1325
|
+
return true;
|
|
1326
|
+
} catch (pauseError) {
|
|
1327
|
+
await handleResumeFailure({
|
|
1328
|
+
error: pauseError,
|
|
1329
|
+
eventName: "slack.resume.pause_handler.failed",
|
|
1330
|
+
failureCode: "persistence_failed",
|
|
1331
|
+
resume: runArgs
|
|
1332
|
+
});
|
|
1333
|
+
return true;
|
|
1334
|
+
}
|
|
1335
|
+
if (deferredFailureHandler) await deferredFailureHandler();
|
|
1336
|
+
return true;
|
|
1337
|
+
}
|
|
1338
|
+
//#endregion
|
|
1339
|
+
//#region src/chat/task-execution/turn-wake.ts
|
|
1340
|
+
/** Build the worker input for a paused turn. */
|
|
1341
|
+
async function getPausedTurnRequest(args) {
|
|
1342
|
+
const turn = (await loadTurnCheckpoint({
|
|
1343
|
+
conversationId: args.conversationId,
|
|
1344
|
+
turnId: args.turnId
|
|
1345
|
+
})).record;
|
|
1346
|
+
if (!turn || turn.state !== "paused" || turn.resumeReason !== "timeout" && turn.resumeReason !== "yield" && turn.resumeReason !== "retry" || turn.resumeReason === "timeout" && turn.sliceId < 2) return;
|
|
1347
|
+
let routing;
|
|
1348
|
+
try {
|
|
1349
|
+
routing = await resolveTurnSessionRouting({
|
|
1350
|
+
conversationId: args.conversationId,
|
|
1351
|
+
conversationStore: args.conversationStore
|
|
1352
|
+
});
|
|
1353
|
+
} catch (error) {
|
|
1354
|
+
await failTurnRecord({
|
|
1355
|
+
conversationId: args.conversationId,
|
|
1356
|
+
expectedVersion: turn.version,
|
|
1357
|
+
turnId: args.turnId,
|
|
1358
|
+
errorMessage: error instanceof Error ? error.message : String(error)
|
|
1359
|
+
});
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
return {
|
|
1363
|
+
conversationId: args.conversationId,
|
|
1364
|
+
destination: routing.destination,
|
|
1365
|
+
turnId: args.turnId,
|
|
1366
|
+
expectedVersion: turn.version
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
/** Wake the conversation worker for a paused turn. */
|
|
1370
|
+
async function wakePausedTurn(request, options = {}) {
|
|
1371
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
1372
|
+
await requestConversationWork({
|
|
1373
|
+
conversationId: request.conversationId,
|
|
1374
|
+
destination: request.destination,
|
|
1375
|
+
nowMs,
|
|
1376
|
+
state: options.state
|
|
1377
|
+
});
|
|
1378
|
+
const queue = options.queue ?? getVercelConversationWorkQueue();
|
|
1379
|
+
await ensureConversationWake({
|
|
1380
|
+
conversationId: request.conversationId,
|
|
1381
|
+
idempotencyKey: [
|
|
1382
|
+
"agent-continue",
|
|
1383
|
+
request.conversationId,
|
|
1384
|
+
request.turnId,
|
|
1385
|
+
request.expectedVersion,
|
|
1386
|
+
nowMs
|
|
1387
|
+
].join(":"),
|
|
1388
|
+
nowMs,
|
|
1389
|
+
queue,
|
|
1390
|
+
state: options.state
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
/** Create paused Turn operations for one app. */
|
|
1394
|
+
function createPausedTurns(options = {}) {
|
|
1395
|
+
return {
|
|
1396
|
+
get: (args) => getPausedTurnRequest({
|
|
1397
|
+
...args,
|
|
1398
|
+
conversationStore: options.conversationStore
|
|
1399
|
+
}),
|
|
1400
|
+
wake: (request) => wakePausedTurn(request, options)
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
//#endregion
|
|
1404
|
+
//#region src/handlers/oauth-html.ts
|
|
1405
|
+
/** Build pre-escaped callback HTML with the shared response security policy. */
|
|
1406
|
+
function htmlCallbackResponse(title, message, status, options = {}) {
|
|
1407
|
+
const footerMessage = options.footerMessage ?? `You can close this tab and return to ${botConfig.userName}.`;
|
|
1408
|
+
const isSuccess = status >= 200 && status < 300;
|
|
1409
|
+
const statusClass = isSuccess ? "success" : "error";
|
|
1410
|
+
const statusIcon = isSuccess ? "" : "!";
|
|
1411
|
+
const html = `<!DOCTYPE html>
|
|
1412
|
+
<html lang="en">
|
|
1413
|
+
<head>
|
|
1414
|
+
<meta charset="utf-8">
|
|
1415
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1416
|
+
<meta name="color-scheme" content="light dark">
|
|
1417
|
+
<title>${title}</title>
|
|
1418
|
+
<style>
|
|
1419
|
+
:root {
|
|
1420
|
+
color-scheme: light dark;
|
|
1421
|
+
--canvas: #f4f1fa;
|
|
1422
|
+
--surface: #ffffff;
|
|
1423
|
+
--text: #211a2c;
|
|
1424
|
+
--muted: #6e6479;
|
|
1425
|
+
--border: #ded7e8;
|
|
1426
|
+
--accent: #6c5fc7;
|
|
1427
|
+
--success: #16835c;
|
|
1428
|
+
--success-soft: #ddf5ea;
|
|
1429
|
+
--error: #bc3d50;
|
|
1430
|
+
--error-soft: #fce7eb;
|
|
1431
|
+
--shadow: 0 24px 64px rgba(45, 31, 63, 0.14);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
* { box-sizing: border-box; }
|
|
1435
|
+
|
|
1436
|
+
body {
|
|
1437
|
+
min-height: 100vh;
|
|
1438
|
+
margin: 0;
|
|
1439
|
+
display: grid;
|
|
1440
|
+
place-items: center;
|
|
1441
|
+
padding: 24px;
|
|
1442
|
+
background: var(--canvas);
|
|
1443
|
+
color: var(--text);
|
|
1444
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
main {
|
|
1448
|
+
width: min(100%, 480px);
|
|
1449
|
+
padding: 48px 40px 40px;
|
|
1450
|
+
text-align: center;
|
|
1451
|
+
background: var(--surface);
|
|
1452
|
+
border: 1px solid var(--border);
|
|
1453
|
+
border-radius: 24px;
|
|
1454
|
+
box-shadow: var(--shadow);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
.brand {
|
|
1458
|
+
display: inline-flex;
|
|
1459
|
+
align-items: center;
|
|
1460
|
+
gap: 8px;
|
|
1461
|
+
margin-bottom: 32px;
|
|
1462
|
+
color: var(--muted);
|
|
1463
|
+
font-size: 14px;
|
|
1464
|
+
font-weight: 700;
|
|
1465
|
+
letter-spacing: 0.08em;
|
|
1466
|
+
text-transform: uppercase;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
.brand-mark {
|
|
1470
|
+
width: 10px;
|
|
1471
|
+
height: 10px;
|
|
1472
|
+
background: var(--accent);
|
|
1473
|
+
border-radius: 3px;
|
|
1474
|
+
transform: rotate(12deg);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
.status {
|
|
1478
|
+
width: 64px;
|
|
1479
|
+
height: 64px;
|
|
1480
|
+
margin: 0 auto 24px;
|
|
1481
|
+
display: grid;
|
|
1482
|
+
place-items: center;
|
|
1483
|
+
border-radius: 20px;
|
|
1484
|
+
font-size: 32px;
|
|
1485
|
+
font-weight: 750;
|
|
1486
|
+
line-height: 1;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
.status.success { color: var(--success); background: var(--success-soft); }
|
|
1490
|
+
.status.success::before {
|
|
1491
|
+
content: "";
|
|
1492
|
+
width: 12px;
|
|
1493
|
+
height: 24px;
|
|
1494
|
+
margin-top: -5px;
|
|
1495
|
+
border-right: 4px solid currentColor;
|
|
1496
|
+
border-bottom: 4px solid currentColor;
|
|
1497
|
+
transform: rotate(45deg);
|
|
1498
|
+
}
|
|
1499
|
+
.status.error { color: var(--error); background: var(--error-soft); }
|
|
1500
|
+
|
|
1501
|
+
h1 {
|
|
1502
|
+
margin: 0;
|
|
1503
|
+
font-size: clamp(28px, 7vw, 36px);
|
|
1504
|
+
line-height: 1.12;
|
|
1505
|
+
letter-spacing: -0.035em;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
.message {
|
|
1509
|
+
margin: 16px auto 0;
|
|
1510
|
+
color: var(--muted);
|
|
1511
|
+
font-size: 17px;
|
|
1512
|
+
line-height: 1.6;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
.footer {
|
|
1516
|
+
margin: 32px 0 0;
|
|
1517
|
+
padding-top: 24px;
|
|
1518
|
+
color: var(--muted);
|
|
1519
|
+
border-top: 1px solid var(--border);
|
|
1520
|
+
font-size: 14px;
|
|
1521
|
+
line-height: 1.5;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
@media (prefers-color-scheme: dark) {
|
|
1525
|
+
:root {
|
|
1526
|
+
--canvas: #15121a;
|
|
1527
|
+
--surface: #211c28;
|
|
1528
|
+
--text: #f6f1fb;
|
|
1529
|
+
--muted: #b8adbf;
|
|
1530
|
+
--border: #3a3243;
|
|
1531
|
+
--accent: #a99af0;
|
|
1532
|
+
--success: #72d6ad;
|
|
1533
|
+
--success-soft: #193c30;
|
|
1534
|
+
--error: #ff9aaa;
|
|
1535
|
+
--error-soft: #48262e;
|
|
1536
|
+
--shadow: 0 24px 64px rgba(0, 0, 0, 0.36);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
@media (max-width: 520px) {
|
|
1541
|
+
body { padding: 16px; }
|
|
1542
|
+
main { padding: 40px 24px 32px; border-radius: 20px; }
|
|
1543
|
+
}
|
|
1544
|
+
</style>
|
|
1545
|
+
</head>
|
|
1546
|
+
<body>
|
|
1547
|
+
<main>
|
|
1548
|
+
<div class="brand"><span class="brand-mark" aria-hidden="true"></span>${botConfig.userName}</div>
|
|
1549
|
+
<div class="status ${statusClass}" aria-hidden="true">${statusIcon}</div>
|
|
1550
|
+
<h1>${title}</h1>
|
|
1551
|
+
<p class="message">${message}</p>
|
|
1552
|
+
<p class="footer">${footerMessage}</p>
|
|
1553
|
+
</main>
|
|
1554
|
+
</body>
|
|
1555
|
+
</html>`;
|
|
1556
|
+
return new Response(html, {
|
|
1557
|
+
status,
|
|
1558
|
+
headers: {
|
|
1559
|
+
"Cache-Control": "no-store",
|
|
1560
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
|
|
1561
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1562
|
+
"Referrer-Policy": "no-referrer",
|
|
1563
|
+
"X-Content-Type-Options": "nosniff"
|
|
1564
|
+
}
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
//#endregion
|
|
1568
|
+
//#region src/handlers/mcp-oauth-callback.ts
|
|
1569
|
+
/**
|
|
1570
|
+
* MCP OAuth callback handler.
|
|
1571
|
+
*
|
|
1572
|
+
* Public signed callbacks relay to the owning CLI. The loopback callback
|
|
1573
|
+
* finalizes local MCP authorization and leaves continuation to that CLI.
|
|
1574
|
+
* Slack callbacks finalize authorization, update pending-auth/event-log state,
|
|
1575
|
+
* and resume the exact parked turn. Stale callbacks must not resume newer
|
|
1576
|
+
* thread work after another user message supersedes the paused request.
|
|
1577
|
+
*/
|
|
1578
|
+
function callbackPages(botName) {
|
|
1579
|
+
return {
|
|
1580
|
+
missing_state: {
|
|
1581
|
+
title: "Authorization failed",
|
|
1582
|
+
message: "Missing state parameter.",
|
|
1583
|
+
status: 400
|
|
1584
|
+
},
|
|
1585
|
+
provider_error: {
|
|
1586
|
+
title: "Authorization failed",
|
|
1587
|
+
message: "The provider returned an authorization error.",
|
|
1588
|
+
status: 400
|
|
1589
|
+
},
|
|
1590
|
+
missing_code: {
|
|
1591
|
+
title: "Authorization failed",
|
|
1592
|
+
message: "Missing code parameter.",
|
|
1593
|
+
status: 400
|
|
1594
|
+
},
|
|
1595
|
+
expired: {
|
|
1596
|
+
title: "Authorization expired",
|
|
1597
|
+
message: `This authorization link is no longer active. Return to ${botName} and retry the original request.`,
|
|
1598
|
+
status: 400
|
|
1599
|
+
},
|
|
1600
|
+
success: {
|
|
1601
|
+
title: "Authorization complete",
|
|
1602
|
+
message: `Your MCP access is connected. ${botName} will continue the paused request.`,
|
|
1603
|
+
status: 200
|
|
1604
|
+
},
|
|
1605
|
+
failure: {
|
|
1606
|
+
title: "Authorization failed",
|
|
1607
|
+
message: `${botName} could not finish the authorization callback. Return to ${botName} and retry the original request.`,
|
|
1608
|
+
status: 500
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
var McpOAuthAttemptExpiredError = class extends Error {
|
|
1613
|
+
constructor() {
|
|
1614
|
+
super("MCP OAuth authorization attempt is no longer current");
|
|
1615
|
+
this.name = "McpOAuthAttemptExpiredError";
|
|
1616
|
+
}
|
|
1617
|
+
};
|
|
1618
|
+
function mcpAuthorizationId(args) {
|
|
1619
|
+
return `${args.sessionId}:mcp:${args.provider}`;
|
|
1620
|
+
}
|
|
1621
|
+
function htmlResponse(kind, options) {
|
|
1622
|
+
const botName = botConfig.userName;
|
|
1623
|
+
const pages = callbackPages(botName);
|
|
1624
|
+
const page = kind === "success" && options?.local ? {
|
|
1625
|
+
...pages.success,
|
|
1626
|
+
message: `Your MCP access is connected. ${botName} will continue the paused request in the local client.`
|
|
1627
|
+
} : pages[kind];
|
|
1628
|
+
const footerMessage = kind === "success" && !options?.local ? `You can close this tab and return to ${botName}.` : void 0;
|
|
1629
|
+
return htmlCallbackResponse(page.title, page.message, page.status, { footerMessage });
|
|
1630
|
+
}
|
|
1631
|
+
async function persistCompletedReplyState(channelId, threadTs, sessionId, reply) {
|
|
1632
|
+
const threadId = `slack:${channelId}:${threadTs}`;
|
|
1633
|
+
const currentState = await getPersistedThreadState(threadId);
|
|
1634
|
+
const conversation = coerceThreadConversationState(currentState);
|
|
1635
|
+
await hydrateConversationMessages({
|
|
1636
|
+
conversation,
|
|
1637
|
+
conversationId: threadId
|
|
1638
|
+
});
|
|
1639
|
+
const userMessage = getTurnUserMessage(conversation, sessionId);
|
|
1640
|
+
const statePatch = buildDeliveredTurnStatePatch({
|
|
1641
|
+
conversation,
|
|
1642
|
+
reply,
|
|
1643
|
+
sessionId,
|
|
1644
|
+
userMessageId: userMessage?.id
|
|
1645
|
+
});
|
|
1646
|
+
await persistThreadStateById(threadId, { ...statePatch });
|
|
1647
|
+
}
|
|
1648
|
+
async function failSessionRecordBestEffort$1(args) {
|
|
1649
|
+
try {
|
|
1650
|
+
await failTurnRecord({
|
|
1651
|
+
conversationId: args.conversationId,
|
|
1652
|
+
turnId: args.turnId,
|
|
1653
|
+
errorMessage: args.errorMessage,
|
|
1654
|
+
expectedVersion: args.expectedVersion
|
|
1655
|
+
});
|
|
1656
|
+
} catch (error) {
|
|
1657
|
+
logException(error, "mcp.oauth_callback.session_record.failure_persistence.failed", {
|
|
1658
|
+
"app.ai.conversation_id": args.conversationId,
|
|
1659
|
+
"app.ai.session_id": args.turnId
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
async function persistFailedReplyState(channelId, threadTs, sessionId, expectedVersion) {
|
|
1664
|
+
const threadId = `slack:${channelId}:${threadTs}`;
|
|
1665
|
+
const currentState = await getPersistedThreadState(threadId);
|
|
1666
|
+
const conversation = coerceThreadConversationState(currentState);
|
|
1667
|
+
await hydrateConversationMessages({
|
|
1668
|
+
conversation,
|
|
1669
|
+
conversationId: threadId
|
|
1670
|
+
});
|
|
1671
|
+
clearPendingAuth(conversation, sessionId);
|
|
1672
|
+
markTurnFailed({
|
|
1673
|
+
conversation,
|
|
1674
|
+
nowMs: Date.now(),
|
|
1675
|
+
sessionId,
|
|
1676
|
+
userMessageId: getTurnUserMessageId(conversation, sessionId),
|
|
1677
|
+
markConversationMessage
|
|
1678
|
+
});
|
|
1679
|
+
await failSessionRecordBestEffort$1({
|
|
1680
|
+
conversationId: threadId,
|
|
1681
|
+
turnId: sessionId,
|
|
1682
|
+
errorMessage: "OAuth-resumed MCP turn failed",
|
|
1683
|
+
expectedVersion
|
|
1684
|
+
});
|
|
1685
|
+
await persistThreadStateById(threadId, { conversation });
|
|
1686
|
+
}
|
|
1687
|
+
async function resumeAuthorizedMcpTurn(args) {
|
|
1688
|
+
const { authSession, agentRunner, provider } = args;
|
|
1689
|
+
if (!authSession.channelId || !authSession.destination || !authSession.threadTs) return;
|
|
1690
|
+
const destination = requireSlackDestination(authSession.destination, "MCP OAuth resume");
|
|
1691
|
+
const threadId = `slack:${authSession.channelId}:${authSession.threadTs}`;
|
|
1692
|
+
const currentState = await getPersistedThreadState(threadId);
|
|
1693
|
+
const conversation = coerceThreadConversationState(currentState);
|
|
1694
|
+
await hydrateConversationMessages({
|
|
1695
|
+
conversation,
|
|
1696
|
+
conversationId: threadId
|
|
1697
|
+
});
|
|
1698
|
+
const pendingAuth = getConversationPendingAuth({
|
|
1699
|
+
conversation,
|
|
1700
|
+
kind: "mcp",
|
|
1701
|
+
provider,
|
|
1702
|
+
actorId: authSession.userId
|
|
1703
|
+
});
|
|
1704
|
+
if (pendingAuth?.authSessionId !== authSession.authSessionId) return;
|
|
1705
|
+
const resolvedSessionId = pendingAuth.sessionId;
|
|
1706
|
+
const userMessage = getTurnUserMessage(conversation, resolvedSessionId);
|
|
1707
|
+
if (!isPendingAuthLatestRequest(conversation, pendingAuth)) {
|
|
1708
|
+
clearPendingAuth(conversation, pendingAuth.sessionId);
|
|
1709
|
+
await persistThreadStateById(threadId, { conversation });
|
|
1710
|
+
await abandonTurnRecord({
|
|
1711
|
+
conversationId: authSession.conversationId,
|
|
1712
|
+
turnId: pendingAuth.sessionId,
|
|
1713
|
+
errorMessage: "Auth completed after a newer thread message abandoned this blocked request."
|
|
1714
|
+
});
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
if (!userMessage) return;
|
|
1718
|
+
await resumeSlackTurn({
|
|
1719
|
+
messageText: userMessage.text,
|
|
1720
|
+
conversationId: authSession.conversationId,
|
|
1721
|
+
turnId: resolvedSessionId,
|
|
1722
|
+
channelId: authSession.channelId,
|
|
1723
|
+
threadTs: authSession.threadTs,
|
|
1724
|
+
messageTs: getTurnUserSlackMessageTs(userMessage),
|
|
1725
|
+
lockKey: threadId,
|
|
1726
|
+
initialText: "",
|
|
1727
|
+
executeTurn: async (run, saveResult, timeoutMs) => await executeTurn(agentRunner, run, saveResult, timeoutMs),
|
|
1728
|
+
beforeStart: async () => {
|
|
1729
|
+
const lockedState = await getPersistedThreadState(threadId);
|
|
1730
|
+
const lockedConversation = coerceThreadConversationState(lockedState);
|
|
1731
|
+
await hydrateConversationMessages({
|
|
1732
|
+
conversation: lockedConversation,
|
|
1733
|
+
conversationId: threadId
|
|
1734
|
+
});
|
|
1735
|
+
const lockedPendingAuth = getConversationPendingAuth({
|
|
1736
|
+
conversation: lockedConversation,
|
|
1737
|
+
kind: "mcp",
|
|
1738
|
+
provider,
|
|
1739
|
+
actorId: authSession.userId
|
|
1740
|
+
});
|
|
1741
|
+
if (lockedPendingAuth?.authSessionId !== authSession.authSessionId) return false;
|
|
1742
|
+
const lockedSessionId = lockedPendingAuth.sessionId;
|
|
1743
|
+
if (lockedSessionId !== resolvedSessionId) return false;
|
|
1744
|
+
if (!isPendingAuthLatestRequest(lockedConversation, lockedPendingAuth)) {
|
|
1745
|
+
clearPendingAuth(lockedConversation, lockedPendingAuth.sessionId);
|
|
1746
|
+
await persistThreadStateById(threadId, { conversation: lockedConversation });
|
|
1747
|
+
await abandonTurnRecord({
|
|
1748
|
+
conversationId: authSession.conversationId,
|
|
1749
|
+
turnId: lockedPendingAuth.sessionId,
|
|
1750
|
+
errorMessage: "Auth completed after a newer thread message abandoned this blocked request."
|
|
1751
|
+
});
|
|
1752
|
+
return false;
|
|
1753
|
+
}
|
|
1754
|
+
const lockedUserMessage = getTurnUserMessage(lockedConversation, lockedSessionId);
|
|
1755
|
+
if (!lockedUserMessage) return false;
|
|
1756
|
+
const lockedSessionRecord = await getTurnRecord(authSession.conversationId, lockedSessionId);
|
|
1757
|
+
if (!lockedSessionRecord || lockedSessionRecord.state !== "paused" || lockedSessionRecord.resumeReason !== "auth") return false;
|
|
1758
|
+
const lockedConversationContext = buildConversationContext(lockedConversation, { excludeMessageId: lockedUserMessage.id });
|
|
1759
|
+
const lockedLocationConfiguration = getLocationConfigurationService(destination);
|
|
1760
|
+
let actor;
|
|
1761
|
+
try {
|
|
1762
|
+
actor = createSlackResumeActor({
|
|
1763
|
+
actor: lockedSessionRecord.actor,
|
|
1764
|
+
teamId: destination.teamId,
|
|
1765
|
+
userId: authSession.userId
|
|
1766
|
+
});
|
|
1767
|
+
} catch {
|
|
1768
|
+
await failTurnRecord({
|
|
1769
|
+
conversationId: authSession.conversationId,
|
|
1770
|
+
expectedVersion: lockedSessionRecord.version,
|
|
1771
|
+
turnId: lockedSessionId,
|
|
1772
|
+
errorMessage: "Unable to rebuild Slack actor for OAuth resume"
|
|
1773
|
+
});
|
|
1774
|
+
return false;
|
|
1775
|
+
}
|
|
1776
|
+
let routing;
|
|
1777
|
+
try {
|
|
1778
|
+
routing = await resolveTurnSessionRouting({ conversationId: authSession.conversationId });
|
|
1779
|
+
} catch (error) {
|
|
1780
|
+
await failTurnRecord({
|
|
1781
|
+
conversationId: authSession.conversationId,
|
|
1782
|
+
expectedVersion: lockedSessionRecord.version,
|
|
1783
|
+
turnId: lockedSessionId,
|
|
1784
|
+
errorMessage: error instanceof Error ? error.message : String(error)
|
|
1785
|
+
});
|
|
1786
|
+
return false;
|
|
1787
|
+
}
|
|
1788
|
+
await recordAuthorizationCompleted({
|
|
1789
|
+
conversationId: authSession.conversationId,
|
|
1790
|
+
kind: "mcp",
|
|
1791
|
+
provider,
|
|
1792
|
+
actorId: authSession.userId,
|
|
1793
|
+
authorizationId: mcpAuthorizationId({
|
|
1794
|
+
provider,
|
|
1795
|
+
sessionId: lockedSessionId
|
|
1796
|
+
})
|
|
1797
|
+
});
|
|
1798
|
+
const lockedMessageTs = getTurnUserSlackMessageTs(lockedUserMessage);
|
|
1799
|
+
return {
|
|
1800
|
+
messageText: lockedUserMessage.text,
|
|
1801
|
+
sliceId: lockedSessionRecord.sliceId,
|
|
1802
|
+
messageTs: lockedMessageTs,
|
|
1803
|
+
inputMessageIds: [lockedUserMessage.id],
|
|
1804
|
+
run: {
|
|
1805
|
+
instruction: {
|
|
1806
|
+
text: lockedUserMessage.text,
|
|
1807
|
+
context: lockedConversationContext,
|
|
1808
|
+
...getTurnUserReplyAttachmentContext(lockedUserMessage)
|
|
1809
|
+
},
|
|
1810
|
+
history: await loadProjection({ conversationId: threadId }),
|
|
1811
|
+
credentialContext: { actor: {
|
|
1812
|
+
type: "user",
|
|
1813
|
+
userId: actor.userId
|
|
1814
|
+
} },
|
|
1815
|
+
actor,
|
|
1816
|
+
destination,
|
|
1817
|
+
...routing.location ? { location: routing.location } : void 0,
|
|
1818
|
+
source: routing.source,
|
|
1819
|
+
toolChannelId: authSession.toolChannelId ?? authSession.channelId,
|
|
1820
|
+
environment: {
|
|
1821
|
+
configuration: authSession.configuration,
|
|
1822
|
+
locationConfiguration: lockedLocationConfiguration
|
|
1823
|
+
},
|
|
1824
|
+
state: {
|
|
1825
|
+
pendingAuth: lockedPendingAuth,
|
|
1826
|
+
sandboxRef: getPersistedSandboxState(lockedState)
|
|
1827
|
+
},
|
|
1828
|
+
durability: { recordPendingAuth: async (nextPendingAuth) => {
|
|
1829
|
+
lockedConversation.processing.pendingAuth = nextPendingAuth;
|
|
1830
|
+
await persistThreadStateById(threadId, { conversation: lockedConversation });
|
|
1831
|
+
} }
|
|
1832
|
+
},
|
|
1833
|
+
commitResult: async (reply) => {
|
|
1834
|
+
await persistCompletedReplyState(authSession.channelId, authSession.threadTs, lockedSessionId, reply);
|
|
1835
|
+
},
|
|
1836
|
+
onPostDeliveryCommitFailure: async () => {
|
|
1837
|
+
await failTurnRecord({
|
|
1838
|
+
conversationId: authSession.conversationId,
|
|
1839
|
+
expectedVersion: lockedSessionRecord.version,
|
|
1840
|
+
turnId: lockedSessionId,
|
|
1841
|
+
errorMessage: "OAuth-resumed MCP reply was delivered but completion state did not persist"
|
|
1842
|
+
});
|
|
1843
|
+
},
|
|
1844
|
+
onFailure: async () => {
|
|
1845
|
+
try {
|
|
1846
|
+
await persistFailedReplyState(authSession.channelId, authSession.threadTs, lockedSessionId, lockedSessionRecord.version);
|
|
1847
|
+
} catch (persistError) {
|
|
1848
|
+
logException(persistError, "mcp.oauth_callback.resume_failure_persist.failed", { "app.credential.provider": provider });
|
|
1849
|
+
}
|
|
1850
|
+
},
|
|
1851
|
+
onAuthPause: async () => {
|
|
1852
|
+
await persistAuthPauseTurnState({
|
|
1853
|
+
sessionId: lockedSessionId,
|
|
1854
|
+
threadStateId: threadId
|
|
1855
|
+
});
|
|
1856
|
+
logWarn("mcp.oauth_callback.resume.reparked_for_auth", { "app.credential.provider": provider });
|
|
1857
|
+
},
|
|
1858
|
+
onSuspend: async (resumeVersion) => {
|
|
1859
|
+
await wakePausedTurn({
|
|
1860
|
+
conversationId: authSession.conversationId,
|
|
1861
|
+
destination,
|
|
1862
|
+
turnId: lockedSessionId,
|
|
1863
|
+
expectedVersion: resumeVersion
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
async function isCurrentMcpAuthorizationAttempt(authSession, provider) {
|
|
1871
|
+
const conversationId = mcpConversationId(authSession);
|
|
1872
|
+
if (!conversationId) return false;
|
|
1873
|
+
const currentState = await getPersistedThreadState(conversationId);
|
|
1874
|
+
const conversation = coerceThreadConversationState(currentState);
|
|
1875
|
+
const pendingAuth = getConversationPendingAuth({
|
|
1876
|
+
conversation,
|
|
1877
|
+
kind: "mcp",
|
|
1878
|
+
provider,
|
|
1879
|
+
actorId: authSession.userId
|
|
1880
|
+
});
|
|
1881
|
+
return pendingAuth?.authSessionId === authSession.authSessionId && pendingAuth.sessionId === authSession.sessionId;
|
|
1882
|
+
}
|
|
1883
|
+
function mcpConversationId(authSession) {
|
|
1884
|
+
if (authSession.destination?.platform === "local" || authSession.source?.kind === "web") return authSession.conversationId;
|
|
1885
|
+
if (authSession.channelId && authSession.threadTs) return `slack:${authSession.channelId}:${authSession.threadTs}`;
|
|
1886
|
+
}
|
|
1887
|
+
/** Commit one shared credential mutation only while this attempt owns the thread. */
|
|
1888
|
+
async function runCurrentMcpCredentialMutation(authSession, provider, mutation) {
|
|
1889
|
+
const conversationId = mcpConversationId(authSession);
|
|
1890
|
+
if (!conversationId) throw new McpOAuthAttemptExpiredError();
|
|
1891
|
+
const stateAdapter = getStateAdapter();
|
|
1892
|
+
await stateAdapter.connect();
|
|
1893
|
+
const lock = await acquireActiveLock(stateAdapter, conversationId);
|
|
1894
|
+
if (!lock) throw new Error(`Could not acquire MCP OAuth callback lock for ${conversationId}`);
|
|
1895
|
+
try {
|
|
1896
|
+
if (!await isCurrentMcpAuthorizationAttempt(authSession, provider)) throw new McpOAuthAttemptExpiredError();
|
|
1897
|
+
return await mutation();
|
|
1898
|
+
} finally {
|
|
1899
|
+
await stateAdapter.releaseLock(lock);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
async function GET$1(request, provider, waitUntil, options) {
|
|
1903
|
+
const localRelay = await relayLocalOAuthCallback(request);
|
|
1904
|
+
if (localRelay) return localRelay;
|
|
1905
|
+
const url = new URL(request.url);
|
|
1906
|
+
const state = url.searchParams.get("state")?.trim();
|
|
1907
|
+
const code = url.searchParams.get("code")?.trim();
|
|
1908
|
+
const error = url.searchParams.get("error")?.trim();
|
|
1909
|
+
if (!state) return htmlResponse("missing_state");
|
|
1910
|
+
if (error) {
|
|
1911
|
+
try {
|
|
1912
|
+
const pendingSession = await getMcpAuthSession(state);
|
|
1913
|
+
if (pendingSession && pendingSession.provider === provider) {
|
|
1914
|
+
try {
|
|
1915
|
+
await runCurrentMcpCredentialMutation(pendingSession, provider, async () => {
|
|
1916
|
+
const credentials = await getMcpStoredOAuthCredentials(pendingSession.userId, provider);
|
|
1917
|
+
if (!credentials?.clientInformation && !credentials?.discoveryState) return;
|
|
1918
|
+
const nextCredentials = credentials.tokens ? { tokens: credentials.tokens } : {};
|
|
1919
|
+
await putMcpStoredOAuthCredentials(pendingSession.userId, provider, nextCredentials);
|
|
1920
|
+
});
|
|
1921
|
+
} catch (cleanupError) {
|
|
1922
|
+
if (!(cleanupError instanceof McpOAuthAttemptExpiredError)) logException(cleanupError, "mcp.oauth_callback.provider_error_cleanup.failed", { "app.credential.provider": provider });
|
|
1923
|
+
}
|
|
1924
|
+
await deleteMcpAuthSession(pendingSession.authSessionId);
|
|
1925
|
+
}
|
|
1926
|
+
} catch (cleanupError) {
|
|
1927
|
+
logException(cleanupError, "mcp.oauth_callback.provider_error_cleanup.failed", { "app.credential.provider": provider });
|
|
1928
|
+
}
|
|
1929
|
+
return htmlResponse("provider_error");
|
|
1930
|
+
}
|
|
1931
|
+
if (!code) return htmlResponse("missing_code");
|
|
1932
|
+
try {
|
|
1933
|
+
const pendingSession = await getMcpAuthSession(state);
|
|
1934
|
+
if (!pendingSession || pendingSession.provider !== provider || !await isCurrentMcpAuthorizationAttempt(pendingSession, provider)) {
|
|
1935
|
+
if (pendingSession) await deleteMcpAuthSession(pendingSession.authSessionId);
|
|
1936
|
+
return htmlResponse("expired");
|
|
1937
|
+
}
|
|
1938
|
+
const authSession = await finalizeMcpAuthorization(provider, state, code, async (mutation) => await runCurrentMcpCredentialMutation(pendingSession, provider, mutation));
|
|
1939
|
+
let providerLabel;
|
|
1940
|
+
try {
|
|
1941
|
+
providerLabel = formatProviderLabel(provider);
|
|
1942
|
+
} catch {
|
|
1943
|
+
providerLabel = void 0;
|
|
1944
|
+
}
|
|
1945
|
+
try {
|
|
1946
|
+
await recordAuthenticationLinked({
|
|
1947
|
+
conversationId: authSession.conversationId,
|
|
1948
|
+
provider,
|
|
1949
|
+
actorId: authSession.userId,
|
|
1950
|
+
authorizationId: mcpAuthorizationId({
|
|
1951
|
+
provider,
|
|
1952
|
+
sessionId: authSession.sessionId
|
|
1953
|
+
}),
|
|
1954
|
+
turnId: authSession.sessionId,
|
|
1955
|
+
...providerLabel ? { providerLabel } : void 0
|
|
1956
|
+
});
|
|
1957
|
+
} catch (error) {
|
|
1958
|
+
logException(error, "mcp.oauth_callback.authentication_event.failed", { "app.credential.provider": provider });
|
|
1959
|
+
}
|
|
1960
|
+
try {
|
|
1961
|
+
await deleteMcpAuthSession(authSession.authSessionId);
|
|
1962
|
+
} catch (cleanupError) {
|
|
1963
|
+
logException(cleanupError, "mcp.oauth_callback.session_cleanup.failed", { "app.credential.provider": provider });
|
|
1964
|
+
}
|
|
1965
|
+
if (authSession.source?.kind === "web" && authSession.destination) waitUntil(async () => {
|
|
1966
|
+
const turn = await getTurnRecord(authSession.conversationId, authSession.sessionId);
|
|
1967
|
+
await deleteWebAuthorization({
|
|
1968
|
+
actorId: authSession.userId,
|
|
1969
|
+
conversationId: authSession.conversationId
|
|
1970
|
+
});
|
|
1971
|
+
if (!turn || turn.state !== "paused" || turn.resumeReason !== "auth") return;
|
|
1972
|
+
await recordAuthorizationCompleted({
|
|
1973
|
+
conversationId: authSession.conversationId,
|
|
1974
|
+
kind: "mcp",
|
|
1975
|
+
provider,
|
|
1976
|
+
actorId: authSession.userId,
|
|
1977
|
+
authorizationId: mcpAuthorizationId({
|
|
1978
|
+
provider,
|
|
1979
|
+
sessionId: authSession.sessionId
|
|
1980
|
+
})
|
|
1981
|
+
});
|
|
1982
|
+
await wakePausedTurn({
|
|
1983
|
+
conversationId: authSession.conversationId,
|
|
1984
|
+
destination: authSession.destination,
|
|
1985
|
+
turnId: authSession.sessionId,
|
|
1986
|
+
expectedVersion: turn.version
|
|
1987
|
+
}, options.conversationWorkQueue ? { queue: options.conversationWorkQueue } : void 0);
|
|
1988
|
+
});
|
|
1989
|
+
else if (authSession.destination?.platform !== "local") waitUntil(() => resumeAuthorizedMcpTurn({
|
|
1990
|
+
authSession,
|
|
1991
|
+
agentRunner: options.agentRunner,
|
|
1992
|
+
provider
|
|
1993
|
+
}));
|
|
1994
|
+
return htmlResponse("success", { local: authSession.destination?.platform === "local" && authSession.source?.kind !== "web" });
|
|
1995
|
+
} catch (callbackError) {
|
|
1996
|
+
if (callbackError instanceof McpOAuthAttemptExpiredError) {
|
|
1997
|
+
await deleteMcpAuthSession(state);
|
|
1998
|
+
return htmlResponse("expired");
|
|
1999
|
+
}
|
|
2000
|
+
logException(callbackError, "mcp.oauth_callback.failed", {
|
|
2001
|
+
"app.credential.provider": provider,
|
|
2002
|
+
...getMcpProviderErrorAttributes(callbackError)
|
|
2003
|
+
});
|
|
2004
|
+
return htmlResponse("failure");
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
//#endregion
|
|
2008
|
+
//#region src/chat/slack/app-home.ts
|
|
2009
|
+
const DEFAULT_DESCRIPTION_TEXT = "I help your team investigate, summarize, and act on work in Slack.";
|
|
2010
|
+
const MAX_HOME_SKILLS = 6;
|
|
2011
|
+
const MAX_SECTION_TEXT_CHARS = 3e3;
|
|
2012
|
+
const HIDDEN_HOME_SKILLS = /* @__PURE__ */ new Set(["jr-rpc"]);
|
|
2013
|
+
function clampSectionText(text) {
|
|
2014
|
+
if (text.length <= MAX_SECTION_TEXT_CHARS) return text;
|
|
2015
|
+
return `${text.slice(0, 2999)}…`;
|
|
2016
|
+
}
|
|
2017
|
+
function loadDescriptionText() {
|
|
2018
|
+
const descriptionPath = path.join(homeDir(), "DESCRIPTION.md");
|
|
2019
|
+
try {
|
|
2020
|
+
const raw = fs.readFileSync(descriptionPath, "utf8").trim();
|
|
2021
|
+
if (raw.length > 0) return clampSectionText(raw);
|
|
2022
|
+
} catch {}
|
|
2023
|
+
return DEFAULT_DESCRIPTION_TEXT;
|
|
2024
|
+
}
|
|
2025
|
+
async function buildSkillsSummaryText() {
|
|
2026
|
+
const skills = (await discoverSkills()).filter((skill) => !HIDDEN_HOME_SKILLS.has(skill.name));
|
|
2027
|
+
if (skills.length === 0) return "No skills installed.";
|
|
2028
|
+
const visible = skills.slice(0, MAX_HOME_SKILLS);
|
|
2029
|
+
const lines = visible.map((skill) => `• *${skill.name}* — ${skill.description}`);
|
|
2030
|
+
if (skills.length > visible.length) lines.push(`• …and ${skills.length - visible.length} more`);
|
|
2031
|
+
return lines.join("\n");
|
|
2032
|
+
}
|
|
2033
|
+
function accountLabel(account) {
|
|
2034
|
+
const label = account.label ?? account.id;
|
|
2035
|
+
return account.url ? `<${account.url}|${label}>` : label;
|
|
2036
|
+
}
|
|
2037
|
+
function connectedAccountText(plugin, account) {
|
|
2038
|
+
return account ? `*${plugin.manifest.name}*\nConnected as ${accountLabel(account)}` : `*${plugin.manifest.name}*\n${plugin.manifest.description}`;
|
|
2039
|
+
}
|
|
2040
|
+
async function connectedOAuthTokens(userId, plugin, userTokenStore) {
|
|
2041
|
+
if (plugin.manifest.oauth || plugin.manifest.credentials) {
|
|
2042
|
+
const stored = await userTokenStore.get(userId, plugin.manifest.name);
|
|
2043
|
+
return stored && hasRequiredOAuthScope(stored.scope, plugin.manifest.oauth?.scope) ? stored : void 0;
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
async function hasConnectedMcpAccount(userId, plugin) {
|
|
2047
|
+
if (!plugin.manifest.mcp) return false;
|
|
2048
|
+
const credentials = await getMcpStoredOAuthCredentials(userId, plugin.manifest.name);
|
|
2049
|
+
return Boolean(credentials?.tokens || credentials?.clientInformation || credentials?.discoveryState);
|
|
2050
|
+
}
|
|
2051
|
+
/** Build the Slack App Home tab view showing skills, connected accounts, and version. */
|
|
2052
|
+
async function buildHomeView(userId, userTokenStore) {
|
|
2053
|
+
const runtimeMetadata = getRuntimeMetadata();
|
|
2054
|
+
const descriptionText = loadDescriptionText();
|
|
2055
|
+
const skillsSummaryText = await buildSkillsSummaryText();
|
|
2056
|
+
const providers = pluginCatalogRuntime.getProviders();
|
|
2057
|
+
const connectedSections = [];
|
|
2058
|
+
for (const plugin of providers) {
|
|
2059
|
+
const tokens = await connectedOAuthTokens(userId, plugin, userTokenStore);
|
|
2060
|
+
if (!tokens && !await hasConnectedMcpAccount(userId, plugin)) continue;
|
|
2061
|
+
connectedSections.push({
|
|
2062
|
+
type: "section",
|
|
2063
|
+
text: {
|
|
2064
|
+
type: "mrkdwn",
|
|
2065
|
+
text: connectedAccountText(plugin, tokens?.account)
|
|
2066
|
+
},
|
|
2067
|
+
accessory: {
|
|
2068
|
+
type: "button",
|
|
2069
|
+
text: {
|
|
2070
|
+
type: "plain_text",
|
|
2071
|
+
text: "Unlink"
|
|
2072
|
+
},
|
|
2073
|
+
action_id: "app_home_disconnect",
|
|
2074
|
+
value: plugin.manifest.name,
|
|
2075
|
+
style: "danger"
|
|
2076
|
+
}
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2079
|
+
const accountBlocks = connectedSections.length > 0 ? connectedSections : [{
|
|
2080
|
+
type: "section",
|
|
2081
|
+
text: {
|
|
2082
|
+
type: "mrkdwn",
|
|
2083
|
+
text: "No connected accounts"
|
|
2084
|
+
}
|
|
2085
|
+
}];
|
|
2086
|
+
return {
|
|
2087
|
+
type: "home",
|
|
2088
|
+
blocks: [
|
|
2089
|
+
{
|
|
2090
|
+
type: "header",
|
|
2091
|
+
text: {
|
|
2092
|
+
type: "plain_text",
|
|
2093
|
+
text: "Junior"
|
|
2094
|
+
}
|
|
2095
|
+
},
|
|
2096
|
+
{
|
|
2097
|
+
type: "section",
|
|
2098
|
+
text: {
|
|
2099
|
+
type: "mrkdwn",
|
|
2100
|
+
text: descriptionText
|
|
2101
|
+
}
|
|
2102
|
+
},
|
|
2103
|
+
{ type: "divider" },
|
|
2104
|
+
{
|
|
2105
|
+
type: "header",
|
|
2106
|
+
text: {
|
|
2107
|
+
type: "plain_text",
|
|
2108
|
+
text: "What I can help with"
|
|
2109
|
+
}
|
|
2110
|
+
},
|
|
2111
|
+
{
|
|
2112
|
+
type: "section",
|
|
2113
|
+
text: {
|
|
2114
|
+
type: "mrkdwn",
|
|
2115
|
+
text: skillsSummaryText
|
|
2116
|
+
}
|
|
2117
|
+
},
|
|
2118
|
+
{ type: "divider" },
|
|
2119
|
+
{
|
|
2120
|
+
type: "header",
|
|
2121
|
+
text: {
|
|
2122
|
+
type: "plain_text",
|
|
2123
|
+
text: "Connected accounts"
|
|
2124
|
+
}
|
|
2125
|
+
},
|
|
2126
|
+
...accountBlocks,
|
|
2127
|
+
{
|
|
2128
|
+
type: "context",
|
|
2129
|
+
elements: [{
|
|
2130
|
+
type: "mrkdwn",
|
|
2131
|
+
text: `*junior version:* \`${runtimeMetadata.version ?? "unknown"}\``
|
|
2132
|
+
}]
|
|
2133
|
+
}
|
|
2134
|
+
]
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
/** Publish the App Home view to a specific Slack user. */
|
|
2138
|
+
async function publishAppHomeView(slackClient, userId, userTokenStore) {
|
|
2139
|
+
const view = await buildHomeView(userId, userTokenStore);
|
|
2140
|
+
await slackClient.views.publish({
|
|
2141
|
+
user_id: userId,
|
|
2142
|
+
view
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
//#endregion
|
|
2146
|
+
//#region src/handlers/oauth-callback.ts
|
|
2147
|
+
/**
|
|
2148
|
+
* OAuth callback contract for `@sentry/junior`.
|
|
2149
|
+
*
|
|
2150
|
+
* Providers redirect users to `/api/oauth/callback/:provider`. A public signed
|
|
2151
|
+
* callback relays to the owning CLI; the loopback callback exchanges and stores
|
|
2152
|
+
* the local credential while the CLI owns continuation. Slack callbacks
|
|
2153
|
+
* exchange credentials synchronously, then use `waitUntil(...)` for
|
|
2154
|
+
* best-effort resume side effects.
|
|
2155
|
+
*/
|
|
2156
|
+
function htmlErrorResponse(title, message, status) {
|
|
2157
|
+
return htmlCallbackResponse(escapeXml(title), escapeXml(message), status);
|
|
2158
|
+
}
|
|
2159
|
+
function oauthTokenErrorAttributes(provider, endpoint, status) {
|
|
2160
|
+
return {
|
|
2161
|
+
"app.credential.provider": provider,
|
|
2162
|
+
"app.oauth.error.phase": "token_exchange",
|
|
2163
|
+
"server.address": new URL(endpoint).hostname,
|
|
2164
|
+
...status !== void 0 ? { "http.response.status_code": status } : void 0
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
async function persistCompletedOAuthReplyState(args) {
|
|
2168
|
+
const currentState = await getPersistedThreadState(args.conversationId);
|
|
2169
|
+
const conversation = coerceThreadConversationState(currentState);
|
|
2170
|
+
await hydrateConversationMessages({
|
|
2171
|
+
conversation,
|
|
2172
|
+
conversationId: args.conversationId
|
|
2173
|
+
});
|
|
2174
|
+
const userMessage = getTurnUserMessage(conversation, args.sessionId);
|
|
2175
|
+
const statePatch = buildDeliveredTurnStatePatch({
|
|
2176
|
+
conversation,
|
|
2177
|
+
reply: args.reply,
|
|
2178
|
+
sessionId: args.sessionId,
|
|
2179
|
+
userMessageId: userMessage?.id
|
|
2180
|
+
});
|
|
2181
|
+
await persistThreadStateById(args.conversationId, { ...statePatch });
|
|
2182
|
+
}
|
|
2183
|
+
function pluginAuthorizationId(args) {
|
|
2184
|
+
return `${args.sessionId}:plugin:${args.provider}`;
|
|
2185
|
+
}
|
|
2186
|
+
async function failSessionRecordBestEffort(args) {
|
|
2187
|
+
try {
|
|
2188
|
+
await failTurnRecord({
|
|
2189
|
+
conversationId: args.conversationId,
|
|
2190
|
+
expectedVersion: args.expectedVersion,
|
|
2191
|
+
turnId: args.turnId,
|
|
2192
|
+
errorMessage: args.errorMessage
|
|
2193
|
+
});
|
|
2194
|
+
} catch (error) {
|
|
2195
|
+
logException(error, "oauth.callback.session_record.failure_persistence.failed", {
|
|
2196
|
+
"app.ai.conversation_id": args.conversationId,
|
|
2197
|
+
"app.ai.session_id": args.turnId
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
async function persistFailedOAuthReplyState(args) {
|
|
2202
|
+
const currentState = await getPersistedThreadState(args.conversationId);
|
|
2203
|
+
const conversation = coerceThreadConversationState(currentState);
|
|
2204
|
+
await hydrateConversationMessages({
|
|
2205
|
+
conversation,
|
|
2206
|
+
conversationId: args.conversationId
|
|
2207
|
+
});
|
|
2208
|
+
clearPendingAuth(conversation, args.sessionId);
|
|
2209
|
+
markTurnFailed({
|
|
2210
|
+
conversation,
|
|
2211
|
+
nowMs: Date.now(),
|
|
2212
|
+
sessionId: args.sessionId,
|
|
2213
|
+
userMessageId: getTurnUserMessage(conversation, args.sessionId)?.id,
|
|
2214
|
+
markConversationMessage
|
|
2215
|
+
});
|
|
2216
|
+
await failSessionRecordBestEffort({
|
|
2217
|
+
conversationId: args.conversationId,
|
|
2218
|
+
expectedVersion: args.expectedVersion,
|
|
2219
|
+
turnId: args.sessionId,
|
|
2220
|
+
errorMessage: "OAuth-resumed turn failed"
|
|
2221
|
+
});
|
|
2222
|
+
await persistThreadStateById(args.conversationId, { conversation });
|
|
2223
|
+
}
|
|
2224
|
+
async function resumeOAuthSessionRecordTurn(stored, options) {
|
|
2225
|
+
if (!stored.resumeConversationId || !stored.resumeSessionId || !stored.channelId || !stored.destination || !stored.source || !stored.threadTs) return;
|
|
2226
|
+
const destination = requireSlackDestination(stored.destination, "OAuth resume");
|
|
2227
|
+
const currentState = await getPersistedThreadState(stored.resumeConversationId);
|
|
2228
|
+
const conversation = coerceThreadConversationState(currentState);
|
|
2229
|
+
await hydrateConversationMessages({
|
|
2230
|
+
conversation,
|
|
2231
|
+
conversationId: stored.resumeConversationId
|
|
2232
|
+
});
|
|
2233
|
+
const pendingAuth = getConversationPendingAuth({
|
|
2234
|
+
conversation,
|
|
2235
|
+
kind: "plugin",
|
|
2236
|
+
provider: stored.provider,
|
|
2237
|
+
actorId: stored.userId,
|
|
2238
|
+
...stored.scope ? { scope: stored.scope } : void 0
|
|
2239
|
+
});
|
|
2240
|
+
const resolvedSessionId = pendingAuth?.sessionId ?? stored.resumeSessionId;
|
|
2241
|
+
const userMessage = resolvedSessionId ? getTurnUserMessage(conversation, resolvedSessionId) : void 0;
|
|
2242
|
+
if (pendingAuth) {
|
|
2243
|
+
if (!isPendingAuthLatestRequest(conversation, pendingAuth)) {
|
|
2244
|
+
clearPendingAuth(conversation, pendingAuth.sessionId);
|
|
2245
|
+
await persistThreadStateById(stored.resumeConversationId, { conversation });
|
|
2246
|
+
await abandonTurnRecord({
|
|
2247
|
+
conversationId: stored.resumeConversationId,
|
|
2248
|
+
turnId: pendingAuth.sessionId,
|
|
2249
|
+
errorMessage: "Auth completed after a newer thread message abandoned this blocked request."
|
|
2250
|
+
});
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
const sessionRecord = await getTurnRecord(stored.resumeConversationId, resolvedSessionId);
|
|
2255
|
+
if (!sessionRecord) return;
|
|
2256
|
+
if (sessionRecord.state === "completed" || sessionRecord.state === "failed" || sessionRecord.state === "abandoned") return;
|
|
2257
|
+
if (sessionRecord.state !== "paused" || sessionRecord.resumeReason !== "auth") return;
|
|
2258
|
+
if (!userMessage?.author?.userId) return;
|
|
2259
|
+
if (!pendingAuth && conversation.processing.activeTurnId !== stored.resumeSessionId) return;
|
|
2260
|
+
await resumeSlackTurn({
|
|
2261
|
+
messageText: userMessage.text,
|
|
2262
|
+
conversationId: stored.resumeConversationId,
|
|
2263
|
+
turnId: resolvedSessionId,
|
|
2264
|
+
channelId: stored.channelId,
|
|
2265
|
+
threadTs: stored.threadTs,
|
|
2266
|
+
messageTs: getTurnUserSlackMessageTs(userMessage),
|
|
2267
|
+
lockKey: stored.resumeConversationId,
|
|
2268
|
+
initialText: "",
|
|
2269
|
+
executeTurn: async (run, saveResult, timeoutMs) => await executeTurn(options.agentRunner, run, saveResult, timeoutMs),
|
|
2270
|
+
beforeStart: async () => {
|
|
2271
|
+
const lockedState = await getPersistedThreadState(stored.resumeConversationId);
|
|
2272
|
+
const lockedConversation = coerceThreadConversationState(lockedState);
|
|
2273
|
+
await hydrateConversationMessages({
|
|
2274
|
+
conversation: lockedConversation,
|
|
2275
|
+
conversationId: stored.resumeConversationId
|
|
2276
|
+
});
|
|
2277
|
+
const lockedPendingAuth = getConversationPendingAuth({
|
|
2278
|
+
conversation: lockedConversation,
|
|
2279
|
+
kind: "plugin",
|
|
2280
|
+
provider: stored.provider,
|
|
2281
|
+
actorId: stored.userId,
|
|
2282
|
+
...stored.scope ? { scope: stored.scope } : void 0
|
|
2283
|
+
});
|
|
2284
|
+
const lockedSessionId = lockedPendingAuth?.sessionId ?? stored.resumeSessionId;
|
|
2285
|
+
if (lockedSessionId !== resolvedSessionId) return false;
|
|
2286
|
+
const lockedSessionRecord = await getTurnRecord(stored.resumeConversationId, lockedSessionId);
|
|
2287
|
+
if (!lockedSessionRecord || lockedSessionRecord.state !== "paused" || lockedSessionRecord.resumeReason !== "auth") return false;
|
|
2288
|
+
if (lockedPendingAuth) {
|
|
2289
|
+
if (!isPendingAuthLatestRequest(lockedConversation, lockedPendingAuth)) {
|
|
2290
|
+
clearPendingAuth(lockedConversation, lockedPendingAuth.sessionId);
|
|
2291
|
+
await persistThreadStateById(stored.resumeConversationId, { conversation: lockedConversation });
|
|
2292
|
+
await abandonTurnRecord({
|
|
2293
|
+
conversationId: stored.resumeConversationId,
|
|
2294
|
+
turnId: lockedPendingAuth.sessionId,
|
|
2295
|
+
errorMessage: "Auth completed after a newer thread message abandoned this blocked request."
|
|
2296
|
+
});
|
|
2297
|
+
return false;
|
|
2298
|
+
}
|
|
2299
|
+
} else if (lockedConversation.processing.activeTurnId !== stored.resumeSessionId) return false;
|
|
2300
|
+
const lockedUserMessage = getTurnUserMessage(lockedConversation, lockedSessionId);
|
|
2301
|
+
if (!lockedUserMessage?.author?.userId) return false;
|
|
2302
|
+
const lockedConversationContext = buildConversationContext(lockedConversation, { excludeMessageId: lockedUserMessage.id });
|
|
2303
|
+
const lockedLocationConfiguration = getLocationConfigurationService(destination);
|
|
2304
|
+
let actor;
|
|
2305
|
+
try {
|
|
2306
|
+
actor = createSlackResumeActor({
|
|
2307
|
+
actor: lockedSessionRecord.actor,
|
|
2308
|
+
teamId: destination.teamId,
|
|
2309
|
+
userId: lockedUserMessage.author.userId
|
|
2310
|
+
});
|
|
2311
|
+
} catch {
|
|
2312
|
+
await failTurnRecord({
|
|
2313
|
+
conversationId: stored.resumeConversationId,
|
|
2314
|
+
expectedVersion: lockedSessionRecord.version,
|
|
2315
|
+
turnId: lockedSessionId,
|
|
2316
|
+
errorMessage: "Unable to rebuild Slack actor for OAuth resume"
|
|
2317
|
+
});
|
|
2318
|
+
return false;
|
|
2319
|
+
}
|
|
2320
|
+
let routing;
|
|
2321
|
+
try {
|
|
2322
|
+
routing = await resolveTurnSessionRouting({ conversationId: stored.resumeConversationId });
|
|
2323
|
+
} catch (error) {
|
|
2324
|
+
await failTurnRecord({
|
|
2325
|
+
conversationId: stored.resumeConversationId,
|
|
2326
|
+
expectedVersion: lockedSessionRecord.version,
|
|
2327
|
+
turnId: lockedSessionId,
|
|
2328
|
+
errorMessage: error instanceof Error ? error.message : String(error)
|
|
2329
|
+
});
|
|
2330
|
+
return false;
|
|
2331
|
+
}
|
|
2332
|
+
await recordAuthorizationCompleted({
|
|
2333
|
+
conversationId: stored.resumeConversationId,
|
|
2334
|
+
kind: "plugin",
|
|
2335
|
+
provider: stored.provider,
|
|
2336
|
+
actorId: stored.userId,
|
|
2337
|
+
authorizationId: pluginAuthorizationId({
|
|
2338
|
+
provider: stored.provider,
|
|
2339
|
+
sessionId: lockedSessionId
|
|
2340
|
+
})
|
|
2341
|
+
});
|
|
2342
|
+
const lockedMessageTs = getTurnUserSlackMessageTs(lockedUserMessage);
|
|
2343
|
+
return {
|
|
2344
|
+
messageText: lockedUserMessage.text,
|
|
2345
|
+
sliceId: lockedSessionRecord.sliceId,
|
|
2346
|
+
messageTs: lockedMessageTs,
|
|
2347
|
+
inputMessageIds: [lockedUserMessage.id],
|
|
2348
|
+
run: {
|
|
2349
|
+
instruction: {
|
|
2350
|
+
text: lockedUserMessage.text,
|
|
2351
|
+
context: lockedConversationContext,
|
|
2352
|
+
...getTurnUserReplyAttachmentContext(lockedUserMessage)
|
|
2353
|
+
},
|
|
2354
|
+
history: await loadProjection({ conversationId: stored.resumeConversationId }),
|
|
2355
|
+
credentialContext: { actor: {
|
|
2356
|
+
type: "user",
|
|
2357
|
+
userId: actor.userId
|
|
2358
|
+
} },
|
|
2359
|
+
actor,
|
|
2360
|
+
destination,
|
|
2361
|
+
...routing.location ? { location: routing.location } : void 0,
|
|
2362
|
+
source: routing.source,
|
|
2363
|
+
toolChannelId: stored.channelId,
|
|
2364
|
+
environment: { locationConfiguration: lockedLocationConfiguration },
|
|
2365
|
+
state: {
|
|
2366
|
+
pendingAuth: lockedPendingAuth,
|
|
2367
|
+
sandboxRef: getPersistedSandboxState(lockedState)
|
|
2368
|
+
},
|
|
2369
|
+
durability: { recordPendingAuth: async (nextPendingAuth) => {
|
|
2370
|
+
lockedConversation.processing.pendingAuth = nextPendingAuth;
|
|
2371
|
+
await persistThreadStateById(stored.resumeConversationId, { conversation: lockedConversation });
|
|
2372
|
+
} }
|
|
2373
|
+
},
|
|
2374
|
+
commitResult: async (reply) => {
|
|
2375
|
+
logInfo("oauth.callback.resume.completed", {
|
|
2376
|
+
"app.credential.provider": stored.provider,
|
|
2377
|
+
"app.ai.outcome": reply.diagnostics.outcome,
|
|
2378
|
+
"app.ai.tool_calls": reply.diagnostics.toolCalls.length
|
|
2379
|
+
});
|
|
2380
|
+
await persistCompletedOAuthReplyState({
|
|
2381
|
+
conversationId: stored.resumeConversationId,
|
|
2382
|
+
sessionId: lockedSessionId,
|
|
2383
|
+
reply
|
|
2384
|
+
});
|
|
2385
|
+
},
|
|
2386
|
+
onPostDeliveryCommitFailure: async () => {
|
|
2387
|
+
await failTurnRecord({
|
|
2388
|
+
conversationId: stored.resumeConversationId,
|
|
2389
|
+
expectedVersion: lockedSessionRecord.version,
|
|
2390
|
+
turnId: lockedSessionId,
|
|
2391
|
+
errorMessage: "OAuth-resumed reply was delivered but completion state did not persist"
|
|
2392
|
+
});
|
|
2393
|
+
},
|
|
2394
|
+
onFailure: async () => {
|
|
2395
|
+
await persistFailedOAuthReplyState({
|
|
2396
|
+
conversationId: stored.resumeConversationId,
|
|
2397
|
+
expectedVersion: lockedSessionRecord.version,
|
|
2398
|
+
sessionId: lockedSessionId
|
|
2399
|
+
});
|
|
2400
|
+
},
|
|
2401
|
+
onAuthPause: async () => {
|
|
2402
|
+
await persistAuthPauseTurnState({
|
|
2403
|
+
sessionId: lockedSessionId,
|
|
2404
|
+
threadStateId: stored.resumeConversationId
|
|
2405
|
+
});
|
|
2406
|
+
},
|
|
2407
|
+
onSuspend: async (resumeVersion) => {
|
|
2408
|
+
await wakePausedTurn({
|
|
2409
|
+
conversationId: stored.resumeConversationId,
|
|
2410
|
+
destination,
|
|
2411
|
+
turnId: lockedSessionId,
|
|
2412
|
+
expectedVersion: resumeVersion
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
async function GET(request, provider, waitUntil, options) {
|
|
2420
|
+
const localRelay = await relayLocalOAuthCallback(request);
|
|
2421
|
+
if (localRelay) return localRelay;
|
|
2422
|
+
const providerConfig = pluginCatalogRuntime.getOAuthConfig(provider);
|
|
2423
|
+
if (!providerConfig) return htmlErrorResponse("Unknown provider", "The OAuth provider in this link is not recognized.", 404);
|
|
2424
|
+
const providerLabel = formatProviderLabel(provider);
|
|
2425
|
+
const url = new URL(request.url);
|
|
2426
|
+
const errorParam = url.searchParams.get("error");
|
|
2427
|
+
const code = url.searchParams.get("code");
|
|
2428
|
+
const state = url.searchParams.get("state");
|
|
2429
|
+
if (errorParam) {
|
|
2430
|
+
if (state) await getStateAdapter().delete(`oauth-state:${state}`);
|
|
2431
|
+
if (errorParam === "access_denied") return htmlErrorResponse("Authorization declined", `You declined the ${providerLabel} authorization request. Return to Junior and connect your ${providerLabel} account again if you change your mind.`, 400);
|
|
2432
|
+
return htmlErrorResponse("Authorization failed", `${providerLabel} returned an error: ${errorParam}. Return to Junior and try again.`, 400);
|
|
2433
|
+
}
|
|
2434
|
+
if (!code || !state) return htmlErrorResponse("Invalid request", "This authorization link is missing required parameters.", 400);
|
|
2435
|
+
const stateAdapter = getStateAdapter();
|
|
2436
|
+
const stateKey = `oauth-state:${state}`;
|
|
2437
|
+
const stored = parseOAuthStatePayload(await stateAdapter.get(stateKey));
|
|
2438
|
+
if (!stored) return htmlErrorResponse("Link expired", `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.`, 400);
|
|
2439
|
+
if (stored.provider !== provider) return htmlErrorResponse("Provider mismatch", "This authorization link does not match the expected provider.", 400);
|
|
2440
|
+
await stateAdapter.delete(stateKey);
|
|
2441
|
+
const clientId = process.env[providerConfig.clientIdEnv]?.trim();
|
|
2442
|
+
const clientSecret = process.env[providerConfig.clientSecretEnv]?.trim();
|
|
2443
|
+
if (!clientId || !clientSecret) return htmlErrorResponse("Configuration error", "OAuth client credentials are not configured on the server.", 500);
|
|
2444
|
+
const baseUrl = resolveBaseUrl();
|
|
2445
|
+
if (!baseUrl) return htmlErrorResponse("Configuration error", "The server cannot determine its base URL.", 500);
|
|
2446
|
+
const redirectUri = `${baseUrl}${providerConfig.callbackPath}`;
|
|
2447
|
+
const requestedScope = stored.scope ?? providerConfig.scope;
|
|
2448
|
+
let tokenResponse;
|
|
2449
|
+
try {
|
|
2450
|
+
const tokenRequest = buildOAuthTokenRequest({
|
|
2451
|
+
clientId,
|
|
2452
|
+
clientSecret,
|
|
2453
|
+
payload: {
|
|
2454
|
+
grant_type: "authorization_code",
|
|
2455
|
+
code,
|
|
2456
|
+
redirect_uri: redirectUri
|
|
2457
|
+
},
|
|
2458
|
+
tokenAuthMethod: providerConfig.tokenAuthMethod,
|
|
2459
|
+
tokenExtraHeaders: providerConfig.tokenExtraHeaders
|
|
2460
|
+
});
|
|
2461
|
+
tokenResponse = await fetch(providerConfig.tokenEndpoint, {
|
|
2462
|
+
method: "POST",
|
|
2463
|
+
headers: tokenRequest.headers,
|
|
2464
|
+
body: tokenRequest.body
|
|
2465
|
+
});
|
|
2466
|
+
} catch {
|
|
2467
|
+
logWarn("oauth.token_exchange.failed", oauthTokenErrorAttributes(provider, providerConfig.tokenEndpoint));
|
|
2468
|
+
return htmlErrorResponse("Connection failed", "Failed to exchange the authorization code. Please try again.", 500);
|
|
2469
|
+
}
|
|
2470
|
+
if (!tokenResponse.ok) {
|
|
2471
|
+
logWarn("oauth.token_exchange.failed", oauthTokenErrorAttributes(provider, providerConfig.tokenEndpoint, tokenResponse.status));
|
|
2472
|
+
return htmlErrorResponse("Connection failed", "The token exchange with the provider failed. Please try again.", 500);
|
|
2473
|
+
}
|
|
2474
|
+
let parsedTokenResponse;
|
|
2475
|
+
try {
|
|
2476
|
+
const tokenData = await tokenResponse.json();
|
|
2477
|
+
parsedTokenResponse = parseOAuthTokenResponse(tokenData, requestedScope, { treatEmptyScopeAsUnreported: providerConfig.treatEmptyScopeAsUnreported });
|
|
2478
|
+
} catch {
|
|
2479
|
+
logWarn("oauth.token_exchange.failed", oauthTokenErrorAttributes(provider, providerConfig.tokenEndpoint, tokenResponse.status));
|
|
2480
|
+
return htmlErrorResponse("Connection failed", "The provider returned an incomplete token response. Please try again.", 500);
|
|
2481
|
+
}
|
|
2482
|
+
if (!hasRequiredOAuthScope(parsedTokenResponse.scope, requestedScope)) return htmlErrorResponse("Connection failed", `The ${providerLabel} authorization did not grant the access Junior requires. Return to Junior and connect your ${providerLabel} account again.`, 400);
|
|
2483
|
+
const userTokenStore = createUserTokenStore();
|
|
2484
|
+
let account;
|
|
2485
|
+
try {
|
|
2486
|
+
account = await resolvePluginOAuthAccount({
|
|
2487
|
+
provider,
|
|
2488
|
+
tokens: parsedTokenResponse
|
|
2489
|
+
});
|
|
2490
|
+
} catch {
|
|
2491
|
+
return htmlErrorResponse("Connection failed", `Junior could not verify the connected ${providerLabel} account. Please try again.`, 500);
|
|
2492
|
+
}
|
|
2493
|
+
await userTokenStore.set(stored.userId, provider, {
|
|
2494
|
+
...parsedTokenResponse,
|
|
2495
|
+
...account ? { account } : void 0
|
|
2496
|
+
});
|
|
2497
|
+
const slackActor = stored.actor?.platform === "slack" ? stored.actor : void 0;
|
|
2498
|
+
if (account && slackActor) await runBestEffort(async () => {
|
|
2499
|
+
const slackUserId = parseSlackUserId(slackActor.userId);
|
|
2500
|
+
if (!slackUserId) throw new Error("OAuth Slack actor user id is invalid");
|
|
2501
|
+
const profile = await lookupSlackUserProfile(slackUserId);
|
|
2502
|
+
const slackIdentity = await upsertIdentity(getSqlExecutor(), {
|
|
2503
|
+
kind: "user",
|
|
2504
|
+
provider: "slack",
|
|
2505
|
+
providerTenantId: slackActor.teamId,
|
|
2506
|
+
providerSubjectId: profile.id,
|
|
2507
|
+
displayName: profile.real_name || profile.display_name,
|
|
2508
|
+
handle: profile.name,
|
|
2509
|
+
...profile.email ? {
|
|
2510
|
+
email: profile.email,
|
|
2511
|
+
emailVerified: true
|
|
2512
|
+
} : void 0
|
|
2513
|
+
});
|
|
2514
|
+
if (!slackIdentity.userId) throw new Error("OAuth Slack identity is not linked to a user");
|
|
2515
|
+
await upsertLinkedIdentity(getSqlExecutor(), slackIdentity.userId, {
|
|
2516
|
+
kind: "user",
|
|
2517
|
+
provider,
|
|
2518
|
+
providerSubjectId: account.id,
|
|
2519
|
+
displayName: account.displayName,
|
|
2520
|
+
handle: account.handle ?? account.label
|
|
2521
|
+
});
|
|
2522
|
+
}, "oauth.callback.identity_link.failed", { "app.credential.provider": provider });
|
|
2523
|
+
const resumeConversationId = stored.resumeConversationId;
|
|
2524
|
+
if (resumeConversationId) await runBestEffort(async () => {
|
|
2525
|
+
await recordAuthenticationLinked({
|
|
2526
|
+
conversationId: resumeConversationId,
|
|
2527
|
+
provider,
|
|
2528
|
+
actorId: stored.userId,
|
|
2529
|
+
providerLabel,
|
|
2530
|
+
...account?.label ? { accountLabel: account.label } : void 0,
|
|
2531
|
+
...stored.resumeSessionId ? {
|
|
2532
|
+
authorizationId: pluginAuthorizationId({
|
|
2533
|
+
provider,
|
|
2534
|
+
sessionId: stored.resumeSessionId
|
|
2535
|
+
}),
|
|
2536
|
+
turnId: stored.resumeSessionId
|
|
2537
|
+
} : void 0
|
|
2538
|
+
});
|
|
2539
|
+
}, "oauth.callback.authentication_event.failed", { "app.credential.provider": provider });
|
|
2540
|
+
if (stored.destination?.platform !== "local" && stored.source?.kind !== "web") waitUntil(async () => {
|
|
2541
|
+
try {
|
|
2542
|
+
await publishAppHomeView(getSlackClient(), stored.userId, userTokenStore);
|
|
2543
|
+
} catch {}
|
|
2544
|
+
});
|
|
2545
|
+
const resumesWebTurn = Boolean(stored.source?.kind === "web" && stored.destination && stored.resumeConversationId && stored.resumeSessionId);
|
|
2546
|
+
const resumesAgentTurn = Boolean(stored.destination?.platform !== "local" && stored.resumeConversationId && stored.resumeSessionId);
|
|
2547
|
+
if (resumesWebTurn) waitUntil(async () => {
|
|
2548
|
+
const turn = await getTurnRecord(stored.resumeConversationId, stored.resumeSessionId);
|
|
2549
|
+
await deleteWebAuthorization({
|
|
2550
|
+
actorId: stored.userId,
|
|
2551
|
+
conversationId: stored.resumeConversationId
|
|
2552
|
+
});
|
|
2553
|
+
if (!turn || turn.state !== "paused" || turn.resumeReason !== "auth") return;
|
|
2554
|
+
await recordAuthorizationCompleted({
|
|
2555
|
+
conversationId: stored.resumeConversationId,
|
|
2556
|
+
kind: "plugin",
|
|
2557
|
+
provider: stored.provider,
|
|
2558
|
+
actorId: stored.userId,
|
|
2559
|
+
authorizationId: pluginAuthorizationId({
|
|
2560
|
+
provider: stored.provider,
|
|
2561
|
+
sessionId: stored.resumeSessionId
|
|
2562
|
+
})
|
|
2563
|
+
});
|
|
2564
|
+
await wakePausedTurn({
|
|
2565
|
+
conversationId: stored.resumeConversationId,
|
|
2566
|
+
destination: stored.destination,
|
|
2567
|
+
turnId: stored.resumeSessionId,
|
|
2568
|
+
expectedVersion: turn.version
|
|
2569
|
+
}, options.conversationWorkQueue ? { queue: options.conversationWorkQueue } : void 0);
|
|
2570
|
+
});
|
|
2571
|
+
else if (resumesAgentTurn) waitUntil(() => withLogContext({ conversationId: stored.resumeConversationId }, async () => {
|
|
2572
|
+
try {
|
|
2573
|
+
await resumeOAuthSessionRecordTurn(stored, options);
|
|
2574
|
+
} catch (error) {
|
|
2575
|
+
if (error instanceof ResumeTurnBusyError) {
|
|
2576
|
+
logWarn("oauth.callback.resume.busy", {
|
|
2577
|
+
"app.credential.provider": stored.provider,
|
|
2578
|
+
...stored.resumeSessionId && { "app.ai.session_id": stored.resumeSessionId }
|
|
2579
|
+
});
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
throw error;
|
|
2583
|
+
}
|
|
2584
|
+
}));
|
|
2585
|
+
else if (stored.channelId && stored.threadTs) {
|
|
2586
|
+
const { channelId, threadTs } = stored;
|
|
2587
|
+
waitUntil(() => postSlackMessage({
|
|
2588
|
+
channelId,
|
|
2589
|
+
threadTs,
|
|
2590
|
+
text: `Your ${providerLabel} account is now connected. You can start using ${providerLabel} commands.`
|
|
2591
|
+
}));
|
|
2592
|
+
}
|
|
2593
|
+
const botName = botConfig.userName;
|
|
2594
|
+
const statusMessage = stored.destination?.platform === "local" && !resumesWebTurn ? `Your request is continuing in the local ${botName} client.` : resumesWebTurn ? `Your request is continuing in ${botName}.` : resumesAgentTurn ? "Your request is being processed in Slack." : `Your ${providerLabel} account is connected.`;
|
|
2595
|
+
const footerMessage = stored.destination?.platform === "local" || resumesWebTurn ? `You can close this tab and return to ${botName}.` : "You can close this tab and return to Slack.";
|
|
2596
|
+
return htmlCallbackResponse(escapeXml(`${providerLabel} account connected`), escapeXml(statusMessage), 200, { footerMessage: escapeXml(footerMessage) });
|
|
2597
|
+
}
|
|
2598
|
+
//#endregion
|
|
2599
|
+
//#region src/chat/local/credential-sync.ts
|
|
2600
|
+
const LOCAL_CREDENTIAL_SYNC_CONTEXT = "junior.local-credential-sync.v1";
|
|
2601
|
+
const LOCAL_CREDENTIAL_SYNC_MAX_AGE_MS = 6e4;
|
|
2602
|
+
const localCredentialSyncSchema = z.object({
|
|
2603
|
+
createdAtMs: z.number().finite(),
|
|
2604
|
+
provider: z.string().regex(/^[a-z][a-z0-9-]*$/),
|
|
2605
|
+
tokens: pluginStoredTokensSchema
|
|
2606
|
+
}).strict();
|
|
2607
|
+
function syncSecret() {
|
|
2608
|
+
const secret = process.env.JUNIOR_SECRET?.trim();
|
|
2609
|
+
if (!secret) throw new Error("Local credential sync requires JUNIOR_SECRET");
|
|
2610
|
+
return secret;
|
|
2611
|
+
}
|
|
2612
|
+
/** Sign one local credential payload in its own HMAC domain. */
|
|
2613
|
+
function signature(body) {
|
|
2614
|
+
const signingKey = createSecretKey(Buffer.from(syncSecret(), "utf8"));
|
|
2615
|
+
return createHmac("sha256", signingKey).update(`${LOCAL_CREDENTIAL_SYNC_CONTEXT}:${body}`).digest("base64url");
|
|
2616
|
+
}
|
|
2617
|
+
/** Compare credential signatures without leaking a matching prefix by timing. */
|
|
2618
|
+
function signaturesMatch(expected, actual) {
|
|
2619
|
+
const expectedBuffer = Buffer.from(expected);
|
|
2620
|
+
const actualBuffer = Buffer.from(actual);
|
|
2621
|
+
return expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer);
|
|
2622
|
+
}
|
|
2623
|
+
/** Copy a local user's provider credential to the loopback dev-server broker. */
|
|
2624
|
+
async function syncLocalOAuthCredential(provider, tokens) {
|
|
2625
|
+
const body = JSON.stringify({
|
|
2626
|
+
createdAtMs: Date.now(),
|
|
2627
|
+
provider,
|
|
2628
|
+
tokens
|
|
2629
|
+
});
|
|
2630
|
+
const port = process.env.PORT?.trim() || "3000";
|
|
2631
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/internal/local-oauth-credentials`, {
|
|
2632
|
+
method: "POST",
|
|
2633
|
+
headers: {
|
|
2634
|
+
"content-type": "application/json",
|
|
2635
|
+
"x-junior-local-credential-signature": signature(body)
|
|
2636
|
+
},
|
|
2637
|
+
body
|
|
2638
|
+
});
|
|
2639
|
+
if (!response.ok) throw new Error(`Could not sync local OAuth credential: HTTP ${response.status}`);
|
|
2640
|
+
}
|
|
2641
|
+
/** Verify and store one credential sent by the local CLI over loopback. */
|
|
2642
|
+
async function receiveLocalOAuthCredential(request) {
|
|
2643
|
+
const hostname = new URL(request.url).hostname;
|
|
2644
|
+
if (process.env.NODE_ENV !== "development" || hostname !== "127.0.0.1" && hostname !== "localhost") return new Response(null, { status: 404 });
|
|
2645
|
+
const body = await request.text();
|
|
2646
|
+
const actualSignature = request.headers.get("x-junior-local-credential-signature");
|
|
2647
|
+
if (!actualSignature || !signaturesMatch(signature(body), actualSignature)) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
2648
|
+
let value;
|
|
2649
|
+
try {
|
|
2650
|
+
value = JSON.parse(body);
|
|
2651
|
+
} catch {
|
|
2652
|
+
return Response.json({ error: "Invalid request" }, { status: 400 });
|
|
2653
|
+
}
|
|
2654
|
+
const payload = localCredentialSyncSchema.safeParse(value);
|
|
2655
|
+
if (!payload.success || Math.abs(Date.now() - payload.data.createdAtMs) > LOCAL_CREDENTIAL_SYNC_MAX_AGE_MS) return Response.json({ error: "Invalid request" }, { status: 400 });
|
|
2656
|
+
await createUserTokenStore().set("local-cli", payload.data.provider, payload.data.tokens);
|
|
2657
|
+
return new Response(null, { status: 204 });
|
|
2658
|
+
}
|
|
2659
|
+
//#endregion
|
|
2660
|
+
export { isSlackTitlePermissionError as C, createSlackAdapterAssistantStatusSession as D, splitSlackReplyText as E, isRetryableSlackPostError as S, buildSlackOutputMessage as T, getThreadId as _, GET$1 as a, getSlackApiErrorCode as b, wakePausedTurn as c, shouldKeepProcessingReactionForToolInvocation as d, startProcessingReaction as f, getRunId as g, getMessageTs as h, publishAppHomeView as i, resumeSlackTurn as l, getChannelId as m, syncLocalOAuthCredential as n, createPausedTurns as o, getAssistantThreadContext as p, GET as r, getPausedTurnRequest as s, receiveLocalOAuthCredential as t, buildAuthPauseResponse as u, getThreadTs as v, sendSlackReply as w, getSlackErrorObservabilityAttributes as x, stripLeadingBotMention as y };
|