@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,4040 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-B4FMCO8f.js";
|
|
2
|
+
import { _ as completeObject, i as getChatConfig, n as botConfig, o as getSlackBotToken, y as embedTexts } from "./config-nGCW1OTG.js";
|
|
3
|
+
import { C as withSpan, P as normalizeIdentityEmail, _ as setSpanAttributes, d as logInfo, f as logWarn, u as logException, v as setSpanStatus } from "./logging-BzAk1S_s.js";
|
|
4
|
+
import { n as defaultModelId } from "./model-profile-CZmVoGdX.js";
|
|
5
|
+
import { _ as parseSlackMessageTs, c as isActorUserId, h as parseSlackTeamId, m as parseSlackChannelReferenceId, u as parseActorUserId } from "./context-B45_WqVX.js";
|
|
6
|
+
import { n as juniorDestinations } from "./destinations-BeBiJeEM.js";
|
|
7
|
+
import { C as juniorConversations, S as conversationReadColumns, T as juniorUsers, b as juniorCodeRepositories, g as juniorConversationEvents, h as juniorConversationBindings, i as juniorWorkspaces, m as juniorConversationParticipants, n as juniorSnapshots, r as juniorWorkspaceRepos, t as createJuniorSqlExecutor, v as juniorConversationAnnotations, w as juniorIdentities, y as juniorCodeChanges } from "./executor-DcMSlI5c.js";
|
|
8
|
+
import { c as newConversationEventSchema, o as decodeStoredConversationEvent, r as conversationEventDataSchema, s as historyReplacementSchema } from "./history-CqTuSAdI.js";
|
|
9
|
+
import { A as sleep$1, P as SANDBOX_WORKSPACE_ROOT, l as isSandboxMissingError, n as hash, v as getVercelSandboxCredentials, w as getStateAdapter, x as runNonInteractiveCommand } from "./profile-CrT5nXbw.js";
|
|
10
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
13
|
+
import { and, asc, desc, eq, gt, gte, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
|
|
14
|
+
import { codeChangeInputSchema, conversationAnnotationInputSchema, destinationSchema, identitySchema, locationSchema, missingToolAnnotationKeys, normalizeResourceEventIdentifier, pluginResourceEventsSchema, promptContextSchema, promptMessageSchema, resourceEventInputSchema, resourceEventMatchSchema, resourceEventMatches, resourceEventTypeSchema, sourceSchema, stableResourceEventMatchKey, userSchema } from "@sentry/junior-plugin-api";
|
|
15
|
+
import { WebClient } from "@slack/web-api";
|
|
16
|
+
import { Sandbox } from "@vercel/sandbox";
|
|
17
|
+
//#region src/chat/destination.ts
|
|
18
|
+
/** Build Junior's canonical destination from Slack workspace and channel ids. */
|
|
19
|
+
function createSlackDestination(input) {
|
|
20
|
+
const channelId = parseSlackChannelReferenceId(input.channelId);
|
|
21
|
+
const teamId = parseSlackTeamId(input.teamId);
|
|
22
|
+
if (!channelId || !teamId) return;
|
|
23
|
+
return {
|
|
24
|
+
platform: "slack",
|
|
25
|
+
teamId,
|
|
26
|
+
channelId
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Parse and validate a serialized destination that crossed a runtime boundary. */
|
|
30
|
+
function parseDestination(value) {
|
|
31
|
+
const parsed = destinationSchema.safeParse(value);
|
|
32
|
+
return parsed.success ? parsed.data : void 0;
|
|
33
|
+
}
|
|
34
|
+
/** Require a Slack destination at a Slack-only runtime boundary. */
|
|
35
|
+
function requireSlackDestination(destination, action) {
|
|
36
|
+
if (destination?.platform === "slack") return destination;
|
|
37
|
+
throw new Error(`${action} requires a Slack destination`);
|
|
38
|
+
}
|
|
39
|
+
/** Compare two destinations without relying on object identity. */
|
|
40
|
+
function sameDestination(left, right) {
|
|
41
|
+
if (left.platform !== right.platform) return false;
|
|
42
|
+
if (left.platform === "local" && right.platform === "local") return left.conversationId === right.conversationId;
|
|
43
|
+
if (left.platform === "slack" && right.platform === "slack") return left.teamId === right.teamId && left.channelId === right.channelId;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region src/chat/identities/sql.ts
|
|
48
|
+
function dateFromMs$1(ms) {
|
|
49
|
+
return new Date(ms);
|
|
50
|
+
}
|
|
51
|
+
function tenantId$1(value) {
|
|
52
|
+
return value ?? "";
|
|
53
|
+
}
|
|
54
|
+
async function upsertUser(executor, args) {
|
|
55
|
+
const id = (await executor.db().insert(juniorUsers).values({
|
|
56
|
+
id: randomUUID(),
|
|
57
|
+
primaryEmail: args.email,
|
|
58
|
+
primaryEmailNormalized: args.emailNormalized,
|
|
59
|
+
displayName: args.displayName ?? null,
|
|
60
|
+
createdAt: dateFromMs$1(args.nowMs),
|
|
61
|
+
updatedAt: dateFromMs$1(args.nowMs)
|
|
62
|
+
}).onConflictDoUpdate({
|
|
63
|
+
target: juniorUsers.primaryEmailNormalized,
|
|
64
|
+
set: {
|
|
65
|
+
displayName: sql`coalesce(${juniorUsers.displayName}, excluded.display_name)`,
|
|
66
|
+
updatedAt: sql`excluded.updated_at`
|
|
67
|
+
}
|
|
68
|
+
}).returning({ id: juniorUsers.id }))[0]?.id;
|
|
69
|
+
if (!id) throw new Error("User identity upsert returned no row");
|
|
70
|
+
return id;
|
|
71
|
+
}
|
|
72
|
+
async function existingIdentity(executor, identity) {
|
|
73
|
+
return (await executor.db().select().from(juniorIdentities).where(and(eq(juniorIdentities.provider, identity.provider), eq(juniorIdentities.providerTenantId, tenantId$1(identity.providerTenantId)), eq(juniorIdentities.providerSubjectId, identity.providerSubjectId))))[0];
|
|
74
|
+
}
|
|
75
|
+
async function upsertIdentityRecord(executor, identity, linkedUserId, nowMs) {
|
|
76
|
+
const emailNormalized = normalizeIdentityEmail(identity.email);
|
|
77
|
+
const email = emailNormalized ? identity.email?.trim() || emailNormalized : void 0;
|
|
78
|
+
const existing = await existingIdentity(executor, identity);
|
|
79
|
+
const userEmailNormalized = existing?.emailVerified && existing.emailNormalized ? existing.emailNormalized : identity.emailVerified ? emailNormalized : void 0;
|
|
80
|
+
const userEmail = existing?.emailVerified && existing.email ? existing.email : email ?? userEmailNormalized;
|
|
81
|
+
const verifiedUserId = identity.kind === "user" && userEmailNormalized ? await upsertUser(executor, {
|
|
82
|
+
email: userEmail ?? userEmailNormalized,
|
|
83
|
+
emailNormalized: userEmailNormalized,
|
|
84
|
+
nowMs,
|
|
85
|
+
...existing?.displayName || identity.displayName ? { displayName: existing?.displayName ?? identity.displayName } : void 0
|
|
86
|
+
}) : void 0;
|
|
87
|
+
if (existing?.userId && verifiedUserId && existing.userId !== verifiedUserId) throw new Error("Identity verified email conflicts with linked user");
|
|
88
|
+
if (linkedUserId && verifiedUserId && linkedUserId !== verifiedUserId) throw new Error("Linked identity conflicts with verified email user");
|
|
89
|
+
if (existing?.userId && linkedUserId && existing.userId !== linkedUserId) throw new Error("Identity conflicts with linked user");
|
|
90
|
+
const userId = existing?.userId ?? linkedUserId ?? verifiedUserId;
|
|
91
|
+
const row = (await executor.db().insert(juniorIdentities).values({
|
|
92
|
+
id: randomUUID(),
|
|
93
|
+
userId: userId ?? null,
|
|
94
|
+
kind: identity.kind,
|
|
95
|
+
provider: identity.provider,
|
|
96
|
+
providerTenantId: tenantId$1(identity.providerTenantId),
|
|
97
|
+
providerSubjectId: identity.providerSubjectId,
|
|
98
|
+
displayName: identity.displayName ?? null,
|
|
99
|
+
handle: identity.handle ?? null,
|
|
100
|
+
email: email ?? null,
|
|
101
|
+
emailNormalized: emailNormalized ?? null,
|
|
102
|
+
emailVerified: Boolean(identity.emailVerified && emailNormalized),
|
|
103
|
+
avatarUrl: null,
|
|
104
|
+
metadata: identity.metadata ?? null,
|
|
105
|
+
createdAt: dateFromMs$1(nowMs),
|
|
106
|
+
updatedAt: dateFromMs$1(nowMs)
|
|
107
|
+
}).onConflictDoUpdate({
|
|
108
|
+
target: [
|
|
109
|
+
juniorIdentities.provider,
|
|
110
|
+
juniorIdentities.providerTenantId,
|
|
111
|
+
juniorIdentities.providerSubjectId
|
|
112
|
+
],
|
|
113
|
+
set: {
|
|
114
|
+
kind: sql`excluded.kind`,
|
|
115
|
+
userId: sql`coalesce(${juniorIdentities.userId}, excluded.user_id)`,
|
|
116
|
+
displayName: linkedUserId ? sql`coalesce(excluded.display_name, ${juniorIdentities.displayName})` : sql`coalesce(${juniorIdentities.displayName}, excluded.display_name)`,
|
|
117
|
+
handle: linkedUserId ? sql`coalesce(excluded.handle, ${juniorIdentities.handle})` : sql`coalesce(${juniorIdentities.handle}, excluded.handle)`,
|
|
118
|
+
email: sql`case when ${juniorIdentities.emailVerified} then coalesce(${juniorIdentities.email}, excluded.email) when excluded.email_verified then excluded.email else coalesce(${juniorIdentities.email}, excluded.email) end`,
|
|
119
|
+
emailNormalized: sql`case when ${juniorIdentities.emailVerified} then coalesce(${juniorIdentities.emailNormalized}, excluded.email_normalized) when excluded.email_verified then excluded.email_normalized else coalesce(${juniorIdentities.emailNormalized}, excluded.email_normalized) end`,
|
|
120
|
+
emailVerified: sql`${juniorIdentities.emailVerified} OR excluded.email_verified`,
|
|
121
|
+
avatarUrl: sql`coalesce(${juniorIdentities.avatarUrl}, excluded.avatar_url)`,
|
|
122
|
+
metadata: sql`coalesce(${juniorIdentities.metadata}, excluded.metadata_json)`,
|
|
123
|
+
updatedAt: sql`excluded.updated_at`
|
|
124
|
+
},
|
|
125
|
+
...linkedUserId ? { setWhere: sql`${juniorIdentities.userId} IS NULL OR ${juniorIdentities.userId} = excluded.user_id` } : void 0
|
|
126
|
+
}).returning({
|
|
127
|
+
id: juniorIdentities.id,
|
|
128
|
+
userId: juniorIdentities.userId
|
|
129
|
+
}))[0];
|
|
130
|
+
if (!row) {
|
|
131
|
+
if (linkedUserId) throw new Error("Identity conflicts with linked user");
|
|
132
|
+
throw new Error("Identity upsert returned no row");
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
id: row.id,
|
|
136
|
+
...row.userId ? { userId: row.userId } : void 0
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** Persist one provider identity observation and link verified emails to users. */
|
|
140
|
+
async function upsertIdentity(executor, identity, nowMs = Date.now()) {
|
|
141
|
+
return await upsertIdentityRecord(executor, identity, void 0, nowMs);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Persist one provider identity after a trusted account-linking flow.
|
|
145
|
+
* Callers must use provider-verified account data, such as GitHub's authenticated
|
|
146
|
+
* user response, never identity claims inferred from content such as Git commits.
|
|
147
|
+
*/
|
|
148
|
+
async function upsertLinkedIdentity(executor, userId, identity, nowMs = Date.now()) {
|
|
149
|
+
return await upsertIdentityRecord(executor, identity, userId, nowMs);
|
|
150
|
+
}
|
|
151
|
+
/** Remove one exact provider identity owned by a workspace Slack user. */
|
|
152
|
+
async function deleteProviderIdentityForSlackUser(executor, slackTeamId, slackUserId, provider, providerSubjectId) {
|
|
153
|
+
const userId = (await executor.db().select({ userId: juniorIdentities.userId }).from(juniorIdentities).where(and(eq(juniorIdentities.kind, "user"), eq(juniorIdentities.provider, "slack"), eq(juniorIdentities.providerTenantId, slackTeamId), eq(juniorIdentities.providerSubjectId, slackUserId))).limit(1))[0]?.userId;
|
|
154
|
+
if (!userId) return;
|
|
155
|
+
await executor.db().delete(juniorIdentities).where(and(eq(juniorIdentities.userId, userId), eq(juniorIdentities.provider, provider), eq(juniorIdentities.providerTenantId, ""), eq(juniorIdentities.providerSubjectId, providerSubjectId)));
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
158
|
+
//#region src/chat/conversations/sql/participants.ts
|
|
159
|
+
/** Resolve the root conversation id that owns personal-feed membership. */
|
|
160
|
+
async function resolveRootConversationId(executor, conversationId) {
|
|
161
|
+
const [row] = await executor.db().select({ rootConversationId: juniorConversations.rootConversationId }).from(juniorConversations).where(eq(juniorConversations.conversationId, conversationId)).limit(1);
|
|
162
|
+
return row?.rootConversationId ?? conversationId;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Upsert one linked user as a participant on a root conversation.
|
|
166
|
+
* No-ops when the identity is missing, unlinked, or not a user.
|
|
167
|
+
*/
|
|
168
|
+
async function recordConversationParticipant(executor, args) {
|
|
169
|
+
const [identity] = await executor.db().select({
|
|
170
|
+
kind: juniorIdentities.kind,
|
|
171
|
+
userId: juniorIdentities.userId
|
|
172
|
+
}).from(juniorIdentities).where(eq(juniorIdentities.id, args.actorIdentityId)).limit(1);
|
|
173
|
+
if (!identity?.userId || identity.kind !== "user") return;
|
|
174
|
+
const rootConversationId = await resolveRootConversationId(executor, args.conversationId);
|
|
175
|
+
const at = new Date(args.atMs);
|
|
176
|
+
await executor.db().insert(juniorConversationParticipants).values({
|
|
177
|
+
userId: identity.userId,
|
|
178
|
+
rootConversationId,
|
|
179
|
+
lastMessageAt: at
|
|
180
|
+
}).onConflictDoUpdate({
|
|
181
|
+
target: [juniorConversationParticipants.userId, juniorConversationParticipants.rootConversationId],
|
|
182
|
+
set: {
|
|
183
|
+
lastMessageAt: sql`greatest(${juniorConversationParticipants.lastMessageAt}, excluded.last_message_at)`,
|
|
184
|
+
...args.restoreArchive ? { archivedAt: null } : void 0
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region src/chat/source.ts
|
|
190
|
+
/**
|
|
191
|
+
* Normalize a Turn Source into legacy provider fields stored on a Conversation.
|
|
192
|
+
* System Sources do not contain a Conversation Location.
|
|
193
|
+
*/
|
|
194
|
+
function normalizeSessionSource(value) {
|
|
195
|
+
if (!value) return;
|
|
196
|
+
if (value.kind === "local") return {
|
|
197
|
+
kind: "local",
|
|
198
|
+
visibility: value.visibility,
|
|
199
|
+
conversationId: value.conversationId
|
|
200
|
+
};
|
|
201
|
+
if (value.kind === "web") return {
|
|
202
|
+
kind: "web",
|
|
203
|
+
visibility: value.visibility,
|
|
204
|
+
conversationId: value.conversationId
|
|
205
|
+
};
|
|
206
|
+
if (value.kind === "resource_event" || value.kind === "scheduled_task" || value.kind === "event_task" || value.kind === "plugin_dispatch" || value.kind === "agent_invocation") return;
|
|
207
|
+
const threadTs = value.threadTs?.trim();
|
|
208
|
+
return {
|
|
209
|
+
kind: "slack",
|
|
210
|
+
visibility: value.visibility,
|
|
211
|
+
teamId: value.teamId,
|
|
212
|
+
channelId: value.channelId,
|
|
213
|
+
...threadTs ? { threadTs } : void 0
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/** Parse a serialized Source into legacy provider fields stored on a Conversation. */
|
|
217
|
+
function parseSessionSource(value) {
|
|
218
|
+
const parsed = sourceSchema.safeParse(value);
|
|
219
|
+
return parsed.success ? normalizeSessionSource(parsed.data) : void 0;
|
|
220
|
+
}
|
|
221
|
+
//#endregion
|
|
222
|
+
//#region src/chat/conversations/sql/bindings.ts
|
|
223
|
+
/** Resolve the durable conversation bound to provider-owned coordinates. */
|
|
224
|
+
async function getConversationIdByProviderConversation(executor, args) {
|
|
225
|
+
return (await executor.db().select({ conversationId: juniorConversationBindings.conversationId }).from(juniorConversationBindings).where(and(eq(juniorConversationBindings.provider, args.provider), eq(juniorConversationBindings.providerTenantId, args.providerTenantId), eq(juniorConversationBindings.providerDestinationId, args.providerDestinationId), eq(juniorConversationBindings.providerConversationId, args.providerConversationId))).limit(1))[0]?.conversationId;
|
|
226
|
+
}
|
|
227
|
+
/** Bind provider coordinates to a durable conversation in the caller's SQL scope. */
|
|
228
|
+
async function bindProviderConversation(executor, args) {
|
|
229
|
+
if (((await executor.db().insert(juniorConversationBindings).values({
|
|
230
|
+
...args,
|
|
231
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
232
|
+
}).onConflictDoNothing().returning({ conversationId: juniorConversationBindings.conversationId }))[0]?.conversationId ?? await getConversationIdByProviderConversation(executor, args)) !== args.conversationId) throw new Error("Provider conversation is already bound to another conversation");
|
|
233
|
+
}
|
|
234
|
+
//#endregion
|
|
235
|
+
//#region src/chat/conversations/sql/actor-identity.ts
|
|
236
|
+
/** Build a durable identity observation from a stored conversation actor. */
|
|
237
|
+
function identityFromActor(actor) {
|
|
238
|
+
if (actor?.slackUserId) return {
|
|
239
|
+
kind: "user",
|
|
240
|
+
provider: "slack",
|
|
241
|
+
providerTenantId: actor.teamId,
|
|
242
|
+
providerSubjectId: actor.slackUserId,
|
|
243
|
+
...actor.fullName ? { displayName: actor.fullName } : void 0,
|
|
244
|
+
...actor.slackUserName ? { handle: actor.slackUserName } : void 0,
|
|
245
|
+
...actor.email ? {
|
|
246
|
+
email: actor.email,
|
|
247
|
+
emailVerified: true
|
|
248
|
+
} : void 0,
|
|
249
|
+
metadata: { platform: "slack" }
|
|
250
|
+
};
|
|
251
|
+
const email = actor?.email?.trim().toLowerCase();
|
|
252
|
+
if (!email) return;
|
|
253
|
+
return {
|
|
254
|
+
kind: "user",
|
|
255
|
+
provider: "junior",
|
|
256
|
+
providerSubjectId: email,
|
|
257
|
+
email,
|
|
258
|
+
emailVerified: true,
|
|
259
|
+
...actor?.fullName ? { displayName: actor.fullName } : void 0,
|
|
260
|
+
metadata: { platform: "web" }
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/** Build a system identity when a conversation has no user actor. */
|
|
264
|
+
function systemIdentityFromSource(source) {
|
|
265
|
+
if (source === "scheduler") return {
|
|
266
|
+
kind: "system",
|
|
267
|
+
provider: "junior",
|
|
268
|
+
providerSubjectId: "scheduler",
|
|
269
|
+
displayName: "Junior Scheduler"
|
|
270
|
+
};
|
|
271
|
+
if (source === "local") return {
|
|
272
|
+
kind: "system",
|
|
273
|
+
provider: "junior",
|
|
274
|
+
providerSubjectId: "local-cli",
|
|
275
|
+
displayName: "Local CLI"
|
|
276
|
+
};
|
|
277
|
+
if (source === "resource_event") return {
|
|
278
|
+
kind: "system",
|
|
279
|
+
provider: "junior",
|
|
280
|
+
providerSubjectId: "resource-event",
|
|
281
|
+
displayName: "Resource Event"
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/** Prefer the user actor identity, else a source-derived system identity. */
|
|
285
|
+
function actorIdentityForConversation(conversation) {
|
|
286
|
+
return identityFromActor(conversation.actor) ?? systemIdentityFromSource(conversation.source);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Reconstruct a stored actor with the linked user name and identity-scoped
|
|
290
|
+
* provider fields.
|
|
291
|
+
*/
|
|
292
|
+
function actorFromIdentityRow(identity, userDisplayName) {
|
|
293
|
+
if (!identity) return;
|
|
294
|
+
const fullName = userDisplayName?.trim() ? userDisplayName : identity.displayName;
|
|
295
|
+
const email = identity.emailNormalized ?? identity.email ?? void 0;
|
|
296
|
+
if (identity.provider === "slack") return {
|
|
297
|
+
...email ? { email } : void 0,
|
|
298
|
+
...fullName ? { fullName } : void 0,
|
|
299
|
+
platform: "slack",
|
|
300
|
+
slackUserId: identity.providerSubjectId,
|
|
301
|
+
...identity.handle ? { slackUserName: identity.handle } : void 0,
|
|
302
|
+
...identity.providerTenantId ? { teamId: identity.providerTenantId } : void 0
|
|
303
|
+
};
|
|
304
|
+
if (identity.provider === "junior" && email) return {
|
|
305
|
+
email,
|
|
306
|
+
...fullName ? { fullName } : void 0
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/** Merge later actor observations without replacing a durable Slack subject. */
|
|
310
|
+
function mergeActor(current, next) {
|
|
311
|
+
if (!current) return next;
|
|
312
|
+
if (!next) return current;
|
|
313
|
+
if (current.slackUserId && next.slackUserId && current.slackUserId !== next.slackUserId) return current;
|
|
314
|
+
return {
|
|
315
|
+
...current,
|
|
316
|
+
...current.email ?? next.email ? { email: current.email ?? next.email } : void 0,
|
|
317
|
+
...current.fullName ?? next.fullName ? { fullName: current.fullName ?? next.fullName } : void 0,
|
|
318
|
+
...current.platform ?? next.platform ? { platform: current.platform ?? next.platform } : void 0,
|
|
319
|
+
...current.slackUserId ?? next.slackUserId ? { slackUserId: current.slackUserId ?? next.slackUserId } : void 0,
|
|
320
|
+
...current.slackUserName ?? next.slackUserName ? { slackUserName: current.slackUserName ?? next.slackUserName } : void 0,
|
|
321
|
+
...current.teamId ?? next.teamId ? { teamId: current.teamId ?? next.teamId } : void 0
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
//#endregion
|
|
325
|
+
//#region src/chat/conversations/sql/location.ts
|
|
326
|
+
/** Build the complete Location for one Conversation SQL write. */
|
|
327
|
+
function locationForWrite(args) {
|
|
328
|
+
const location = args.location ? locationSchema.parse(args.location) : void 0;
|
|
329
|
+
const destination = args.destination?.platform === "slack" ? args.destination : void 0;
|
|
330
|
+
const source = args.sessionSource?.kind === "slack" ? args.sessionSource : void 0;
|
|
331
|
+
if (location) {
|
|
332
|
+
if (destination && (location.id !== args.destinationId || location.teamId !== destination.teamId || location.channelId !== destination.channelId) || source && (location.teamId !== source.teamId || location.channelId !== source.channelId)) throw new Error("Conversation Location changed");
|
|
333
|
+
return source?.threadTs && !location.threadTs ? {
|
|
334
|
+
...location,
|
|
335
|
+
threadTs: source.threadTs
|
|
336
|
+
} : location;
|
|
337
|
+
}
|
|
338
|
+
if (destination && source && (destination.teamId !== source.teamId || destination.channelId !== source.channelId)) throw new Error("Conversation Location does not match its session Source");
|
|
339
|
+
if (!destination || !args.destinationId) return;
|
|
340
|
+
return locationSchema.parse({
|
|
341
|
+
id: args.destinationId,
|
|
342
|
+
provider: "slack",
|
|
343
|
+
teamId: destination.teamId,
|
|
344
|
+
channelId: destination.channelId,
|
|
345
|
+
...source?.threadTs ? { threadTs: source.threadTs } : void 0
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
/** Read one complete Location from SQL. */
|
|
349
|
+
function locationFromRow(value, row, sessionSource) {
|
|
350
|
+
if (value !== void 0 && value !== null) {
|
|
351
|
+
const location = locationSchema.parse(value);
|
|
352
|
+
const slackSource = !location.threadTs && sessionSource?.kind === "slack" && sessionSource.teamId === location.teamId && sessionSource.channelId === location.channelId ? sessionSource : void 0;
|
|
353
|
+
return slackSource?.threadTs ? {
|
|
354
|
+
...location,
|
|
355
|
+
threadTs: slackSource.threadTs
|
|
356
|
+
} : location;
|
|
357
|
+
}
|
|
358
|
+
if (!row || row.provider === "local") return;
|
|
359
|
+
if (row.provider !== "slack") throw new Error("Conversation Location provider is not supported");
|
|
360
|
+
const slackSource = sessionSource?.kind === "slack" && sessionSource.teamId === row.providerTenantId && sessionSource.channelId === row.providerDestinationId ? sessionSource : void 0;
|
|
361
|
+
return locationSchema.parse({
|
|
362
|
+
id: row.id,
|
|
363
|
+
provider: "slack",
|
|
364
|
+
teamId: row.providerTenantId,
|
|
365
|
+
channelId: row.providerDestinationId,
|
|
366
|
+
...slackSource?.threadTs ? { threadTs: slackSource.threadTs } : void 0
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
/** Read confirmed visibility from one linked Location row. */
|
|
370
|
+
function visibilityFromLocationRow(row) {
|
|
371
|
+
if (!row) return;
|
|
372
|
+
if (row.visibility === "public" || row.visibility === "private") return row.visibility;
|
|
373
|
+
}
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/chat/conversations/sql/store.ts
|
|
376
|
+
const CONVERSATION_MUTATION_LOCK_PREFIX = "junior_conversation";
|
|
377
|
+
/** Serialize one conversation's durable mutations inside a SQL transaction. */
|
|
378
|
+
async function withConversationMutationLock(executor, conversationId, callback) {
|
|
379
|
+
return await executor.withLock(`${CONVERSATION_MUTATION_LOCK_PREFIX}:${conversationId}`, async () => await executor.transaction(callback));
|
|
380
|
+
}
|
|
381
|
+
function now() {
|
|
382
|
+
return Date.now();
|
|
383
|
+
}
|
|
384
|
+
function dateFromMs(ms) {
|
|
385
|
+
return new Date(ms);
|
|
386
|
+
}
|
|
387
|
+
function msFromDate(value) {
|
|
388
|
+
if (value === null || value === void 0) return;
|
|
389
|
+
return (value instanceof Date ? value : new Date(value)).getTime();
|
|
390
|
+
}
|
|
391
|
+
function requiredMsFromDate(value) {
|
|
392
|
+
const ms = msFromDate(value);
|
|
393
|
+
if (typeof ms !== "number" || Number.isNaN(ms)) throw new Error("Conversation record timestamp is invalid");
|
|
394
|
+
return ms;
|
|
395
|
+
}
|
|
396
|
+
function tenantId(value) {
|
|
397
|
+
return value ?? "";
|
|
398
|
+
}
|
|
399
|
+
function sourceFromValue(value) {
|
|
400
|
+
if (value === "api" || value === "internal" || value === "local" || value === "plugin" || value === "resource_event" || value === "scheduler" || value === "slack" || value === "web") return value;
|
|
401
|
+
}
|
|
402
|
+
function originTypeFromSource(source) {
|
|
403
|
+
return source;
|
|
404
|
+
}
|
|
405
|
+
function localWorkspaceFromConversationId(conversationId) {
|
|
406
|
+
return /^local:([^:]+):/.exec(conversationId)?.[1];
|
|
407
|
+
}
|
|
408
|
+
function destinationUpsertFromDestination(args) {
|
|
409
|
+
const { destination } = args;
|
|
410
|
+
if (!destination) return;
|
|
411
|
+
if (destination.platform === "slack") {
|
|
412
|
+
const channelId = destination.channelId;
|
|
413
|
+
return {
|
|
414
|
+
kind: channelId.startsWith("D") ? "dm" : channelId.startsWith("G") ? "group" : "channel",
|
|
415
|
+
provider: "slack",
|
|
416
|
+
providerTenantId: destination.teamId,
|
|
417
|
+
providerDestinationId: channelId,
|
|
418
|
+
refreshVisibility: args.visibility !== void 0,
|
|
419
|
+
visibility: args.visibility ?? "unknown",
|
|
420
|
+
...args.channelName ? { displayName: args.channelName } : void 0,
|
|
421
|
+
metadata: { platform: "slack" }
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
kind: "local_conversation",
|
|
426
|
+
provider: "local",
|
|
427
|
+
providerTenantId: localWorkspaceFromConversationId(destination.conversationId) ?? localWorkspaceFromConversationId(args.conversationId ?? ""),
|
|
428
|
+
providerDestinationId: destination.conversationId,
|
|
429
|
+
refreshVisibility: args.visibility !== void 0,
|
|
430
|
+
visibility: args.visibility ?? "direct",
|
|
431
|
+
metadata: { platform: "local" }
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
function executionStatusFromValue(value) {
|
|
435
|
+
if (value === "awaiting_resume" || value === "paused") return "paused";
|
|
436
|
+
if (value === "failed" || value === "idle" || value === "pending" || value === "running") return value;
|
|
437
|
+
throw new Error("Conversation record execution status is invalid");
|
|
438
|
+
}
|
|
439
|
+
function executionStatusToSql(status) {
|
|
440
|
+
return status === "paused" ? "awaiting_resume" : status;
|
|
441
|
+
}
|
|
442
|
+
function destinationFromRow(destination) {
|
|
443
|
+
return parseDestination(destination?.provider === "slack" ? {
|
|
444
|
+
platform: "slack",
|
|
445
|
+
teamId: destination.providerTenantId,
|
|
446
|
+
channelId: destination.providerDestinationId
|
|
447
|
+
} : destination?.provider === "local" ? {
|
|
448
|
+
platform: "local",
|
|
449
|
+
conversationId: destination.providerDestinationId
|
|
450
|
+
} : void 0);
|
|
451
|
+
}
|
|
452
|
+
/** Decode one SQL row and reject invalid durable conversation records. */
|
|
453
|
+
function conversationFromRow(readRow) {
|
|
454
|
+
const row = readRow.conversation;
|
|
455
|
+
const visibility = visibilityFromLocationRow(readRow.destination);
|
|
456
|
+
if (row.schemaVersion !== 1) throw new Error("Conversation record schema version is invalid");
|
|
457
|
+
if (row.destination !== null && readRow.destination === null) throw new Error("Conversation legacy destination is not migrated");
|
|
458
|
+
if (row.actor !== null && readRow.actorIdentity === null) throw new Error("Conversation legacy actor is not migrated");
|
|
459
|
+
const destination = destinationFromRow(readRow.destination);
|
|
460
|
+
const actor = actorFromIdentityRow(readRow.actorIdentity, readRow.actorUserDisplayName);
|
|
461
|
+
if (readRow.destination !== null && !destination) throw new Error("Conversation record destination is invalid");
|
|
462
|
+
const source = row.source === void 0 || row.source === null ? void 0 : sourceFromValue(row.source);
|
|
463
|
+
if (row.source !== void 0 && row.source !== null && !source) throw new Error("Conversation record source is invalid");
|
|
464
|
+
const sessionSource = row.sessionSource === void 0 || row.sessionSource === null ? void 0 : parseSessionSource(row.sessionSource);
|
|
465
|
+
if (row.sessionSource !== void 0 && row.sessionSource !== null && !sessionSource) throw new Error("Conversation record session source is invalid");
|
|
466
|
+
const execution = {
|
|
467
|
+
status: executionStatusFromValue(row.executionStatus),
|
|
468
|
+
lastCheckpointAtMs: msFromDate(row.lastCheckpointAt),
|
|
469
|
+
lastEnqueuedAtMs: msFromDate(row.lastEnqueuedAt),
|
|
470
|
+
...row.runId ? { runId: row.runId } : void 0,
|
|
471
|
+
updatedAtMs: msFromDate(row.executionUpdatedAt) ?? requiredMsFromDate(row.updatedAt)
|
|
472
|
+
};
|
|
473
|
+
const location = locationFromRow(row.location, readRow.destination, sessionSource);
|
|
474
|
+
return {
|
|
475
|
+
schemaVersion: 1,
|
|
476
|
+
conversationId: row.conversationId,
|
|
477
|
+
createdAtMs: requiredMsFromDate(row.createdAt),
|
|
478
|
+
lastActivityAtMs: requiredMsFromDate(row.lastActivityAt),
|
|
479
|
+
updatedAtMs: requiredMsFromDate(row.updatedAt),
|
|
480
|
+
execution,
|
|
481
|
+
executionMetrics: {
|
|
482
|
+
durationMs: row.executionDurationMs,
|
|
483
|
+
...row.metricRunId ? { runId: row.metricRunId } : void 0,
|
|
484
|
+
...row.executionUsage ? { usage: row.executionUsage } : void 0
|
|
485
|
+
},
|
|
486
|
+
...row.parentConversationId ? { parentConversationId: row.parentConversationId } : void 0,
|
|
487
|
+
...destination ? { destination } : void 0,
|
|
488
|
+
...location ? { location } : void 0,
|
|
489
|
+
...actor ? { actor } : void 0,
|
|
490
|
+
...row.channelName ? { channelName: row.channelName } : void 0,
|
|
491
|
+
...source ? { source } : void 0,
|
|
492
|
+
...sessionSource ? { sessionSource } : void 0,
|
|
493
|
+
...row.title ? { title: row.title } : void 0,
|
|
494
|
+
...msFromDate(row.transcriptPurgedAt) !== void 0 ? { transcriptPurgedAtMs: msFromDate(row.transcriptPurgedAt) } : void 0,
|
|
495
|
+
...visibility ? { visibility } : void 0
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function emptyConversation(args) {
|
|
499
|
+
return {
|
|
500
|
+
schemaVersion: 1,
|
|
501
|
+
conversationId: args.conversationId,
|
|
502
|
+
createdAtMs: args.nowMs,
|
|
503
|
+
lastActivityAtMs: args.nowMs,
|
|
504
|
+
updatedAtMs: args.nowMs,
|
|
505
|
+
...args.destination ? { destination: args.destination } : void 0,
|
|
506
|
+
...args.source ? { source: args.source } : void 0,
|
|
507
|
+
...args.sessionSource ? { sessionSource: args.sessionSource } : void 0,
|
|
508
|
+
execution: {
|
|
509
|
+
status: "idle",
|
|
510
|
+
updatedAtMs: args.nowMs
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
function assertSameConversationDestination(args) {
|
|
515
|
+
if (!args.current || sameDestination(args.current, args.next)) return;
|
|
516
|
+
throw new Error(`Conversation destination changed for ${args.conversationId}`);
|
|
517
|
+
}
|
|
518
|
+
function tokenTotal(usage) {
|
|
519
|
+
if (!usage) return 0;
|
|
520
|
+
if (usage.totalTokens !== void 0) return usage.totalTokens;
|
|
521
|
+
return (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) + (usage.cachedInputTokens ?? 0) + (usage.cacheCreationTokens ?? 0);
|
|
522
|
+
}
|
|
523
|
+
function updateConversationUsage(args) {
|
|
524
|
+
const usage = { totalTokens: tokenTotal(args.current) - tokenTotal(args.previousExecution) + tokenTotal(args.nextExecution) };
|
|
525
|
+
if (args.current?.reasoningTokens !== void 0 || args.previousExecution?.reasoningTokens !== void 0 || args.nextExecution.reasoningTokens !== void 0) usage.reasoningTokens = (args.current?.reasoningTokens ?? 0) - (args.previousExecution?.reasoningTokens ?? 0) + (args.nextExecution.reasoningTokens ?? 0);
|
|
526
|
+
const costFields = [
|
|
527
|
+
"input",
|
|
528
|
+
"output",
|
|
529
|
+
"cacheRead",
|
|
530
|
+
"cacheWrite",
|
|
531
|
+
"total"
|
|
532
|
+
];
|
|
533
|
+
const cost = {};
|
|
534
|
+
for (const field of costFields) {
|
|
535
|
+
if (args.current?.cost?.[field] === void 0 && args.previousExecution?.cost?.[field] === void 0 && args.nextExecution.cost?.[field] === void 0) continue;
|
|
536
|
+
cost[field] = Math.round(((args.current?.cost?.[field] ?? 0) - (args.previousExecution?.cost?.[field] ?? 0) + (args.nextExecution.cost?.[field] ?? 0)) * 0xe8d4a51000) / 0xe8d4a51000;
|
|
537
|
+
}
|
|
538
|
+
if (Object.keys(cost).length > 0) usage.cost = cost;
|
|
539
|
+
return usage;
|
|
540
|
+
}
|
|
541
|
+
var SqlStore = class {
|
|
542
|
+
executor;
|
|
543
|
+
constructor(executor) {
|
|
544
|
+
this.executor = executor;
|
|
545
|
+
}
|
|
546
|
+
async getConversationIdByProviderConversation(args) {
|
|
547
|
+
return await getConversationIdByProviderConversation(this.executor, args);
|
|
548
|
+
}
|
|
549
|
+
async bindProviderConversation(args) {
|
|
550
|
+
await bindProviderConversation(this.executor, args);
|
|
551
|
+
}
|
|
552
|
+
async createChild(args) {
|
|
553
|
+
const nowMs = args.nowMs ?? now();
|
|
554
|
+
await this.withConversationMutation(args.parentConversationId, async () => {
|
|
555
|
+
await this.withConversationMutation(args.childConversationId, async () => {
|
|
556
|
+
const existing = await this.get({ conversationId: args.childConversationId });
|
|
557
|
+
if (existing) {
|
|
558
|
+
if (existing.parentConversationId !== args.parentConversationId) throw new Error(`Conversation parent changed for ${args.childConversationId}`);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const parent = await this.get({ conversationId: args.parentConversationId });
|
|
562
|
+
if (!parent) throw new Error(`Conversation parent is missing for ${args.childConversationId}`);
|
|
563
|
+
if (parent.parentConversationId) throw new Error("Recursive agent delegation is not enabled");
|
|
564
|
+
const child = {
|
|
565
|
+
...emptyConversation({
|
|
566
|
+
conversationId: args.childConversationId,
|
|
567
|
+
nowMs,
|
|
568
|
+
source: args.source
|
|
569
|
+
}),
|
|
570
|
+
parentConversationId: args.parentConversationId
|
|
571
|
+
};
|
|
572
|
+
await this.upsertConversation({ conversation: child });
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
async get(args) {
|
|
577
|
+
const row = await this.readConversationRow(args.conversationId);
|
|
578
|
+
if (!row) return;
|
|
579
|
+
return conversationFromRow(row);
|
|
580
|
+
}
|
|
581
|
+
async recordActivity(args) {
|
|
582
|
+
const nowMs = args.nowMs ?? now();
|
|
583
|
+
const activityAtMs = args.activityAtMs ?? nowMs;
|
|
584
|
+
const sessionSource = normalizeSessionSource(args.sessionSource);
|
|
585
|
+
await this.withConversationMutation(args.conversationId, async () => {
|
|
586
|
+
const existing = await this.get({ conversationId: args.conversationId });
|
|
587
|
+
if (existing && args.destination) assertSameConversationDestination({
|
|
588
|
+
conversationId: args.conversationId,
|
|
589
|
+
current: existing.destination,
|
|
590
|
+
next: args.destination
|
|
591
|
+
});
|
|
592
|
+
const current = existing ?? emptyConversation({
|
|
593
|
+
conversationId: args.conversationId,
|
|
594
|
+
destination: args.destination,
|
|
595
|
+
nowMs,
|
|
596
|
+
source: args.source,
|
|
597
|
+
...sessionSource ? { sessionSource } : void 0
|
|
598
|
+
});
|
|
599
|
+
const { sessionSource: _persistedSessionSource, visibility: _persistedVisibility, ...currentWithoutPersistedSignals } = current;
|
|
600
|
+
await this.upsertConversation({ conversation: {
|
|
601
|
+
...currentWithoutPersistedSignals,
|
|
602
|
+
destination: current.destination ?? args.destination,
|
|
603
|
+
source: current.source ?? args.source,
|
|
604
|
+
...sessionSource ? { sessionSource } : void 0,
|
|
605
|
+
channelName: current.channelName ?? args.channelName,
|
|
606
|
+
actor: mergeActor(current.actor, args.actor),
|
|
607
|
+
title: current.title ?? args.title,
|
|
608
|
+
lastActivityAtMs: Math.max(current.lastActivityAtMs, activityAtMs),
|
|
609
|
+
updatedAtMs: nowMs,
|
|
610
|
+
execution: {
|
|
611
|
+
...current.execution,
|
|
612
|
+
updatedAtMs: current.execution.updatedAtMs ?? nowMs
|
|
613
|
+
},
|
|
614
|
+
...args.visibility ? { visibility: args.visibility } : void 0
|
|
615
|
+
} });
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
async recordExecution(args) {
|
|
619
|
+
await this.withConversationMutation(args.conversationId, async () => {
|
|
620
|
+
const existingRow = await this.readConversationRow(args.conversationId);
|
|
621
|
+
const existing = existingRow ? conversationFromRow(existingRow) : void 0;
|
|
622
|
+
if (!existing && !args.destination) return;
|
|
623
|
+
const incomingIsFresh = (args.execution.updatedAtMs ?? args.updatedAtMs) >= (existing?.execution.updatedAtMs ?? existing?.updatedAtMs ?? 0);
|
|
624
|
+
const metricRunId = existingRow?.conversation.metricRunId;
|
|
625
|
+
const sameRun = Boolean(metricRunId) && metricRunId === args.execution.runId;
|
|
626
|
+
const execution = incomingIsFresh ? args.execution : existing?.execution ?? args.execution;
|
|
627
|
+
await this.upsertConversation({ conversation: {
|
|
628
|
+
schemaVersion: 1,
|
|
629
|
+
conversationId: args.conversationId,
|
|
630
|
+
createdAtMs: args.createdAtMs,
|
|
631
|
+
lastActivityAtMs: args.lastActivityAtMs,
|
|
632
|
+
updatedAtMs: args.updatedAtMs,
|
|
633
|
+
...existing?.parentConversationId ? { parentConversationId: existing.parentConversationId } : void 0,
|
|
634
|
+
...existing?.location ? { location: existing.location } : void 0,
|
|
635
|
+
...args.channelName ? { channelName: args.channelName } : void 0,
|
|
636
|
+
...args.destination ? { destination: args.destination } : void 0,
|
|
637
|
+
...args.actor ? { actor: args.actor } : void 0,
|
|
638
|
+
...args.source ? { source: args.source } : void 0,
|
|
639
|
+
...args.title ? { title: args.title } : void 0,
|
|
640
|
+
...args.visibility ? { visibility: args.visibility } : void 0,
|
|
641
|
+
execution
|
|
642
|
+
} });
|
|
643
|
+
if (incomingIsFresh && args.metrics) {
|
|
644
|
+
const row = existingRow?.conversation;
|
|
645
|
+
const usage = args.metrics.usage ? updateConversationUsage({
|
|
646
|
+
current: row?.usage ?? void 0,
|
|
647
|
+
previousExecution: sameRun ? row?.executionUsage ?? void 0 : void 0,
|
|
648
|
+
nextExecution: args.metrics.usage
|
|
649
|
+
}) : row?.usage ?? void 0;
|
|
650
|
+
await this.executor.db().update(juniorConversations).set({
|
|
651
|
+
durationMs: (row?.durationMs ?? 0) - (sameRun ? row?.executionDurationMs ?? 0 : 0) + args.metrics.durationMs,
|
|
652
|
+
usage: usage ?? null,
|
|
653
|
+
metricRunId: args.execution.runId ?? null,
|
|
654
|
+
executionDurationMs: args.metrics.durationMs,
|
|
655
|
+
executionUsage: args.metrics.usage ?? (sameRun ? row?.executionUsage ?? null : null)
|
|
656
|
+
}).where(eq(juniorConversations.conversationId, args.conversationId));
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
async listByActivity(args = {}) {
|
|
661
|
+
const rows = await this.executor.db().select({
|
|
662
|
+
conversation: conversationReadColumns(),
|
|
663
|
+
destination: juniorDestinations,
|
|
664
|
+
actorIdentity: juniorIdentities,
|
|
665
|
+
actorUserDisplayName: juniorUsers.displayName
|
|
666
|
+
}).from(juniorConversations).leftJoin(juniorDestinations, eq(juniorDestinations.id, juniorConversations.destinationId)).leftJoin(juniorIdentities, eq(juniorIdentities.id, juniorConversations.actorIdentityId)).leftJoin(juniorUsers, eq(juniorUsers.id, juniorIdentities.userId)).where(isNull(juniorConversations.parentConversationId)).orderBy(desc(juniorConversations.lastActivityAt), asc(juniorConversations.conversationId)).limit(Math.max(0, args.limit ?? 1e4)).offset(Math.max(0, args.offset ?? 0));
|
|
667
|
+
const conversations = [];
|
|
668
|
+
for (const row of rows) conversations.push(conversationFromRow(row));
|
|
669
|
+
return conversations;
|
|
670
|
+
}
|
|
671
|
+
async getDestinationVisibility(args) {
|
|
672
|
+
const row = (await this.executor.db().select({ visibility: juniorDestinations.visibility }).from(juniorDestinations).where(and(eq(juniorDestinations.provider, args.provider), eq(juniorDestinations.providerTenantId, tenantId(args.providerTenantId)), eq(juniorDestinations.providerDestinationId, args.providerDestinationId))))[0];
|
|
673
|
+
if (!row) return;
|
|
674
|
+
if (row.visibility === "public" || row.visibility === "private") return row.visibility;
|
|
675
|
+
}
|
|
676
|
+
async findSlackDestinationByName(args) {
|
|
677
|
+
const normalized = args.channelName.trim().replace(/^#/, "").toLowerCase();
|
|
678
|
+
if (!normalized) return;
|
|
679
|
+
const rows = await this.executor.db().select({
|
|
680
|
+
channelId: juniorDestinations.providerDestinationId,
|
|
681
|
+
channelName: juniorDestinations.displayName
|
|
682
|
+
}).from(juniorDestinations).where(and(eq(juniorDestinations.provider, "slack"), eq(juniorDestinations.providerTenantId, tenantId(args.teamId)), eq(juniorDestinations.kind, "channel"), sql`lower(ltrim(${juniorDestinations.displayName}, '#')) = ${normalized}`)).limit(2);
|
|
683
|
+
if (rows.length !== 1) return;
|
|
684
|
+
const row = rows[0];
|
|
685
|
+
if (!row?.channelId) return;
|
|
686
|
+
return {
|
|
687
|
+
channelId: row.channelId,
|
|
688
|
+
...row.channelName ? { channelName: row.channelName } : void 0
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
/** Serialize all durable mutations for one conversation inside a SQL transaction. */
|
|
692
|
+
async withConversationMutation(conversationId, callback) {
|
|
693
|
+
return await withConversationMutationLock(this.executor, conversationId, callback);
|
|
694
|
+
}
|
|
695
|
+
async readConversationRow(conversationId) {
|
|
696
|
+
return (await this.executor.db().select({
|
|
697
|
+
conversation: conversationReadColumns(),
|
|
698
|
+
destination: juniorDestinations,
|
|
699
|
+
actorIdentity: juniorIdentities,
|
|
700
|
+
actorUserDisplayName: juniorUsers.displayName
|
|
701
|
+
}).from(juniorConversations).leftJoin(juniorDestinations, eq(juniorDestinations.id, juniorConversations.destinationId)).leftJoin(juniorIdentities, eq(juniorIdentities.id, juniorConversations.actorIdentityId)).leftJoin(juniorUsers, eq(juniorUsers.id, juniorIdentities.userId)).where(eq(juniorConversations.conversationId, conversationId)))[0];
|
|
702
|
+
}
|
|
703
|
+
/** Upsert the conversation row while preserving previously discovered nullable metadata fields. */
|
|
704
|
+
async upsertConversation(args) {
|
|
705
|
+
const { conversation } = args;
|
|
706
|
+
if (!conversation.parentConversationId && !conversation.destination) {
|
|
707
|
+
if (!await this.get({ conversationId: conversation.conversationId })) throw new Error(`Conversation ${conversation.conversationId} requires a destination at create`);
|
|
708
|
+
}
|
|
709
|
+
const incomingExecutionVersion = sql`coalesce(excluded.execution_updated_at, excluded.updated_at)`;
|
|
710
|
+
const currentExecutionVersion = sql`coalesce(${juniorConversations.executionUpdatedAt}, ${juniorConversations.updatedAt})`;
|
|
711
|
+
const incomingExecutionIsFresh = sql`${incomingExecutionVersion} >= ${currentExecutionVersion}`;
|
|
712
|
+
const destinationId = await this.upsertDestination(destinationUpsertFromDestination({
|
|
713
|
+
channelName: conversation.channelName,
|
|
714
|
+
conversationId: conversation.conversationId,
|
|
715
|
+
destination: conversation.destination,
|
|
716
|
+
...conversation.visibility ? { visibility: conversation.visibility } : void 0
|
|
717
|
+
}), conversation.updatedAtMs);
|
|
718
|
+
const location = locationForWrite({
|
|
719
|
+
destination: conversation.destination,
|
|
720
|
+
destinationId,
|
|
721
|
+
location: conversation.location,
|
|
722
|
+
sessionSource: conversation.sessionSource
|
|
723
|
+
});
|
|
724
|
+
const actorIdentityObservation = actorIdentityForConversation(conversation);
|
|
725
|
+
const actorIdentity = actorIdentityObservation ? await upsertIdentity(this.executor, actorIdentityObservation, conversation.updatedAtMs) : void 0;
|
|
726
|
+
const rootConversationId = conversation.parentConversationId ? sql`(
|
|
727
|
+
select parent.root_conversation_id
|
|
728
|
+
from junior_conversations parent
|
|
729
|
+
where parent.conversation_id = ${conversation.parentConversationId}
|
|
730
|
+
)` : conversation.conversationId;
|
|
731
|
+
const row = (await this.executor.db().insert(juniorConversations).values({
|
|
732
|
+
conversationId: conversation.conversationId,
|
|
733
|
+
schemaVersion: 1,
|
|
734
|
+
source: conversation.source ?? null,
|
|
735
|
+
sessionSource: conversation.sessionSource ?? null,
|
|
736
|
+
originType: originTypeFromSource(conversation.source) ?? null,
|
|
737
|
+
originId: null,
|
|
738
|
+
originRunId: null,
|
|
739
|
+
location: location ?? null,
|
|
740
|
+
destinationId: destinationId ?? null,
|
|
741
|
+
destination: null,
|
|
742
|
+
actorIdentityId: actorIdentity?.id ?? null,
|
|
743
|
+
creatorIdentityId: null,
|
|
744
|
+
credentialSubjectIdentityId: null,
|
|
745
|
+
actor: null,
|
|
746
|
+
channelName: conversation.channelName ?? null,
|
|
747
|
+
title: conversation.title ?? null,
|
|
748
|
+
createdAt: dateFromMs(conversation.createdAtMs),
|
|
749
|
+
lastActivityAt: dateFromMs(conversation.lastActivityAtMs),
|
|
750
|
+
updatedAt: dateFromMs(conversation.updatedAtMs),
|
|
751
|
+
executionUpdatedAt: conversation.execution.updatedAtMs === void 0 ? null : dateFromMs(conversation.execution.updatedAtMs),
|
|
752
|
+
executionStatus: executionStatusToSql(conversation.execution.status),
|
|
753
|
+
runId: conversation.execution.runId ?? null,
|
|
754
|
+
lastCheckpointAt: conversation.execution.lastCheckpointAtMs === void 0 ? null : dateFromMs(conversation.execution.lastCheckpointAtMs),
|
|
755
|
+
lastEnqueuedAt: conversation.execution.lastEnqueuedAtMs === void 0 ? null : dateFromMs(conversation.execution.lastEnqueuedAtMs),
|
|
756
|
+
parentConversationId: conversation.parentConversationId ?? null,
|
|
757
|
+
rootConversationId
|
|
758
|
+
}).onConflictDoUpdate({
|
|
759
|
+
target: juniorConversations.conversationId,
|
|
760
|
+
set: {
|
|
761
|
+
source: sql`coalesce(${juniorConversations.source}, excluded.source)`,
|
|
762
|
+
sessionSource: sql`coalesce(${juniorConversations.sessionSource}, excluded.source_json)`,
|
|
763
|
+
originType: sql`coalesce(excluded.origin_type, ${juniorConversations.originType})`,
|
|
764
|
+
originId: sql`coalesce(excluded.origin_id, ${juniorConversations.originId})`,
|
|
765
|
+
originRunId: sql`coalesce(excluded.origin_run_id, ${juniorConversations.originRunId})`,
|
|
766
|
+
location: sql`coalesce(excluded.location_json, ${juniorConversations.location})`,
|
|
767
|
+
destinationId: sql`coalesce(excluded.destination_id, ${juniorConversations.destinationId})`,
|
|
768
|
+
actorIdentityId: sql`coalesce(excluded.actor_identity_id, ${juniorConversations.actorIdentityId})`,
|
|
769
|
+
creatorIdentityId: sql`coalesce(excluded.creator_identity_id, ${juniorConversations.creatorIdentityId})`,
|
|
770
|
+
credentialSubjectIdentityId: sql`coalesce(excluded.credential_subject_identity_id, ${juniorConversations.credentialSubjectIdentityId})`,
|
|
771
|
+
channelName: sql`coalesce(excluded.channel_name, ${juniorConversations.channelName})`,
|
|
772
|
+
title: sql`coalesce(excluded.title, ${juniorConversations.title})`,
|
|
773
|
+
createdAt: sql`least(${juniorConversations.createdAt}, excluded.created_at)`,
|
|
774
|
+
lastActivityAt: sql`greatest(${juniorConversations.lastActivityAt}, excluded.last_activity_at)`,
|
|
775
|
+
updatedAt: sql`greatest(${juniorConversations.updatedAt}, excluded.updated_at)`,
|
|
776
|
+
executionUpdatedAt: sql`case when ${incomingExecutionIsFresh} then excluded.execution_updated_at else ${juniorConversations.executionUpdatedAt} end`,
|
|
777
|
+
executionStatus: sql`case when ${incomingExecutionIsFresh} then excluded.execution_status else ${juniorConversations.executionStatus} end`,
|
|
778
|
+
runId: sql`case when ${incomingExecutionIsFresh} then excluded.run_id else ${juniorConversations.runId} end`,
|
|
779
|
+
lastCheckpointAt: sql`case when ${incomingExecutionIsFresh} then coalesce(excluded.last_checkpoint_at, ${juniorConversations.lastCheckpointAt}) else ${juniorConversations.lastCheckpointAt} end`,
|
|
780
|
+
lastEnqueuedAt: sql`case when ${incomingExecutionIsFresh} then coalesce(excluded.last_enqueued_at, ${juniorConversations.lastEnqueuedAt}) else ${juniorConversations.lastEnqueuedAt} end`,
|
|
781
|
+
rootConversationId: sql`coalesce(${juniorConversations.rootConversationId}, excluded.root_conversation_id)`
|
|
782
|
+
}
|
|
783
|
+
}).returning({
|
|
784
|
+
actorIdentityId: juniorConversations.actorIdentityId,
|
|
785
|
+
parentConversationId: juniorConversations.parentConversationId,
|
|
786
|
+
rootConversationId: juniorConversations.rootConversationId
|
|
787
|
+
}))[0];
|
|
788
|
+
if (!row?.rootConversationId) throw new Error("Conversation parent is missing its persisted root");
|
|
789
|
+
if (row.actorIdentityId && row.parentConversationId === null) await recordConversationParticipant(this.executor, {
|
|
790
|
+
actorIdentityId: row.actorIdentityId,
|
|
791
|
+
conversationId: conversation.conversationId,
|
|
792
|
+
atMs: conversation.lastActivityAtMs
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
async upsertDestination(destination, nowMs) {
|
|
796
|
+
if (!destination) return;
|
|
797
|
+
const visibilityUpdate = destination.refreshVisibility ? sql`excluded.visibility` : juniorDestinations.visibility;
|
|
798
|
+
return (await this.executor.db().insert(juniorDestinations).values({
|
|
799
|
+
id: randomUUID(),
|
|
800
|
+
provider: destination.provider,
|
|
801
|
+
providerTenantId: tenantId(destination.providerTenantId),
|
|
802
|
+
providerDestinationId: destination.providerDestinationId,
|
|
803
|
+
kind: destination.kind,
|
|
804
|
+
parentDestinationId: null,
|
|
805
|
+
displayName: destination.displayName ?? null,
|
|
806
|
+
visibility: destination.visibility,
|
|
807
|
+
metadata: destination.metadata ?? null,
|
|
808
|
+
createdAt: dateFromMs(nowMs),
|
|
809
|
+
updatedAt: dateFromMs(nowMs)
|
|
810
|
+
}).onConflictDoUpdate({
|
|
811
|
+
target: [
|
|
812
|
+
juniorDestinations.provider,
|
|
813
|
+
juniorDestinations.providerTenantId,
|
|
814
|
+
juniorDestinations.providerDestinationId
|
|
815
|
+
],
|
|
816
|
+
set: {
|
|
817
|
+
kind: sql`excluded.kind`,
|
|
818
|
+
displayName: sql`coalesce(excluded.display_name, ${juniorDestinations.displayName})`,
|
|
819
|
+
visibility: visibilityUpdate,
|
|
820
|
+
metadata: sql`coalesce(excluded.metadata_json, ${juniorDestinations.metadata})`,
|
|
821
|
+
updatedAt: sql`excluded.updated_at`
|
|
822
|
+
}
|
|
823
|
+
}).returning({ id: juniorDestinations.id }))[0]?.id;
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
/** Create a SQL-backed conversation store. */
|
|
827
|
+
function createSqlStore(executor) {
|
|
828
|
+
return new SqlStore(executor);
|
|
829
|
+
}
|
|
830
|
+
//#endregion
|
|
831
|
+
//#region src/chat/conversations/sql/conversation-row.ts
|
|
832
|
+
/**
|
|
833
|
+
* Establish the conversation metadata row on first contact for content writes
|
|
834
|
+
* (conversation events, visible transcript) that can land before activity has
|
|
835
|
+
* created the row, and whose tables FK to it.
|
|
836
|
+
*
|
|
837
|
+
* First contact creates the row; every later content write advances the
|
|
838
|
+
* activity clock so retention mirrors append-refresh semantics (each content
|
|
839
|
+
* append refreshes the retention window). Background observations may opt out
|
|
840
|
+
* and leave an existing row unchanged. The timestamp is content-intrinsic (the
|
|
841
|
+
* first item's `createdAtMs`), and `greatest(...)` guarantees a backfilled or
|
|
842
|
+
* imported historical write — which carries an old timestamp — can never
|
|
843
|
+
* regress `last_activity_at`.
|
|
844
|
+
*/
|
|
845
|
+
async function ensureConversationRow(executor, conversationId, atMs, options = {}) {
|
|
846
|
+
const at = new Date(atMs);
|
|
847
|
+
const insert = executor.db().insert(juniorConversations).values({
|
|
848
|
+
conversationId,
|
|
849
|
+
createdAt: at,
|
|
850
|
+
lastActivityAt: at,
|
|
851
|
+
updatedAt: at,
|
|
852
|
+
executionStatus: "idle"
|
|
853
|
+
});
|
|
854
|
+
if (options.activity === "preserve") {
|
|
855
|
+
await insert.onConflictDoNothing({ target: juniorConversations.conversationId });
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
await insert.onConflictDoUpdate({
|
|
859
|
+
target: juniorConversations.conversationId,
|
|
860
|
+
set: {
|
|
861
|
+
lastActivityAt: sql`greatest(${juniorConversations.lastActivityAt}, excluded.last_activity_at)`,
|
|
862
|
+
updatedAt: sql`greatest(${juniorConversations.updatedAt}, excluded.updated_at)`,
|
|
863
|
+
transcriptPurgedAt: null
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
//#endregion
|
|
868
|
+
//#region src/chat/conversations/sql/event-actor.ts
|
|
869
|
+
const SLACK_USER_ID_PATTERN = /^[UW][A-Z0-9]{5,}$/;
|
|
870
|
+
function isRecord$1(value) {
|
|
871
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
872
|
+
}
|
|
873
|
+
function readAuthor(meta) {
|
|
874
|
+
if (!isRecord$1(meta) || !isRecord$1(meta.author)) return {};
|
|
875
|
+
const author = meta.author;
|
|
876
|
+
return {
|
|
877
|
+
...typeof author.email === "string" ? { email: author.email } : void 0,
|
|
878
|
+
...typeof author.fullName === "string" ? { fullName: author.fullName } : void 0,
|
|
879
|
+
...typeof author.isBot === "boolean" ? { isBot: author.isBot } : void 0,
|
|
880
|
+
...typeof author.userId === "string" ? { userId: author.userId } : void 0,
|
|
881
|
+
...typeof author.userName === "string" ? { userName: author.userName } : void 0
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
async function loadConversationSlackTenantId(executor, conversationId) {
|
|
885
|
+
const [row] = await executor.db().select({
|
|
886
|
+
providerTenantId: juniorDestinations.providerTenantId,
|
|
887
|
+
sessionSource: juniorConversations.sessionSource
|
|
888
|
+
}).from(juniorConversations).leftJoin(juniorDestinations, eq(juniorDestinations.id, juniorConversations.destinationId)).where(eq(juniorConversations.conversationId, conversationId)).limit(1);
|
|
889
|
+
const destinationTenant = row?.providerTenantId?.trim();
|
|
890
|
+
if (destinationTenant) return destinationTenant;
|
|
891
|
+
const sessionSource = parseSessionSource(row?.sessionSource);
|
|
892
|
+
if (sessionSource?.kind === "slack") return sessionSource.teamId;
|
|
893
|
+
}
|
|
894
|
+
async function findIdentityIdByProviderSubject(executor, args) {
|
|
895
|
+
return (await executor.db().select({ id: juniorIdentities.id }).from(juniorIdentities).where(and(eq(juniorIdentities.provider, args.provider), eq(juniorIdentities.providerSubjectId, args.providerSubjectId), args.providerTenantId === void 0 ? void 0 : eq(juniorIdentities.providerTenantId, args.providerTenantId))).limit(1))[0]?.id;
|
|
896
|
+
}
|
|
897
|
+
async function findJuniorIdentityIdByEmail(executor, email) {
|
|
898
|
+
const normalized = email.trim().toLowerCase();
|
|
899
|
+
if (!normalized) return void 0;
|
|
900
|
+
return (await executor.db().select({ id: juniorIdentities.id }).from(juniorIdentities).where(and(eq(juniorIdentities.provider, "junior"), eq(juniorIdentities.kind, "user"), or(eq(juniorIdentities.providerSubjectId, normalized), and(eq(juniorIdentities.emailNormalized, normalized), eq(juniorIdentities.emailVerified, true))))).limit(1))[0]?.id;
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Resolve a dashboard:<hash> author against junior identities keyed by email.
|
|
904
|
+
* Web actors store userId as dashboard:${sha256(email).slice(0,24)} while the
|
|
905
|
+
* durable identity subject remains the verified email.
|
|
906
|
+
*/
|
|
907
|
+
async function findJuniorIdentityIdByDashboardAuthorId(executor, dashboardAuthorId) {
|
|
908
|
+
if (!dashboardAuthorId.startsWith("dashboard:")) return;
|
|
909
|
+
return (await executor.db().select({ id: juniorIdentities.id }).from(juniorIdentities).where(and(eq(juniorIdentities.provider, "junior"), eq(juniorIdentities.kind, "user"), eq(juniorIdentities.emailVerified, true), sql`${juniorIdentities.emailNormalized} is not null`, sql`concat(
|
|
910
|
+
'dashboard:',
|
|
911
|
+
left(encode(digest(${juniorIdentities.emailNormalized}, 'sha256'), 'hex'), 24)
|
|
912
|
+
) = ${dashboardAuthorId}`)).limit(1))[0]?.id;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Resolve the durable actor identity for one event payload.
|
|
916
|
+
* Human user messages get an identity when author data is enough; other events
|
|
917
|
+
* keep a null actor.
|
|
918
|
+
*/
|
|
919
|
+
async function resolveEventActorIdentityId(executor, args) {
|
|
920
|
+
const data = args.data;
|
|
921
|
+
if (data.type !== "message" && data.type !== "message_updated") return;
|
|
922
|
+
if (typeof data.authorIdentityId === "string" && data.authorIdentityId) return data.authorIdentityId;
|
|
923
|
+
if (data.role !== "user") return;
|
|
924
|
+
const author = readAuthor(data.meta);
|
|
925
|
+
if (author.isBot === true) return;
|
|
926
|
+
const userId = parseActorUserId(author.userId);
|
|
927
|
+
if (!userId) return;
|
|
928
|
+
if (SLACK_USER_ID_PATTERN.test(userId)) {
|
|
929
|
+
const tenantId = await loadConversationSlackTenantId(executor, args.conversationId);
|
|
930
|
+
const observation = {
|
|
931
|
+
kind: "user",
|
|
932
|
+
provider: "slack",
|
|
933
|
+
providerSubjectId: userId,
|
|
934
|
+
...tenantId ? { providerTenantId: tenantId } : void 0,
|
|
935
|
+
...author.fullName ? { displayName: author.fullName } : void 0,
|
|
936
|
+
...author.userName ? { handle: author.userName } : void 0,
|
|
937
|
+
...author.email ? {
|
|
938
|
+
email: author.email,
|
|
939
|
+
emailVerified: true
|
|
940
|
+
} : void 0
|
|
941
|
+
};
|
|
942
|
+
if (tenantId) return (await upsertIdentity(executor, observation, args.nowMs)).id;
|
|
943
|
+
return await findIdentityIdByProviderSubject(executor, {
|
|
944
|
+
provider: "slack",
|
|
945
|
+
providerSubjectId: userId
|
|
946
|
+
}) ?? void 0;
|
|
947
|
+
}
|
|
948
|
+
const email = typeof author.email === "string" && author.email.includes("@") ? author.email.trim().toLowerCase() : userId.includes("@") ? userId.trim().toLowerCase() : void 0;
|
|
949
|
+
if (email) return (await upsertIdentity(executor, {
|
|
950
|
+
kind: "user",
|
|
951
|
+
provider: "junior",
|
|
952
|
+
providerSubjectId: email,
|
|
953
|
+
email,
|
|
954
|
+
emailVerified: true,
|
|
955
|
+
...author.fullName ? { displayName: author.fullName } : void 0,
|
|
956
|
+
metadata: { platform: "web" }
|
|
957
|
+
}, args.nowMs)).id;
|
|
958
|
+
if (userId.startsWith("dashboard:")) return findJuniorIdentityIdByDashboardAuthorId(executor, userId);
|
|
959
|
+
return findJuniorIdentityIdByEmail(executor, userId);
|
|
960
|
+
}
|
|
961
|
+
/** Drop payload-only authorIdentityId after lifting it onto the event column. */
|
|
962
|
+
function stripPayloadAuthorIdentityId(data) {
|
|
963
|
+
if (data.type !== "message" && data.type !== "message_updated" || data.authorIdentityId === void 0) return data;
|
|
964
|
+
const { authorIdentityId: _authorIdentityId, ...rest } = data;
|
|
965
|
+
return rest;
|
|
966
|
+
}
|
|
967
|
+
//#endregion
|
|
968
|
+
//#region src/db/postgres-json.ts
|
|
969
|
+
/**
|
|
970
|
+
* Replace actual NUL characters that PostgreSQL cannot store in JSONB.
|
|
971
|
+
* Literal `\\u0000` text is preserved because it contains no NUL character.
|
|
972
|
+
*/
|
|
973
|
+
function sanitizePostgresJson(value) {
|
|
974
|
+
if (typeof value === "string") return value.replaceAll("\0", " ");
|
|
975
|
+
if (Array.isArray(value)) return value.map((item) => sanitizePostgresJson(item));
|
|
976
|
+
if (value === null || typeof value !== "object") return value;
|
|
977
|
+
const prototype = Object.getPrototypeOf(value);
|
|
978
|
+
if (prototype !== Object.prototype && prototype !== null) return value;
|
|
979
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizePostgresJson(item)]));
|
|
980
|
+
}
|
|
981
|
+
//#endregion
|
|
982
|
+
//#region src/chat/conversations/sql/event-lock.ts
|
|
983
|
+
/** Serialize event sequence allocation with other conversation writes. */
|
|
984
|
+
async function withConversationEventLock(executor, conversationId, callback) {
|
|
985
|
+
return executor.withLock(`junior_conversation:${conversationId}`, callback);
|
|
986
|
+
}
|
|
987
|
+
//#endregion
|
|
988
|
+
//#region src/chat/conversations/sql/history.ts
|
|
989
|
+
const messageHistoryEventTypes = [
|
|
990
|
+
"message",
|
|
991
|
+
"message_updated",
|
|
992
|
+
"message_handled"
|
|
993
|
+
];
|
|
994
|
+
const HUMAN_INSTRUCTION_PLATFORMS = /* @__PURE__ */ new Set([
|
|
995
|
+
"slack",
|
|
996
|
+
"local",
|
|
997
|
+
"web"
|
|
998
|
+
]);
|
|
999
|
+
/**
|
|
1000
|
+
* Whether one event should restore an archived conversation to the feed.
|
|
1001
|
+
*
|
|
1002
|
+
* Archive hides finished noise until a human comes back. Resource events,
|
|
1003
|
+
* turn lifecycle, compaction, and other system writes may still refresh
|
|
1004
|
+
* activity clocks, but they must not unarchive on their own.
|
|
1005
|
+
*/
|
|
1006
|
+
function eventUnarchivesConversation(data) {
|
|
1007
|
+
if (data.type === "user_message") {
|
|
1008
|
+
const provenance = data.provenance;
|
|
1009
|
+
if (provenance.authority !== "instruction") return false;
|
|
1010
|
+
const platform = provenance.actor?.platform;
|
|
1011
|
+
return platform === void 0 || HUMAN_INSTRUCTION_PLATFORMS.has(platform);
|
|
1012
|
+
}
|
|
1013
|
+
if (data.type !== "message") return false;
|
|
1014
|
+
if (data.role !== "user") return false;
|
|
1015
|
+
const meta = data.meta;
|
|
1016
|
+
if (!meta) return true;
|
|
1017
|
+
if (typeof meta.eventType === "string" && meta.eventType.length > 0) return false;
|
|
1018
|
+
const author = meta.author;
|
|
1019
|
+
if (author && typeof author === "object" && !Array.isArray(author) && author.isBot === true) return false;
|
|
1020
|
+
return true;
|
|
1021
|
+
}
|
|
1022
|
+
/** Split validated event data into column-lifted and JSON payload fields. */
|
|
1023
|
+
function insertFromEvent(conversationId, seq, historyVersion, event, actorIdentityId) {
|
|
1024
|
+
const stripped = stripPayloadAuthorIdentityId(event.data);
|
|
1025
|
+
const { type, ...payload } = conversationEventDataSchema.parse(stripped);
|
|
1026
|
+
return {
|
|
1027
|
+
conversationId,
|
|
1028
|
+
seq,
|
|
1029
|
+
historyVersion,
|
|
1030
|
+
schemaVersion: 1,
|
|
1031
|
+
idempotencyKey: event.idempotencyKey ?? null,
|
|
1032
|
+
type,
|
|
1033
|
+
payload: sanitizePostgresJson(payload),
|
|
1034
|
+
actorIdentityId: actorIdentityId ?? null,
|
|
1035
|
+
createdAt: new Date(event.createdAtMs)
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
/** Parse one physical event row into the storage-compatible domain envelope. */
|
|
1039
|
+
function eventFromRow(row) {
|
|
1040
|
+
const payload = row.actorIdentityId && typeof row.payload === "object" && row.payload !== null && !Array.isArray(row.payload) && (row.type === "message" || row.type === "message_updated") && !("authorIdentityId" in row.payload) ? {
|
|
1041
|
+
...row.payload,
|
|
1042
|
+
authorIdentityId: row.actorIdentityId
|
|
1043
|
+
} : row.payload;
|
|
1044
|
+
return decodeStoredConversationEvent({
|
|
1045
|
+
schemaVersion: row.schemaVersion,
|
|
1046
|
+
seq: row.seq,
|
|
1047
|
+
historyVersion: row.historyVersion,
|
|
1048
|
+
...row.idempotencyKey ? { idempotencyKey: row.idempotencyKey } : void 0,
|
|
1049
|
+
createdAtMs: row.createdAt.getTime(),
|
|
1050
|
+
type: row.type,
|
|
1051
|
+
payload
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
/** Decode the summary row that defines a readable message-history suffix. */
|
|
1055
|
+
function messagesSummarizedEventFromRow(row) {
|
|
1056
|
+
const event = eventFromRow(row);
|
|
1057
|
+
if (event.schemaVersion !== 1 || event.data.type !== "messages_summarized") throw new Error("Message compaction row did not decode as messages_summarized");
|
|
1058
|
+
return {
|
|
1059
|
+
...event,
|
|
1060
|
+
schemaVersion: 1,
|
|
1061
|
+
data: event.data
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
var SqlConversationEventStore = class {
|
|
1065
|
+
executor;
|
|
1066
|
+
constructor(executor) {
|
|
1067
|
+
this.executor = executor;
|
|
1068
|
+
}
|
|
1069
|
+
async append(conversationId, events, options = {}) {
|
|
1070
|
+
const parsed = events.map((event) => newConversationEventSchema.parse(event));
|
|
1071
|
+
if (parsed.length === 0) return [];
|
|
1072
|
+
return withConversationEventLock(this.executor, conversationId, async () => {
|
|
1073
|
+
const existingKeys = parsed.map((event) => event.idempotencyKey).filter((key) => key !== void 0);
|
|
1074
|
+
const persistedKeys = existingKeys.length === 0 ? /* @__PURE__ */ new Set() : new Set((await this.executor.db().select({ key: juniorConversationEvents.idempotencyKey }).from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), inArray(juniorConversationEvents.idempotencyKey, existingKeys)))).flatMap((row) => row.key ? [row.key] : []));
|
|
1075
|
+
const acceptedKeys = new Set(persistedKeys);
|
|
1076
|
+
const pending = parsed.filter((event) => {
|
|
1077
|
+
if (event.idempotencyKey === void 0) return true;
|
|
1078
|
+
if (acceptedKeys.has(event.idempotencyKey)) return false;
|
|
1079
|
+
acceptedKeys.add(event.idempotencyKey);
|
|
1080
|
+
return true;
|
|
1081
|
+
});
|
|
1082
|
+
if (pending.length === 0) return [];
|
|
1083
|
+
const newestCreatedAtMs = Math.max(...pending.map((event) => event.createdAtMs));
|
|
1084
|
+
await ensureConversationRow(this.executor, conversationId, newestCreatedAtMs, options);
|
|
1085
|
+
const cursor = await this.readCursor(conversationId);
|
|
1086
|
+
const historyVersion = cursor.maxHistoryVersion ?? 0;
|
|
1087
|
+
let seq = cursor.nextSeq;
|
|
1088
|
+
const rows = [];
|
|
1089
|
+
for (const event of pending) {
|
|
1090
|
+
const actorIdentityId = await resolveEventActorIdentityId(this.executor, {
|
|
1091
|
+
conversationId,
|
|
1092
|
+
data: event.data,
|
|
1093
|
+
nowMs: event.createdAtMs
|
|
1094
|
+
});
|
|
1095
|
+
rows.push(insertFromEvent(conversationId, seq++, historyVersion, event, actorIdentityId));
|
|
1096
|
+
if (actorIdentityId) await recordConversationParticipant(this.executor, {
|
|
1097
|
+
actorIdentityId,
|
|
1098
|
+
conversationId,
|
|
1099
|
+
atMs: event.createdAtMs,
|
|
1100
|
+
restoreArchive: eventUnarchivesConversation(event.data)
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
await this.executor.db().insert(juniorConversationEvents).values(rows);
|
|
1104
|
+
return rows.map((row) => ({
|
|
1105
|
+
seq: row.seq,
|
|
1106
|
+
historyVersion: row.historyVersion
|
|
1107
|
+
}));
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
async replaceHistory(conversationId, replacement) {
|
|
1111
|
+
const parsed = historyReplacementSchema.parse(replacement);
|
|
1112
|
+
await withConversationEventLock(this.executor, conversationId, async () => {
|
|
1113
|
+
await ensureConversationRow(this.executor, conversationId, Date.now());
|
|
1114
|
+
const cursor = await this.readCursor(conversationId);
|
|
1115
|
+
const historyVersion = (cursor.maxHistoryVersion ?? 0) + 1;
|
|
1116
|
+
await this.executor.db().insert(juniorConversationEvents).values(insertFromEvent(conversationId, cursor.nextSeq, historyVersion, parsed));
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
async loadCurrentHistoryVersion(conversationId) {
|
|
1120
|
+
return (await this.readCursor(conversationId)).maxHistoryVersion ?? 0;
|
|
1121
|
+
}
|
|
1122
|
+
async loadCurrentHistory(conversationId) {
|
|
1123
|
+
const cursor = await this.readCursor(conversationId);
|
|
1124
|
+
if (cursor.maxHistoryVersion === null) return [];
|
|
1125
|
+
return (await this.executor.db().select().from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), eq(juniorConversationEvents.historyVersion, cursor.maxHistoryVersion))).orderBy(asc(juniorConversationEvents.seq))).map(eventFromRow);
|
|
1126
|
+
}
|
|
1127
|
+
async loadByIdempotencyKey(conversationId, idempotencyKey) {
|
|
1128
|
+
const [row] = await this.executor.db().select().from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), eq(juniorConversationEvents.idempotencyKey, idempotencyKey))).limit(1);
|
|
1129
|
+
return row ? eventFromRow(row) : void 0;
|
|
1130
|
+
}
|
|
1131
|
+
async loadHistoryContaining(conversationId, seq, throughSeq) {
|
|
1132
|
+
const [boundary] = await this.executor.db().select({ historyVersion: juniorConversationEvents.historyVersion }).from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), eq(juniorConversationEvents.seq, seq))).limit(1);
|
|
1133
|
+
if (!boundary) return void 0;
|
|
1134
|
+
return (await this.executor.db().select().from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), eq(juniorConversationEvents.historyVersion, boundary.historyVersion), throughSeq === void 0 ? void 0 : lte(juniorConversationEvents.seq, throughSeq))).orderBy(asc(juniorConversationEvents.seq))).map(eventFromRow);
|
|
1135
|
+
}
|
|
1136
|
+
async loadLatestStructuredEvent(conversationId, namespace, name) {
|
|
1137
|
+
const [row] = await this.executor.db().select().from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), eq(juniorConversationEvents.type, "structured_event"), sql`${juniorConversationEvents.payload}->>'namespace' = ${namespace}`, sql`${juniorConversationEvents.payload}->>'name' = ${name}`)).orderBy(desc(juniorConversationEvents.seq)).limit(1);
|
|
1138
|
+
return row ? eventFromRow(row) : void 0;
|
|
1139
|
+
}
|
|
1140
|
+
async loadLatestInstruction(conversationId) {
|
|
1141
|
+
const [row] = await this.executor.db().select().from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), eq(juniorConversationEvents.type, "user_message"), sql`${juniorConversationEvents.payload}->'provenance'->>'authority' = 'instruction'`)).orderBy(desc(juniorConversationEvents.seq)).limit(1);
|
|
1142
|
+
return row ? eventFromRow(row) : void 0;
|
|
1143
|
+
}
|
|
1144
|
+
async loadMessageHistory(conversationId) {
|
|
1145
|
+
const [compactionRow] = await this.executor.db().select().from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), eq(juniorConversationEvents.type, "messages_summarized"))).orderBy(desc(juniorConversationEvents.seq)).limit(1);
|
|
1146
|
+
const compaction = compactionRow ? messagesSummarizedEventFromRow(compactionRow) : void 0;
|
|
1147
|
+
const historyFromSeq = compaction?.data.historyFromSeq ?? 0;
|
|
1148
|
+
return {
|
|
1149
|
+
events: (await this.executor.db().select().from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), inArray(juniorConversationEvents.type, messageHistoryEventTypes), gte(juniorConversationEvents.seq, historyFromSeq))).orderBy(asc(juniorConversationEvents.seq))).map(eventFromRow),
|
|
1150
|
+
compaction,
|
|
1151
|
+
historyFromSeq
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
async loadHistory(conversationId) {
|
|
1155
|
+
return (await this.executor.db().select().from(juniorConversationEvents).where(eq(juniorConversationEvents.conversationId, conversationId)).orderBy(asc(juniorConversationEvents.seq))).map(eventFromRow);
|
|
1156
|
+
}
|
|
1157
|
+
async query(conversationId, query) {
|
|
1158
|
+
if (query.limit < 1) throw new Error("Conversation event query limit must be at least 1");
|
|
1159
|
+
if (query.afterSeq !== void 0 && query.beforeSeq !== void 0 && query.afterSeq >= query.beforeSeq) return {
|
|
1160
|
+
events: [],
|
|
1161
|
+
hasOlder: false,
|
|
1162
|
+
hasNewer: false
|
|
1163
|
+
};
|
|
1164
|
+
const typeFilter = query.types && query.types.length > 0 ? inArray(juniorConversationEvents.type, [...query.types]) : void 0;
|
|
1165
|
+
const bounds = and(eq(juniorConversationEvents.conversationId, conversationId), query.afterSeq === void 0 ? void 0 : gt(juniorConversationEvents.seq, query.afterSeq), query.beforeSeq === void 0 ? void 0 : lt(juniorConversationEvents.seq, query.beforeSeq), typeFilter);
|
|
1166
|
+
const newestFirst = query.afterSeq === void 0;
|
|
1167
|
+
const rows = await this.executor.db().select().from(juniorConversationEvents).where(bounds).orderBy(newestFirst ? desc(juniorConversationEvents.seq) : asc(juniorConversationEvents.seq)).limit(query.limit + 1);
|
|
1168
|
+
const overflow = rows.length > query.limit;
|
|
1169
|
+
const pageRows = overflow ? rows.slice(0, query.limit) : rows;
|
|
1170
|
+
const events = (newestFirst ? [...pageRows].reverse() : pageRows).map(eventFromRow);
|
|
1171
|
+
if (events.length === 0) return {
|
|
1172
|
+
events,
|
|
1173
|
+
hasOlder: false,
|
|
1174
|
+
hasNewer: false
|
|
1175
|
+
};
|
|
1176
|
+
const firstSeq = events[0].seq;
|
|
1177
|
+
const lastSeq = events[events.length - 1].seq;
|
|
1178
|
+
return {
|
|
1179
|
+
events,
|
|
1180
|
+
hasOlder: newestFirst && overflow ? true : await this.hasMatchingEvent({
|
|
1181
|
+
conversationId,
|
|
1182
|
+
beforeSeq: firstSeq,
|
|
1183
|
+
typeFilter
|
|
1184
|
+
}),
|
|
1185
|
+
hasNewer: !newestFirst && overflow ? true : await this.hasMatchingEvent({
|
|
1186
|
+
conversationId,
|
|
1187
|
+
afterSeq: lastSeq,
|
|
1188
|
+
typeFilter
|
|
1189
|
+
})
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
/** Return whether any matching event exists inside the exclusive seq window. */
|
|
1193
|
+
async hasMatchingEvent(args) {
|
|
1194
|
+
if (args.afterSeq !== void 0 && args.beforeSeq !== void 0 && args.afterSeq >= args.beforeSeq) return false;
|
|
1195
|
+
const [row] = await this.executor.db().select({ seq: juniorConversationEvents.seq }).from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, args.conversationId), args.afterSeq === void 0 ? void 0 : gt(juniorConversationEvents.seq, args.afterSeq), args.beforeSeq === void 0 ? void 0 : lt(juniorConversationEvents.seq, args.beforeSeq), args.typeFilter)).limit(1);
|
|
1196
|
+
return row !== void 0;
|
|
1197
|
+
}
|
|
1198
|
+
/** Read the next sequence and active model-history version. */
|
|
1199
|
+
async readCursor(conversationId) {
|
|
1200
|
+
const rows = await this.executor.db().select({
|
|
1201
|
+
maxSeq: sql`max(${juniorConversationEvents.seq})`,
|
|
1202
|
+
maxHistoryVersion: sql`max(${juniorConversationEvents.historyVersion})`
|
|
1203
|
+
}).from(juniorConversationEvents).where(eq(juniorConversationEvents.conversationId, conversationId));
|
|
1204
|
+
const maxSeq = rows[0]?.maxSeq;
|
|
1205
|
+
const maxHistoryVersion = rows[0]?.maxHistoryVersion;
|
|
1206
|
+
return {
|
|
1207
|
+
maxHistoryVersion: maxHistoryVersion === null || maxHistoryVersion === void 0 ? null : Number(maxHistoryVersion),
|
|
1208
|
+
nextSeq: maxSeq === null || maxSeq === void 0 ? 0 : Number(maxSeq) + 1
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
1212
|
+
/** Create a SQL-backed canonical conversation event store. */
|
|
1213
|
+
function createSqlConversationEventStore(executor) {
|
|
1214
|
+
return new SqlConversationEventStore(executor);
|
|
1215
|
+
}
|
|
1216
|
+
//#endregion
|
|
1217
|
+
//#region src/chat/conversations/sql/message-search.ts
|
|
1218
|
+
const EXCERPT_WITHOUT_QUERY_CHARS = 240;
|
|
1219
|
+
var SqlConversationMessageSearchStore = class {
|
|
1220
|
+
executor;
|
|
1221
|
+
constructor(executor) {
|
|
1222
|
+
this.executor = executor;
|
|
1223
|
+
}
|
|
1224
|
+
async search(args) {
|
|
1225
|
+
const db = this.executor.db();
|
|
1226
|
+
const query = args.filters.query?.trim() || void 0;
|
|
1227
|
+
const text = sql`${juniorConversationEvents.payload}->>'text'`;
|
|
1228
|
+
const role = sql`${juniorConversationEvents.payload}->>'role'`;
|
|
1229
|
+
const messageId = sql`${juniorConversationEvents.payload}->>'messageId'`;
|
|
1230
|
+
const tsquery = query ? sql`websearch_to_tsquery('english', ${query})` : void 0;
|
|
1231
|
+
const rank = tsquery ? sql`ts_rank_cd(to_tsvector('english', ${text}), ${tsquery})` : sql`1`;
|
|
1232
|
+
const excerpt = tsquery ? sql`ts_headline('english', ${text}, ${tsquery}, 'MaxFragments=2, MinWords=8, MaxWords=40, FragmentDelimiter=" … ", StartSel=**, StopSel=**')` : sql`left(${text}, ${EXCERPT_WITHOUT_QUERY_CHARS})`;
|
|
1233
|
+
const conditions = [
|
|
1234
|
+
eq(juniorConversations.source, "slack"),
|
|
1235
|
+
isNull(juniorConversations.parentConversationId),
|
|
1236
|
+
isNull(juniorConversations.transcriptPurgedAt),
|
|
1237
|
+
ne(juniorConversations.conversationId, args.currentConversationId),
|
|
1238
|
+
eq(juniorDestinations.provider, args.scope.provider),
|
|
1239
|
+
eq(juniorDestinations.providerTenantId, args.scope.providerTenantId),
|
|
1240
|
+
eq(juniorDestinations.visibility, "public"),
|
|
1241
|
+
eq(juniorConversationEvents.type, "message"),
|
|
1242
|
+
sql`${role} in ('user', 'assistant')`
|
|
1243
|
+
];
|
|
1244
|
+
if (tsquery) conditions.push(sql`to_tsvector('english', ${text}) @@ ${tsquery}`);
|
|
1245
|
+
if (args.filters.channelId) conditions.push(eq(juniorDestinations.providerDestinationId, args.filters.channelId));
|
|
1246
|
+
if (args.filters.afterMs !== void 0 && Number.isFinite(args.filters.afterMs)) conditions.push(gte(juniorConversations.lastActivityAt, new Date(args.filters.afterMs)));
|
|
1247
|
+
if (args.filters.beforeMs !== void 0 && Number.isFinite(args.filters.beforeMs)) conditions.push(lt(juniorConversations.lastActivityAt, new Date(args.filters.beforeMs)));
|
|
1248
|
+
if (args.filters.annotation) {
|
|
1249
|
+
const annotation = args.filters.annotation.toLowerCase();
|
|
1250
|
+
const nestedAnnotationPrefix = `${annotation}#`;
|
|
1251
|
+
conditions.push(sql`exists (
|
|
1252
|
+
select 1
|
|
1253
|
+
from ${juniorConversationAnnotations}
|
|
1254
|
+
where ${and(eq(juniorConversationAnnotations.conversationId, juniorConversations.conversationId), sql`(
|
|
1255
|
+
lower(${juniorConversationAnnotations.key}) = ${annotation}
|
|
1256
|
+
or starts_with(
|
|
1257
|
+
lower(${juniorConversationAnnotations.key}),
|
|
1258
|
+
${nestedAnnotationPrefix}
|
|
1259
|
+
)
|
|
1260
|
+
)`)}
|
|
1261
|
+
)`);
|
|
1262
|
+
}
|
|
1263
|
+
const bestPerConversation = db.selectDistinctOn([juniorConversations.conversationId], {
|
|
1264
|
+
channelName: juniorDestinations.displayName,
|
|
1265
|
+
conversationId: juniorConversations.conversationId,
|
|
1266
|
+
excerpt: excerpt.as("excerpt"),
|
|
1267
|
+
lastActivityAt: juniorConversations.lastActivityAt,
|
|
1268
|
+
messageCreatedAt: juniorConversationEvents.createdAt,
|
|
1269
|
+
messageId: messageId.as("message_id"),
|
|
1270
|
+
providerDestinationId: juniorDestinations.providerDestinationId,
|
|
1271
|
+
rank: rank.as("rank"),
|
|
1272
|
+
role: role.as("role")
|
|
1273
|
+
}).from(juniorConversationEvents).innerJoin(juniorConversations, eq(juniorConversations.conversationId, juniorConversationEvents.conversationId)).innerJoin(juniorDestinations, eq(juniorDestinations.id, juniorConversations.destinationId)).where(and(...conditions)).orderBy(juniorConversations.conversationId, desc(rank), desc(juniorConversationEvents.createdAt)).as("best_conversation_matches");
|
|
1274
|
+
return (await db.select().from(bestPerConversation).orderBy(desc(bestPerConversation.rank), desc(bestPerConversation.lastActivityAt), desc(bestPerConversation.messageCreatedAt)).limit(args.limit)).map((row) => ({
|
|
1275
|
+
conversationId: row.conversationId,
|
|
1276
|
+
excerpt: row.excerpt,
|
|
1277
|
+
messageCreatedAtMs: row.messageCreatedAt.getTime(),
|
|
1278
|
+
messageId: row.messageId,
|
|
1279
|
+
providerDestinationId: row.providerDestinationId,
|
|
1280
|
+
role: row.role,
|
|
1281
|
+
...row.channelName ? { channelName: row.channelName } : void 0
|
|
1282
|
+
}));
|
|
1283
|
+
}
|
|
1284
|
+
};
|
|
1285
|
+
/** Create a SQL-backed public workspace message search store. */
|
|
1286
|
+
function createSqlConversationMessageSearchStore(executor) {
|
|
1287
|
+
return new SqlConversationMessageSearchStore(executor);
|
|
1288
|
+
}
|
|
1289
|
+
//#endregion
|
|
1290
|
+
//#region src/chat/db.ts
|
|
1291
|
+
var db_exports = /* @__PURE__ */ __exportAll({
|
|
1292
|
+
closeDb: () => closeDb,
|
|
1293
|
+
getConversationEventStore: () => getConversationEventStore,
|
|
1294
|
+
getConversationMessageSearchStore: () => getConversationMessageSearchStore,
|
|
1295
|
+
getConversationStore: () => getConversationStore,
|
|
1296
|
+
getDb: () => getDb,
|
|
1297
|
+
getSqlExecutor: () => getSqlExecutor
|
|
1298
|
+
});
|
|
1299
|
+
let current;
|
|
1300
|
+
function createDb(args) {
|
|
1301
|
+
return createJuniorSqlExecutor({
|
|
1302
|
+
connectionString: args.databaseUrl,
|
|
1303
|
+
driver: args.driver,
|
|
1304
|
+
statementTimeoutMs: args.statementTimeoutMs
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
/** Return the process SQL executor for SQL-specific queries and transactions. */
|
|
1308
|
+
function getSqlExecutor() {
|
|
1309
|
+
const { sql } = getChatConfig();
|
|
1310
|
+
if (current?.databaseUrl !== sql.databaseUrl || current.driver !== sql.driver || current.statementTimeoutMs !== sql.statementTimeoutMs) {
|
|
1311
|
+
if (current) {
|
|
1312
|
+
const previous = current;
|
|
1313
|
+
current = void 0;
|
|
1314
|
+
previous.db.close().catch(() => void 0);
|
|
1315
|
+
}
|
|
1316
|
+
const db = createDb({
|
|
1317
|
+
databaseUrl: sql.databaseUrl,
|
|
1318
|
+
driver: sql.driver,
|
|
1319
|
+
statementTimeoutMs: sql.statementTimeoutMs
|
|
1320
|
+
});
|
|
1321
|
+
current = {
|
|
1322
|
+
databaseUrl: sql.databaseUrl,
|
|
1323
|
+
driver: sql.driver,
|
|
1324
|
+
statementTimeoutMs: sql.statementTimeoutMs,
|
|
1325
|
+
db,
|
|
1326
|
+
store: createSqlStore(db),
|
|
1327
|
+
eventStore: createSqlConversationEventStore(db),
|
|
1328
|
+
searchStore: createSqlConversationMessageSearchStore(db)
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
return current.db;
|
|
1332
|
+
}
|
|
1333
|
+
/** Return the process Drizzle database. */
|
|
1334
|
+
function getDb() {
|
|
1335
|
+
return getSqlExecutor().db();
|
|
1336
|
+
}
|
|
1337
|
+
/** Return the SQL-backed conversation store. */
|
|
1338
|
+
function getConversationStore() {
|
|
1339
|
+
getSqlExecutor();
|
|
1340
|
+
return current.store;
|
|
1341
|
+
}
|
|
1342
|
+
/** Return the canonical SQL-backed conversation event store. */
|
|
1343
|
+
function getConversationEventStore() {
|
|
1344
|
+
getSqlExecutor();
|
|
1345
|
+
return current.eventStore;
|
|
1346
|
+
}
|
|
1347
|
+
/** Return the SQL-backed public provider-tenant conversation search store. */
|
|
1348
|
+
function getConversationMessageSearchStore() {
|
|
1349
|
+
getSqlExecutor();
|
|
1350
|
+
return current.searchStore;
|
|
1351
|
+
}
|
|
1352
|
+
/** Close the process SQL database when it has been opened. */
|
|
1353
|
+
async function closeDb() {
|
|
1354
|
+
const previous = current;
|
|
1355
|
+
current = void 0;
|
|
1356
|
+
await previous?.db.close();
|
|
1357
|
+
}
|
|
1358
|
+
//#endregion
|
|
1359
|
+
//#region src/chat/plugins/annotations.ts
|
|
1360
|
+
function annotationFromRow(row) {
|
|
1361
|
+
const parsed = conversationAnnotationInputSchema.safeParse(row.annotation);
|
|
1362
|
+
if (!parsed.success) return void 0;
|
|
1363
|
+
return {
|
|
1364
|
+
...parsed.data,
|
|
1365
|
+
plugin: row.plugin,
|
|
1366
|
+
createdAt: row.createdAt.toISOString(),
|
|
1367
|
+
updatedAt: row.updatedAt.toISOString()
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
function createPluginAnnotations(args) {
|
|
1371
|
+
return {
|
|
1372
|
+
async upsert(input) {
|
|
1373
|
+
const annotation = conversationAnnotationInputSchema.parse(input);
|
|
1374
|
+
const now = /* @__PURE__ */ new Date();
|
|
1375
|
+
await args.db.insert(juniorConversationAnnotations).values({
|
|
1376
|
+
annotation,
|
|
1377
|
+
conversationId: args.conversationId,
|
|
1378
|
+
createdAt: now,
|
|
1379
|
+
key: annotation.key,
|
|
1380
|
+
kind: annotation.kind,
|
|
1381
|
+
plugin: args.plugin,
|
|
1382
|
+
updatedAt: now
|
|
1383
|
+
}).onConflictDoUpdate({
|
|
1384
|
+
target: [
|
|
1385
|
+
juniorConversationAnnotations.conversationId,
|
|
1386
|
+
juniorConversationAnnotations.plugin,
|
|
1387
|
+
juniorConversationAnnotations.kind,
|
|
1388
|
+
juniorConversationAnnotations.key
|
|
1389
|
+
],
|
|
1390
|
+
set: {
|
|
1391
|
+
annotation,
|
|
1392
|
+
updatedAt: now
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
},
|
|
1396
|
+
async remove(kind, key) {
|
|
1397
|
+
await args.db.delete(juniorConversationAnnotations).where(and(eq(juniorConversationAnnotations.conversationId, args.conversationId), eq(juniorConversationAnnotations.plugin, args.plugin), eq(juniorConversationAnnotations.kind, kind), eq(juniorConversationAnnotations.key, key)));
|
|
1398
|
+
},
|
|
1399
|
+
async list() {
|
|
1400
|
+
return (await listConversationAnnotations(args.db, args.conversationId)).filter((a) => a.plugin === args.plugin);
|
|
1401
|
+
}
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
/** Load stored annotations for one conversation, newest update first. */
|
|
1405
|
+
async function listConversationAnnotations(db, conversationId) {
|
|
1406
|
+
return (await listConversationAnnotationsById(db, [conversationId])).get(conversationId) ?? [];
|
|
1407
|
+
}
|
|
1408
|
+
/** Load stored annotations for a bounded set of conversations. */
|
|
1409
|
+
async function listConversationAnnotationsById(db, conversationIds) {
|
|
1410
|
+
const byConversation = /* @__PURE__ */ new Map();
|
|
1411
|
+
if (conversationIds.length === 0) return byConversation;
|
|
1412
|
+
const rows = await db.select().from(juniorConversationAnnotations).where(inArray(juniorConversationAnnotations.conversationId, [...conversationIds])).orderBy(desc(juniorConversationAnnotations.updatedAt));
|
|
1413
|
+
for (const row of rows) {
|
|
1414
|
+
const annotation = annotationFromRow(row);
|
|
1415
|
+
if (!annotation) continue;
|
|
1416
|
+
const current = byConversation.get(row.conversationId);
|
|
1417
|
+
if (current) current.push(annotation);
|
|
1418
|
+
else byConversation.set(row.conversationId, [annotation]);
|
|
1419
|
+
}
|
|
1420
|
+
return byConversation;
|
|
1421
|
+
}
|
|
1422
|
+
//#endregion
|
|
1423
|
+
//#region src/chat/plugins/conversation-events.ts
|
|
1424
|
+
function registeredDefinition(plugin, value) {
|
|
1425
|
+
return plugin.conversationEvents?.find((definition) => definition === value.definition);
|
|
1426
|
+
}
|
|
1427
|
+
/** Keep operation idempotency stable when a registered event schema advances. */
|
|
1428
|
+
function eventIdentityVersion(plugin, definition) {
|
|
1429
|
+
let version = definition.version;
|
|
1430
|
+
for (const candidate of plugin.conversationEvents ?? []) if (candidate.eventName === definition.eventName) version = Math.min(version, candidate.version);
|
|
1431
|
+
return version;
|
|
1432
|
+
}
|
|
1433
|
+
/** Create a conversation-bound writer for one plugin operation. */
|
|
1434
|
+
function createPluginConversationEvents(args) {
|
|
1435
|
+
return { async emit(value) {
|
|
1436
|
+
const definition = registeredDefinition(args.plugin, value);
|
|
1437
|
+
if (!definition) throw new Error(`Plugin "${args.plugin.manifest.name}" cannot emit an unregistered conversation event.`);
|
|
1438
|
+
const content = definition.parse(value.data);
|
|
1439
|
+
const identityVersion = eventIdentityVersion(args.plugin, definition);
|
|
1440
|
+
await getConversationEventStore().append(args.conversationId, [{
|
|
1441
|
+
createdAtMs: Date.now(),
|
|
1442
|
+
idempotencyKey: `plugin:${args.plugin.manifest.name}:operation:${args.operationId}:event:${definition.eventName}@${identityVersion}`,
|
|
1443
|
+
data: {
|
|
1444
|
+
type: "structured_event",
|
|
1445
|
+
namespace: args.plugin.manifest.name,
|
|
1446
|
+
name: definition.eventName,
|
|
1447
|
+
version: definition.version,
|
|
1448
|
+
turnId: args.turnId,
|
|
1449
|
+
content
|
|
1450
|
+
}
|
|
1451
|
+
}], { activity: "preserve" });
|
|
1452
|
+
} };
|
|
1453
|
+
}
|
|
1454
|
+
/** Render a stored plugin event through its currently registered definition. */
|
|
1455
|
+
function renderPluginConversationEvent(args) {
|
|
1456
|
+
const definition = getPlugins().find((candidate) => candidate.manifest.name === args.namespace)?.conversationEvents?.find((candidate) => candidate.eventName === args.name && candidate.version === args.version);
|
|
1457
|
+
if (!definition) return void 0;
|
|
1458
|
+
return definition.renderEvent(definition.parse(args.content));
|
|
1459
|
+
}
|
|
1460
|
+
//#endregion
|
|
1461
|
+
//#region src/chat/plugins/conversation-event-stats.ts
|
|
1462
|
+
const DAY_MS = 864e5;
|
|
1463
|
+
const costRowSchema = z.object({
|
|
1464
|
+
costUsd: z.number().finite().nonnegative(),
|
|
1465
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2})?$/),
|
|
1466
|
+
events: z.number().int().nonnegative()
|
|
1467
|
+
}).strict();
|
|
1468
|
+
function startOfUtcDay(value) {
|
|
1469
|
+
const date = new Date(value);
|
|
1470
|
+
date.setUTCHours(0, 0, 0, 0);
|
|
1471
|
+
return date;
|
|
1472
|
+
}
|
|
1473
|
+
function registeredEventName(plugin, eventName) {
|
|
1474
|
+
if (!plugin.conversationEvents?.some((definition) => definition.eventName === eventName)) throw new TypeError(`Plugin "${plugin.manifest.name}" did not register event "${eventName}"`);
|
|
1475
|
+
return eventName;
|
|
1476
|
+
}
|
|
1477
|
+
/** Create aggregate event-cost reads bound to one plugin's registered namespace. */
|
|
1478
|
+
function createPluginConversationEventStats(plugin, now = Date.now) {
|
|
1479
|
+
return {
|
|
1480
|
+
async costsByHour(input) {
|
|
1481
|
+
const eventName = registeredEventName(plugin, input.eventName);
|
|
1482
|
+
const hourCount = input.hours ?? 24;
|
|
1483
|
+
const end = new Date(now());
|
|
1484
|
+
end.setUTCMinutes(0, 0, 0);
|
|
1485
|
+
const start = /* @__PURE__ */ new Date(end.getTime() - (hourCount - 1) * 60 * 60 * 1e3);
|
|
1486
|
+
const endExclusive = new Date(end.getTime() + 36e5);
|
|
1487
|
+
const date = sql`TO_CHAR(
|
|
1488
|
+
${juniorConversationEvents.createdAt} AT TIME ZONE 'UTC',
|
|
1489
|
+
'YYYY-MM-DD"T"HH24'
|
|
1490
|
+
)`;
|
|
1491
|
+
const cost = sql`CASE
|
|
1492
|
+
WHEN jsonb_typeof(${juniorConversationEvents.payload}->'content'->'costUsd') = 'number'
|
|
1493
|
+
THEN (${juniorConversationEvents.payload}->'content'->>'costUsd')::double precision
|
|
1494
|
+
ELSE 0
|
|
1495
|
+
END`;
|
|
1496
|
+
const rows = await getDb().select({
|
|
1497
|
+
costUsd: sql`SUM(${cost})::double precision`.mapWith(Number),
|
|
1498
|
+
date,
|
|
1499
|
+
events: sql`COUNT(*)::integer`.mapWith(Number)
|
|
1500
|
+
}).from(juniorConversationEvents).where(and(eq(juniorConversationEvents.type, "structured_event"), sql`${juniorConversationEvents.payload}->>'namespace' = ${plugin.manifest.name}`, sql`${juniorConversationEvents.payload}->>'name' = ${eventName}`, gte(juniorConversationEvents.createdAt, start), lt(juniorConversationEvents.createdAt, endExclusive))).groupBy(date);
|
|
1501
|
+
const byHour = new Map(z.array(costRowSchema).parse(rows).map((row) => [row.date, row]));
|
|
1502
|
+
return Array.from({ length: hourCount }, (_, index) => {
|
|
1503
|
+
const key = new Date(start.getTime() + index * 60 * 60 * 1e3).toISOString().slice(0, 13);
|
|
1504
|
+
return byHour.get(key) ?? {
|
|
1505
|
+
costUsd: 0,
|
|
1506
|
+
date: key,
|
|
1507
|
+
events: 0
|
|
1508
|
+
};
|
|
1509
|
+
});
|
|
1510
|
+
},
|
|
1511
|
+
async costsByDay(input) {
|
|
1512
|
+
const eventName = registeredEventName(plugin, input.eventName);
|
|
1513
|
+
const end = startOfUtcDay(now());
|
|
1514
|
+
const start = /* @__PURE__ */ new Date(end.getTime() - (input.days - 1) * DAY_MS);
|
|
1515
|
+
const endExclusive = new Date(end.getTime() + DAY_MS);
|
|
1516
|
+
const date = sql`TO_CHAR(
|
|
1517
|
+
${juniorConversationEvents.createdAt} AT TIME ZONE 'UTC',
|
|
1518
|
+
'YYYY-MM-DD'
|
|
1519
|
+
)`;
|
|
1520
|
+
const cost = sql`CASE
|
|
1521
|
+
WHEN jsonb_typeof(${juniorConversationEvents.payload}->'content'->'costUsd') = 'number'
|
|
1522
|
+
THEN (${juniorConversationEvents.payload}->'content'->>'costUsd')::double precision
|
|
1523
|
+
ELSE 0
|
|
1524
|
+
END`;
|
|
1525
|
+
const rows = await getDb().select({
|
|
1526
|
+
costUsd: sql`SUM(${cost})::double precision`.mapWith(Number),
|
|
1527
|
+
date,
|
|
1528
|
+
events: sql`COUNT(*)::integer`.mapWith(Number)
|
|
1529
|
+
}).from(juniorConversationEvents).where(and(eq(juniorConversationEvents.type, "structured_event"), sql`${juniorConversationEvents.payload}->>'namespace' = ${plugin.manifest.name}`, sql`${juniorConversationEvents.payload}->>'name' = ${eventName}`, gte(juniorConversationEvents.createdAt, start), lt(juniorConversationEvents.createdAt, endExclusive))).groupBy(date);
|
|
1530
|
+
const byDate = new Map(z.array(costRowSchema).parse(rows).map((row) => [row.date, row]));
|
|
1531
|
+
return Array.from({ length: input.days }, (_, index) => {
|
|
1532
|
+
const date = new Date(start.getTime() + index * DAY_MS).toISOString().slice(0, 10);
|
|
1533
|
+
return byDate.get(date) ?? {
|
|
1534
|
+
costUsd: 0,
|
|
1535
|
+
date,
|
|
1536
|
+
events: 0
|
|
1537
|
+
};
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
//#endregion
|
|
1543
|
+
//#region src/chat/plugins/logging.ts
|
|
1544
|
+
/** Create the host logger exposed to plugin hooks. */
|
|
1545
|
+
function createPluginLogger(plugin) {
|
|
1546
|
+
return {
|
|
1547
|
+
info(message, metadata) {
|
|
1548
|
+
logInfo("plugin.log.info", {
|
|
1549
|
+
"app.log.message": message,
|
|
1550
|
+
"app.plugin.name": plugin,
|
|
1551
|
+
...metadata
|
|
1552
|
+
});
|
|
1553
|
+
},
|
|
1554
|
+
warn(message, metadata) {
|
|
1555
|
+
logWarn("plugin.log.warn", {
|
|
1556
|
+
"app.log.message": message,
|
|
1557
|
+
"app.plugin.name": plugin,
|
|
1558
|
+
...metadata
|
|
1559
|
+
});
|
|
1560
|
+
},
|
|
1561
|
+
error(message, metadata) {
|
|
1562
|
+
logException(new Error(message), "plugin.log.error", {
|
|
1563
|
+
"app.plugin.name": plugin,
|
|
1564
|
+
...metadata
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
//#endregion
|
|
1570
|
+
//#region src/chat/plugins/model.ts
|
|
1571
|
+
/** Create the host-owned structured model capability exposed to plugins. */
|
|
1572
|
+
function createPluginModel(pluginName, options = {}, runtime = {}) {
|
|
1573
|
+
return { async completeObject(input) {
|
|
1574
|
+
const modelId = options.structuredModelId ?? (options.structuredModel === "default" ? defaultModelId(botConfig) : botConfig.fastModelId);
|
|
1575
|
+
const result = await completeObject({
|
|
1576
|
+
modelId,
|
|
1577
|
+
schema: input.schema,
|
|
1578
|
+
prompt: input.prompt,
|
|
1579
|
+
...input.system !== void 0 ? { system: input.system } : void 0,
|
|
1580
|
+
...input.maxTokens !== void 0 ? { maxTokens: input.maxTokens } : void 0,
|
|
1581
|
+
signal: runtime.signal,
|
|
1582
|
+
promptName: `${pluginName}.structured_completion`,
|
|
1583
|
+
metadata: {
|
|
1584
|
+
pluginName,
|
|
1585
|
+
pluginModelRole: "structured"
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
return {
|
|
1589
|
+
object: result.object,
|
|
1590
|
+
...result.costUsd !== void 0 ? { costUsd: result.costUsd } : void 0
|
|
1591
|
+
};
|
|
1592
|
+
} };
|
|
1593
|
+
}
|
|
1594
|
+
/** Create the host-owned embedding capability exposed to prompt hooks. */
|
|
1595
|
+
function createPluginEmbedder(pluginName, runtime = {}) {
|
|
1596
|
+
return { async embedTexts(input) {
|
|
1597
|
+
return await embedTexts({
|
|
1598
|
+
modelId: botConfig.embeddingModelId,
|
|
1599
|
+
texts: input.texts,
|
|
1600
|
+
signal: runtime.signal,
|
|
1601
|
+
metadata: {
|
|
1602
|
+
pluginName,
|
|
1603
|
+
pluginModelRole: "embedding"
|
|
1604
|
+
}
|
|
1605
|
+
});
|
|
1606
|
+
} };
|
|
1607
|
+
}
|
|
1608
|
+
//#endregion
|
|
1609
|
+
//#region src/chat/plugins/state.ts
|
|
1610
|
+
const MAX_PLUGIN_STATE_KEY_LENGTH = 512;
|
|
1611
|
+
function hashKeyPart(value) {
|
|
1612
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
1613
|
+
}
|
|
1614
|
+
function pluginStateKey(plugin, key) {
|
|
1615
|
+
const pluginPrefix = `junior:${plugin}`;
|
|
1616
|
+
if (key === pluginPrefix || key.startsWith(`${pluginPrefix}:`)) return key;
|
|
1617
|
+
return `junior:plugin_state:${hashKeyPart(plugin)}:${hashKeyPart(key)}`;
|
|
1618
|
+
}
|
|
1619
|
+
function validatePluginStateKey(key) {
|
|
1620
|
+
if (!key.trim()) throw new Error("Plugin state key is required");
|
|
1621
|
+
if (key.length > MAX_PLUGIN_STATE_KEY_LENGTH) throw new Error("Plugin state key exceeds the maximum length");
|
|
1622
|
+
}
|
|
1623
|
+
/** Create a durable state namespace scoped to one plugin. */
|
|
1624
|
+
function createPluginState(plugin, adapter) {
|
|
1625
|
+
const getAdapter = () => adapter ?? getStateAdapter();
|
|
1626
|
+
return {
|
|
1627
|
+
async delete(key) {
|
|
1628
|
+
validatePluginStateKey(key);
|
|
1629
|
+
const state = getAdapter();
|
|
1630
|
+
await state.connect();
|
|
1631
|
+
await state.delete(pluginStateKey(plugin, key));
|
|
1632
|
+
},
|
|
1633
|
+
async get(key) {
|
|
1634
|
+
validatePluginStateKey(key);
|
|
1635
|
+
const state = getAdapter();
|
|
1636
|
+
await state.connect();
|
|
1637
|
+
return await state.get(pluginStateKey(plugin, key)) ?? void 0;
|
|
1638
|
+
},
|
|
1639
|
+
async set(key, value, ttlMs) {
|
|
1640
|
+
validatePluginStateKey(key);
|
|
1641
|
+
const state = getAdapter();
|
|
1642
|
+
await state.connect();
|
|
1643
|
+
await state.set(pluginStateKey(plugin, key), value, ttlMs);
|
|
1644
|
+
},
|
|
1645
|
+
async setIfNotExists(key, value, ttlMs) {
|
|
1646
|
+
validatePluginStateKey(key);
|
|
1647
|
+
const state = getAdapter();
|
|
1648
|
+
await state.connect();
|
|
1649
|
+
return await state.setIfNotExists(pluginStateKey(plugin, key), value, ttlMs);
|
|
1650
|
+
},
|
|
1651
|
+
async withLock(key, ttlMs, callback) {
|
|
1652
|
+
validatePluginStateKey(key);
|
|
1653
|
+
const state = getAdapter();
|
|
1654
|
+
await state.connect();
|
|
1655
|
+
const lockKey = pluginStateKey(plugin, key);
|
|
1656
|
+
const lock = await state.acquireLock(lockKey, ttlMs);
|
|
1657
|
+
if (!lock) throw new Error(`Could not acquire plugin state lock for ${key}`);
|
|
1658
|
+
try {
|
|
1659
|
+
return await callback();
|
|
1660
|
+
} finally {
|
|
1661
|
+
await state.releaseLock(lock);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
//#endregion
|
|
1667
|
+
//#region src/chat/slack/dashboard-link.ts
|
|
1668
|
+
let dashboardConversationLinkOptions;
|
|
1669
|
+
function withHttps(host) {
|
|
1670
|
+
return /^https?:\/\//.test(host) ? host : `https://${host}`;
|
|
1671
|
+
}
|
|
1672
|
+
function stripTrailingSlashes(value) {
|
|
1673
|
+
let end = value.length;
|
|
1674
|
+
while (end > 1 && value.charCodeAt(end - 1) === 47) end -= 1;
|
|
1675
|
+
return end === value.length ? value : value.slice(0, end);
|
|
1676
|
+
}
|
|
1677
|
+
function normalizeDashboardPath(path, fallback) {
|
|
1678
|
+
const value = path?.trim() || fallback;
|
|
1679
|
+
return stripTrailingSlashes(value.startsWith("/") ? value : `/${value}`);
|
|
1680
|
+
}
|
|
1681
|
+
function resolveDashboardBaseURL(config) {
|
|
1682
|
+
const explicit = config.baseURL ?? process.env.JUNIOR_BASE_URL;
|
|
1683
|
+
if (explicit?.trim()) return stripTrailingSlashes(withHttps(explicit.trim()));
|
|
1684
|
+
const vercelProd = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim();
|
|
1685
|
+
if (vercelProd) return stripTrailingSlashes(withHttps(vercelProd));
|
|
1686
|
+
const vercelUrl = process.env.VERCEL_URL?.trim();
|
|
1687
|
+
if (vercelUrl) return stripTrailingSlashes(withHttps(vercelUrl));
|
|
1688
|
+
return "http://localhost:3000";
|
|
1689
|
+
}
|
|
1690
|
+
/** Configure core dashboard links used in Slack footers. */
|
|
1691
|
+
function setDashboardConversationLinkOptions(options) {
|
|
1692
|
+
const previous = dashboardConversationLinkOptions;
|
|
1693
|
+
dashboardConversationLinkOptions = options?.disabled ? void 0 : options;
|
|
1694
|
+
return previous;
|
|
1695
|
+
}
|
|
1696
|
+
function resolveDashboardPath(segmentPath) {
|
|
1697
|
+
if (!dashboardConversationLinkOptions) return;
|
|
1698
|
+
const baseURL = resolveDashboardBaseURL(dashboardConversationLinkOptions);
|
|
1699
|
+
const basePath = normalizeDashboardPath(dashboardConversationLinkOptions.basePath, "/");
|
|
1700
|
+
return `${baseURL}${basePath === "/" ? segmentPath : `${basePath}${segmentPath}`}`;
|
|
1701
|
+
}
|
|
1702
|
+
/** Build the dashboard conversation URL when the core dashboard is enabled. */
|
|
1703
|
+
function getDashboardConversationLink(conversationId) {
|
|
1704
|
+
return resolveDashboardPath(`/conversations/${encodeURIComponent(conversationId)}`);
|
|
1705
|
+
}
|
|
1706
|
+
/** Build the dashboard task detail URL when the core dashboard is enabled. */
|
|
1707
|
+
function getDashboardTaskLink(taskId) {
|
|
1708
|
+
return resolveDashboardPath(`/tasks/${encodeURIComponent(taskId)}`);
|
|
1709
|
+
}
|
|
1710
|
+
//#endregion
|
|
1711
|
+
//#region src/chat/state/ttl.ts
|
|
1712
|
+
/** Retain expiring conversation/runtime state long enough for resume and dashboard history. */
|
|
1713
|
+
const JUNIOR_THREAD_STATE_TTL_MS = 6048e5;
|
|
1714
|
+
//#endregion
|
|
1715
|
+
//#region src/chat/resource-events/store.ts
|
|
1716
|
+
const RESOURCE_EVENT_PREFIX = "junior:resource_event_subscription:v5";
|
|
1717
|
+
const INDEX_LOCK_TTL_MS = 1e4;
|
|
1718
|
+
const SUBSCRIPTION_LOCK_TTL_MS = 1e4;
|
|
1719
|
+
const SUBSCRIPTION_LOCK_WAIT_MS = 1e4;
|
|
1720
|
+
const SUBSCRIPTION_LOCK_RETRY_MS = 25;
|
|
1721
|
+
const SUBSCRIPTION_LOCK_HEARTBEAT_MS = 3e3;
|
|
1722
|
+
const subscriptionStatusSchema = z.enum([
|
|
1723
|
+
"active",
|
|
1724
|
+
"cancelled",
|
|
1725
|
+
"completed"
|
|
1726
|
+
]);
|
|
1727
|
+
const subscriptionIdIndexSchema = z.array(z.string().min(1));
|
|
1728
|
+
const subscriptionSchema = z.object({
|
|
1729
|
+
conversationId: z.string().min(1),
|
|
1730
|
+
createdAtMs: z.number().finite(),
|
|
1731
|
+
events: z.array(resourceEventTypeSchema).min(1),
|
|
1732
|
+
expiresAtMs: z.number().finite(),
|
|
1733
|
+
id: z.string().min(1),
|
|
1734
|
+
intent: z.string().min(1),
|
|
1735
|
+
label: z.string().min(1),
|
|
1736
|
+
match: resourceEventMatchSchema.optional(),
|
|
1737
|
+
namespace: z.string().min(1),
|
|
1738
|
+
identifier: z.string().min(1),
|
|
1739
|
+
resourceType: z.string().min(1),
|
|
1740
|
+
status: subscriptionStatusSchema,
|
|
1741
|
+
updatedAtMs: z.number().finite()
|
|
1742
|
+
}).strict();
|
|
1743
|
+
function digest(value) {
|
|
1744
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
1745
|
+
}
|
|
1746
|
+
function subscriptionKey(id) {
|
|
1747
|
+
return `${RESOURCE_EVENT_PREFIX}:record:${id}`;
|
|
1748
|
+
}
|
|
1749
|
+
function subscriptionLockKey(id) {
|
|
1750
|
+
return `${RESOURCE_EVENT_PREFIX}:lock:${id}`;
|
|
1751
|
+
}
|
|
1752
|
+
function resourceIndexKey(namespace, identifier) {
|
|
1753
|
+
return `${RESOURCE_EVENT_PREFIX}:resource:${digest(`${namespace}\0${identifier}`)}`;
|
|
1754
|
+
}
|
|
1755
|
+
function conversationIndexKey(conversationId) {
|
|
1756
|
+
return `${RESOURCE_EVENT_PREFIX}:conversation:${digest(conversationId)}`;
|
|
1757
|
+
}
|
|
1758
|
+
function indexLockKey(key) {
|
|
1759
|
+
return `${key}:lock`;
|
|
1760
|
+
}
|
|
1761
|
+
async function sleep(ms) {
|
|
1762
|
+
await new Promise((resolve) => {
|
|
1763
|
+
setTimeout(resolve, ms).unref?.();
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
async function acquireSubscriptionLock(state, subscriptionId, waitDeadlineMs = Date.now() + SUBSCRIPTION_LOCK_WAIT_MS) {
|
|
1767
|
+
while (true) {
|
|
1768
|
+
const lock = await state.acquireLock(subscriptionLockKey(subscriptionId), SUBSCRIPTION_LOCK_TTL_MS);
|
|
1769
|
+
if (lock) return lock;
|
|
1770
|
+
if (Date.now() >= waitDeadlineMs) throw new Error(`Could not acquire resource event subscription lock for ${subscriptionId}`);
|
|
1771
|
+
await sleep(SUBSCRIPTION_LOCK_RETRY_MS);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
/** Run one subscription lifecycle transition while keeping its lease alive. */
|
|
1775
|
+
async function withSubscriptionLock(state, subscriptionId, callback, waitDeadlineMs) {
|
|
1776
|
+
const lock = await acquireSubscriptionLock(state, subscriptionId, waitDeadlineMs);
|
|
1777
|
+
let lockLostError;
|
|
1778
|
+
let heartbeatError;
|
|
1779
|
+
let heartbeat;
|
|
1780
|
+
const timer = setInterval(() => {
|
|
1781
|
+
if (heartbeat) return;
|
|
1782
|
+
heartbeat = state.extendLock(lock, SUBSCRIPTION_LOCK_TTL_MS).then((extended) => {
|
|
1783
|
+
if (extended) heartbeatError = void 0;
|
|
1784
|
+
else lockLostError = /* @__PURE__ */ new Error(`Resource event subscription lock was lost for ${subscriptionId}`);
|
|
1785
|
+
}).catch((error) => {
|
|
1786
|
+
heartbeatError = error instanceof Error ? error : /* @__PURE__ */ new Error(`Failed to extend subscription lock: ${String(error)}`);
|
|
1787
|
+
}).finally(() => {
|
|
1788
|
+
heartbeat = void 0;
|
|
1789
|
+
});
|
|
1790
|
+
}, SUBSCRIPTION_LOCK_HEARTBEAT_MS);
|
|
1791
|
+
timer.unref?.();
|
|
1792
|
+
let result;
|
|
1793
|
+
try {
|
|
1794
|
+
result = await callback();
|
|
1795
|
+
} finally {
|
|
1796
|
+
clearInterval(timer);
|
|
1797
|
+
await heartbeat;
|
|
1798
|
+
await state.releaseLock(lock);
|
|
1799
|
+
}
|
|
1800
|
+
const lockError = lockLostError ?? heartbeatError;
|
|
1801
|
+
if (lockError) throw lockError;
|
|
1802
|
+
return result;
|
|
1803
|
+
}
|
|
1804
|
+
function ttlUntil(expiresAtMs, nowMs) {
|
|
1805
|
+
return Math.max(1, expiresAtMs - nowMs);
|
|
1806
|
+
}
|
|
1807
|
+
async function readSubscriptionIdIndex(state, key) {
|
|
1808
|
+
const value = await state.get(key);
|
|
1809
|
+
if (value === void 0 || value === null) return [];
|
|
1810
|
+
return subscriptionIdIndexSchema.parse(value);
|
|
1811
|
+
}
|
|
1812
|
+
function buildSubscriptionId(input) {
|
|
1813
|
+
const eventKey = [...new Set(input.events)].sort().join("\0");
|
|
1814
|
+
const matchKey = stableResourceEventMatchKey(input.match);
|
|
1815
|
+
return `resub_${digest(matchKey ? `${input.namespace}\0${input.identifier}\0${input.conversationId}\0${eventKey}\0${matchKey}` : `${input.namespace}\0${input.identifier}\0${input.conversationId}\0${eventKey}`)}`;
|
|
1816
|
+
}
|
|
1817
|
+
async function withIndexLock(state, key, callback) {
|
|
1818
|
+
const lock = await state.acquireLock(indexLockKey(key), INDEX_LOCK_TTL_MS);
|
|
1819
|
+
if (!lock) throw new Error(`Could not acquire resource event index lock for ${key}`);
|
|
1820
|
+
try {
|
|
1821
|
+
return await callback();
|
|
1822
|
+
} finally {
|
|
1823
|
+
await state.releaseLock(lock);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
async function addToIndex(state, key, subscriptionId, nowMs) {
|
|
1827
|
+
await withIndexLock(state, key, async () => {
|
|
1828
|
+
const ids = [...new Set(await readSubscriptionIdIndex(state, key))];
|
|
1829
|
+
const next = ids.includes(subscriptionId) ? ids : [...ids, subscriptionId];
|
|
1830
|
+
await state.set(key, next, await indexTtlMs(state, next, nowMs));
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
async function removeFromIndex(state, key, subscriptionId, nowMs) {
|
|
1834
|
+
await withIndexLock(state, key, async () => {
|
|
1835
|
+
const next = (await readSubscriptionIdIndex(state, key)).filter((id) => id !== subscriptionId);
|
|
1836
|
+
await state.set(key, next, await indexTtlMs(state, next, nowMs));
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
async function indexTtlMs(state, subscriptionIds, nowMs) {
|
|
1840
|
+
const records = await Promise.all(subscriptionIds.map(async (id) => parseSubscription(await state.get(subscriptionKey(id)))));
|
|
1841
|
+
return ttlUntil(Math.max(nowMs, ...records.filter((record) => record !== void 0 && activeAt(record, nowMs)).map((record) => record.expiresAtMs)), nowMs);
|
|
1842
|
+
}
|
|
1843
|
+
function parseSubscription(value) {
|
|
1844
|
+
if (value === void 0 || value === null) return;
|
|
1845
|
+
return subscriptionSchema.parse(value);
|
|
1846
|
+
}
|
|
1847
|
+
function activeAt(subscription, nowMs) {
|
|
1848
|
+
return subscription.status === "active" && subscription.expiresAtMs > nowMs;
|
|
1849
|
+
}
|
|
1850
|
+
function matchesEvent(subscription, input) {
|
|
1851
|
+
return subscription.namespace === input.namespace && subscription.identifier === input.identifier && subscription.events.includes(input.eventType) && activeAt(subscription, input.nowMs) && resourceEventMatches(subscription.match, input.data);
|
|
1852
|
+
}
|
|
1853
|
+
/** Create or replace the active subscription for one conversation/resource/event set. */
|
|
1854
|
+
async function createResourceEventSubscription(input, options = {}) {
|
|
1855
|
+
const state = options.state ?? getStateAdapter();
|
|
1856
|
+
await state.connect();
|
|
1857
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
1858
|
+
const events = [...new Set(input.events.map((event) => event.trim()))].filter(Boolean);
|
|
1859
|
+
if (events.length === 0) throw new Error("Resource event subscription requires at least one event");
|
|
1860
|
+
if (input.expiresAtMs <= nowMs) throw new Error("Resource event subscription expiry must be in the future");
|
|
1861
|
+
const match = input.match && Object.keys(input.match).length > 0 ? input.match : void 0;
|
|
1862
|
+
const id = buildSubscriptionId({
|
|
1863
|
+
conversationId: input.conversationId,
|
|
1864
|
+
events,
|
|
1865
|
+
match,
|
|
1866
|
+
namespace: input.namespace,
|
|
1867
|
+
identifier: input.identifier
|
|
1868
|
+
});
|
|
1869
|
+
const record = {
|
|
1870
|
+
conversationId: input.conversationId,
|
|
1871
|
+
createdAtMs: nowMs,
|
|
1872
|
+
events,
|
|
1873
|
+
expiresAtMs: input.expiresAtMs,
|
|
1874
|
+
id,
|
|
1875
|
+
intent: input.intent,
|
|
1876
|
+
label: input.label,
|
|
1877
|
+
...match ? { match } : void 0,
|
|
1878
|
+
namespace: input.namespace,
|
|
1879
|
+
identifier: input.identifier,
|
|
1880
|
+
resourceType: input.resourceType,
|
|
1881
|
+
status: "active",
|
|
1882
|
+
updatedAtMs: nowMs
|
|
1883
|
+
};
|
|
1884
|
+
const parsed = subscriptionSchema.parse(record);
|
|
1885
|
+
await state.set(subscriptionKey(id), parsed, ttlUntil(parsed.expiresAtMs, nowMs));
|
|
1886
|
+
await addToIndex(state, resourceIndexKey(input.namespace, input.identifier), id, nowMs);
|
|
1887
|
+
await addToIndex(state, conversationIndexKey(input.conversationId), id, nowMs);
|
|
1888
|
+
return parsed;
|
|
1889
|
+
}
|
|
1890
|
+
/** List active subscriptions bound to one conversation. */
|
|
1891
|
+
async function listResourceEventSubscriptions(input) {
|
|
1892
|
+
const state = input.state ?? getStateAdapter();
|
|
1893
|
+
await state.connect();
|
|
1894
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
1895
|
+
const ids = await readSubscriptionIdIndex(state, conversationIndexKey(input.conversationId));
|
|
1896
|
+
return (await Promise.all(ids.map(async (id) => parseSubscription(await state.get(subscriptionKey(id)))))).filter((record) => record !== void 0 && record.conversationId === input.conversationId && activeAt(record, nowMs)).sort((left, right) => left.createdAtMs - right.createdAtMs);
|
|
1897
|
+
}
|
|
1898
|
+
/** Cancel a current-conversation subscription and remove it from match indexes. */
|
|
1899
|
+
async function cancelResourceEventSubscription(input) {
|
|
1900
|
+
const state = input.state ?? getStateAdapter();
|
|
1901
|
+
await state.connect();
|
|
1902
|
+
return await withSubscriptionLock(state, input.id, async () => {
|
|
1903
|
+
const current = parseSubscription(await state.get(subscriptionKey(input.id)));
|
|
1904
|
+
if (!current || current.conversationId !== input.conversationId) return;
|
|
1905
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
1906
|
+
const next = {
|
|
1907
|
+
...current,
|
|
1908
|
+
status: "cancelled",
|
|
1909
|
+
updatedAtMs: nowMs
|
|
1910
|
+
};
|
|
1911
|
+
await state.set(subscriptionKey(input.id), next, JUNIOR_THREAD_STATE_TTL_MS);
|
|
1912
|
+
await removeFromIndex(state, resourceIndexKey(current.namespace, current.identifier), input.id, nowMs);
|
|
1913
|
+
await removeFromIndex(state, conversationIndexKey(current.conversationId), input.id, nowMs);
|
|
1914
|
+
return next;
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
/** Cancel every active resource event subscription bound to one conversation. */
|
|
1918
|
+
async function cancelSubscriptions(input) {
|
|
1919
|
+
const subscriptions = await listResourceEventSubscriptions(input);
|
|
1920
|
+
for (const subscription of subscriptions) await cancelResourceEventSubscription({
|
|
1921
|
+
conversationId: input.conversationId,
|
|
1922
|
+
id: subscription.id,
|
|
1923
|
+
nowMs: input.nowMs,
|
|
1924
|
+
state: input.state
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
/**
|
|
1928
|
+
* Collect match keys from active watches for these identifiers and event types.
|
|
1929
|
+
* Does not evaluate match values — only reports keys that filters use.
|
|
1930
|
+
*/
|
|
1931
|
+
async function collectResourceEventMatchKeys(input) {
|
|
1932
|
+
const state = input.state ?? getStateAdapter();
|
|
1933
|
+
await state.connect();
|
|
1934
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
1935
|
+
const eventTypes = new Set(input.eventTypes.map((eventType) => eventType.trim()).filter(Boolean));
|
|
1936
|
+
if (eventTypes.size === 0) return [];
|
|
1937
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1938
|
+
const identifiers = new Set(input.identifiers.map((value) => value.trim()).filter(Boolean));
|
|
1939
|
+
for (const identifier of identifiers) {
|
|
1940
|
+
const ids = await readSubscriptionIdIndex(state, resourceIndexKey(input.namespace, identifier));
|
|
1941
|
+
const records = await Promise.all(ids.map(async (id) => parseSubscription(await state.get(subscriptionKey(id)))));
|
|
1942
|
+
for (const record of records) {
|
|
1943
|
+
if (!record) continue;
|
|
1944
|
+
if (record.namespace !== input.namespace) continue;
|
|
1945
|
+
if (record.identifier !== identifier) continue;
|
|
1946
|
+
if (!activeAt(record, nowMs)) continue;
|
|
1947
|
+
if (!record.events.some((eventType) => eventTypes.has(eventType))) continue;
|
|
1948
|
+
for (const key of Object.keys(record.match ?? {})) keys.add(key);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
return [...keys].sort();
|
|
1952
|
+
}
|
|
1953
|
+
/** Find active subscriptions interested in a normalized resource event. */
|
|
1954
|
+
async function findMatchingResourceEventSubscriptions(input) {
|
|
1955
|
+
const state = input.state ?? getStateAdapter();
|
|
1956
|
+
await state.connect();
|
|
1957
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
1958
|
+
const ids = await readSubscriptionIdIndex(state, resourceIndexKey(input.namespace, input.identifier));
|
|
1959
|
+
return (await Promise.all(ids.map(async (id) => parseSubscription(await state.get(subscriptionKey(id)))))).filter((record) => record !== void 0 && matchesEvent(record, {
|
|
1960
|
+
data: input.data,
|
|
1961
|
+
eventType: input.eventType,
|
|
1962
|
+
nowMs,
|
|
1963
|
+
namespace: input.namespace,
|
|
1964
|
+
identifier: input.identifier
|
|
1965
|
+
}));
|
|
1966
|
+
}
|
|
1967
|
+
/** Recheck and deliver a matched subscription while holding its status lock. */
|
|
1968
|
+
async function deliverResourceEventSubscription(input) {
|
|
1969
|
+
const state = input.state ?? getStateAdapter();
|
|
1970
|
+
await state.connect();
|
|
1971
|
+
return await withSubscriptionLock(state, input.subscription.id, async () => {
|
|
1972
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
1973
|
+
const current = parseSubscription(await state.get(subscriptionKey(input.subscription.id)));
|
|
1974
|
+
if (!current || !matchesEvent(current, {
|
|
1975
|
+
data: input.data,
|
|
1976
|
+
eventType: input.eventType,
|
|
1977
|
+
nowMs,
|
|
1978
|
+
namespace: input.namespace,
|
|
1979
|
+
identifier: input.identifier
|
|
1980
|
+
})) return false;
|
|
1981
|
+
const delivered = await input.deliver(current);
|
|
1982
|
+
if (input.terminal) {
|
|
1983
|
+
const latest = parseSubscription(await state.get(subscriptionKey(current.id)));
|
|
1984
|
+
if (!latest || latest.status !== current.status || latest.updatedAtMs !== current.updatedAtMs) return delivered;
|
|
1985
|
+
const next = {
|
|
1986
|
+
...current,
|
|
1987
|
+
status: "completed",
|
|
1988
|
+
updatedAtMs: nowMs
|
|
1989
|
+
};
|
|
1990
|
+
await state.set(subscriptionKey(current.id), next, JUNIOR_THREAD_STATE_TTL_MS);
|
|
1991
|
+
await removeFromIndex(state, resourceIndexKey(current.namespace, current.identifier), current.id, nowMs);
|
|
1992
|
+
await removeFromIndex(state, conversationIndexKey(current.conversationId), current.id, nowMs);
|
|
1993
|
+
}
|
|
1994
|
+
return delivered;
|
|
1995
|
+
}, input.waitDeadlineMs);
|
|
1996
|
+
}
|
|
1997
|
+
//#endregion
|
|
1998
|
+
//#region src/chat/resource-events/tool-support.ts
|
|
1999
|
+
/** Default lifetime for temporary resource subscriptions. */
|
|
2000
|
+
const RESOURCE_SUBSCRIPTION_DEFAULT_TTL_MS = 12096e5;
|
|
2001
|
+
/** Hard upper bound for temporary resource subscriptions. */
|
|
2002
|
+
const RESOURCE_SUBSCRIPTION_MAX_TTL_MS = 2592e6;
|
|
2003
|
+
const STOP_WATCHING_TOOL_NAME = "stopWatchingResources";
|
|
2004
|
+
const RESOURCE_WATCH_TOOL_SOURCE = {
|
|
2005
|
+
id: "resource-watches",
|
|
2006
|
+
description: "Inspect or stop resource watches for the current conversation."
|
|
2007
|
+
};
|
|
2008
|
+
//#endregion
|
|
2009
|
+
//#region src/chat/slack/tool-support/context.ts
|
|
2010
|
+
/** Resolve Slack tool context from the Conversation Location. */
|
|
2011
|
+
function getSlackToolContext(context) {
|
|
2012
|
+
const source = context.source.kind === "slack" ? context.source : void 0;
|
|
2013
|
+
const location = context.location?.provider === "slack" ? context.location : source ? {
|
|
2014
|
+
provider: "slack",
|
|
2015
|
+
teamId: source.teamId,
|
|
2016
|
+
channelId: source.channelId,
|
|
2017
|
+
threadTs: source.threadTs
|
|
2018
|
+
} : void 0;
|
|
2019
|
+
if (!location) return void 0;
|
|
2020
|
+
const destinationChannelId = parseSlackChannelReferenceId(context.destination.platform === "slack" ? context.destination.channelId : location.channelId);
|
|
2021
|
+
const locationChannelId = parseSlackChannelReferenceId(location.channelId);
|
|
2022
|
+
const teamId = parseSlackTeamId(location.teamId);
|
|
2023
|
+
if (!destinationChannelId || !locationChannelId || !teamId) return;
|
|
2024
|
+
return {
|
|
2025
|
+
actor: context.actor?.platform === "slack" ? context.actor : void 0,
|
|
2026
|
+
destinationChannelId,
|
|
2027
|
+
locationChannelId,
|
|
2028
|
+
messageChannelId: source ? parseSlackChannelReferenceId(source.channelId) : void 0,
|
|
2029
|
+
messageTs: parseSlackMessageTs(source?.messageTs),
|
|
2030
|
+
teamId,
|
|
2031
|
+
threadTs: parseSlackMessageTs(location.threadTs)
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
//#endregion
|
|
2035
|
+
//#region src/chat/plugins/viewer.ts
|
|
2036
|
+
function identityFromRow(row) {
|
|
2037
|
+
return identitySchema.parse({
|
|
2038
|
+
id: row.id,
|
|
2039
|
+
provider: row.provider,
|
|
2040
|
+
providerSubjectId: row.providerSubjectId,
|
|
2041
|
+
...row.providerTenantId ? { providerTenantId: row.providerTenantId } : void 0,
|
|
2042
|
+
...row.displayName ? { displayName: row.displayName } : void 0,
|
|
2043
|
+
...row.handle ? { handle: row.handle } : void 0
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
async function readUserById(db, userRow) {
|
|
2047
|
+
const identityRows = await db.select().from(juniorIdentities).where(and(eq(juniorIdentities.userId, userRow.id), eq(juniorIdentities.kind, "user"))).orderBy(asc(juniorIdentities.provider), asc(juniorIdentities.providerTenantId), asc(juniorIdentities.providerSubjectId));
|
|
2048
|
+
return userSchema.parse({
|
|
2049
|
+
email: userRow.primaryEmail,
|
|
2050
|
+
id: userRow.id,
|
|
2051
|
+
identities: identityRows.map(identityFromRow),
|
|
2052
|
+
...userRow.displayName ? { displayName: userRow.displayName } : void 0
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
/** Resolve or create the canonical user for one verified viewer email. */
|
|
2056
|
+
async function resolveViewerUserFromSql(db, email) {
|
|
2057
|
+
const normalizedEmail = normalizeIdentityEmail(email);
|
|
2058
|
+
if (!normalizedEmail) return void 0;
|
|
2059
|
+
const now = /* @__PURE__ */ new Date();
|
|
2060
|
+
const userRow = (await db.insert(juniorUsers).values({
|
|
2061
|
+
id: randomUUID(),
|
|
2062
|
+
primaryEmail: email.trim(),
|
|
2063
|
+
primaryEmailNormalized: normalizedEmail,
|
|
2064
|
+
displayName: null,
|
|
2065
|
+
createdAt: now,
|
|
2066
|
+
updatedAt: now
|
|
2067
|
+
}).onConflictDoNothing({ target: juniorUsers.primaryEmailNormalized }).returning())[0] ?? (await db.select().from(juniorUsers).where(eq(juniorUsers.primaryEmailNormalized, normalizedEmail)).limit(1))[0];
|
|
2068
|
+
return userRow ? await readUserById(db, userRow) : void 0;
|
|
2069
|
+
}
|
|
2070
|
+
/** Resolve or create the canonical user for one authenticated viewer. */
|
|
2071
|
+
async function resolveViewerUser(email) {
|
|
2072
|
+
return await resolveViewerUserFromSql(getDb(), email);
|
|
2073
|
+
}
|
|
2074
|
+
/** Read one existing canonical user by verified email without creating a row. */
|
|
2075
|
+
async function findUserByEmailFromSql(db, email) {
|
|
2076
|
+
const normalizedEmail = normalizeIdentityEmail(email);
|
|
2077
|
+
if (!normalizedEmail) return void 0;
|
|
2078
|
+
const userRow = (await db.select().from(juniorUsers).where(eq(juniorUsers.primaryEmailNormalized, normalizedEmail)).limit(1))[0];
|
|
2079
|
+
return userRow ? await readUserById(db, userRow) : void 0;
|
|
2080
|
+
}
|
|
2081
|
+
/** Read one existing canonical user by verified email without creating a row. */
|
|
2082
|
+
async function findUserByEmail(email) {
|
|
2083
|
+
return await findUserByEmailFromSql(getDb(), email);
|
|
2084
|
+
}
|
|
2085
|
+
/** Update one canonical user's display name in SQL. */
|
|
2086
|
+
async function updateViewerDisplayNameFromSql(db, userId, displayName) {
|
|
2087
|
+
const userRow = (await db.update(juniorUsers).set({
|
|
2088
|
+
displayName,
|
|
2089
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
2090
|
+
}).where(eq(juniorUsers.id, userId)).returning())[0];
|
|
2091
|
+
return userRow ? await readUserById(db, userRow) : void 0;
|
|
2092
|
+
}
|
|
2093
|
+
/** Update one canonical user's display name. */
|
|
2094
|
+
async function updateViewerDisplayName(userId, displayName) {
|
|
2095
|
+
return await updateViewerDisplayNameFromSql(getDb(), userId, displayName);
|
|
2096
|
+
}
|
|
2097
|
+
/** Resolve the stored identity and linked user for one runtime actor. */
|
|
2098
|
+
async function readActorIdentityFromSql(db, actor) {
|
|
2099
|
+
if (actor.platform === "system") return void 0;
|
|
2100
|
+
const providerTenantId = actor.platform === "slack" ? actor.teamId : "";
|
|
2101
|
+
const row = (await db.select({
|
|
2102
|
+
identity: juniorIdentities,
|
|
2103
|
+
user: juniorUsers
|
|
2104
|
+
}).from(juniorIdentities).leftJoin(juniorUsers, eq(juniorUsers.id, juniorIdentities.userId)).where(and(eq(juniorIdentities.kind, "user"), eq(juniorIdentities.provider, actor.platform), eq(juniorIdentities.providerTenantId, providerTenantId), eq(juniorIdentities.providerSubjectId, actor.userId))).limit(1))[0];
|
|
2105
|
+
if (!row) return void 0;
|
|
2106
|
+
return {
|
|
2107
|
+
identity: identityFromRow(row.identity),
|
|
2108
|
+
...row.user ? { user: await readUserById(db, row.user) } : void 0
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
/** Resolve the stored identity and linked user for one runtime actor. */
|
|
2112
|
+
async function readActorIdentity(actor) {
|
|
2113
|
+
return await readActorIdentityFromSql(getDb(), actor);
|
|
2114
|
+
}
|
|
2115
|
+
//#endregion
|
|
2116
|
+
//#region src/chat/slack/workspace-context.ts
|
|
2117
|
+
const workspaceTeamIdStorage = new AsyncLocalStorage();
|
|
2118
|
+
/** Run a callback with the Slack workspace team ID for the inbound webhook. */
|
|
2119
|
+
function runWithWorkspaceTeamId(teamId, fn) {
|
|
2120
|
+
if (!teamId) return fn();
|
|
2121
|
+
return workspaceTeamIdStorage.run(teamId, fn);
|
|
2122
|
+
}
|
|
2123
|
+
/** Return the Slack workspace team ID for the current inbound webhook. */
|
|
2124
|
+
function getWorkspaceTeamId() {
|
|
2125
|
+
return workspaceTeamIdStorage.getStore();
|
|
2126
|
+
}
|
|
2127
|
+
//#endregion
|
|
2128
|
+
//#region src/chat/slack/client.ts
|
|
2129
|
+
var SlackActionError = class extends Error {
|
|
2130
|
+
code;
|
|
2131
|
+
apiError;
|
|
2132
|
+
needed;
|
|
2133
|
+
provided;
|
|
2134
|
+
statusCode;
|
|
2135
|
+
requestId;
|
|
2136
|
+
errorData;
|
|
2137
|
+
retryAfterSeconds;
|
|
2138
|
+
detail;
|
|
2139
|
+
detailLine;
|
|
2140
|
+
detailRule;
|
|
2141
|
+
constructor(message, code, options = {}) {
|
|
2142
|
+
super(message);
|
|
2143
|
+
this.name = "SlackActionError";
|
|
2144
|
+
this.code = code;
|
|
2145
|
+
this.apiError = options.apiError;
|
|
2146
|
+
this.needed = options.needed;
|
|
2147
|
+
this.provided = options.provided;
|
|
2148
|
+
this.statusCode = options.statusCode;
|
|
2149
|
+
this.requestId = options.requestId;
|
|
2150
|
+
this.errorData = options.errorData;
|
|
2151
|
+
this.retryAfterSeconds = options.retryAfterSeconds;
|
|
2152
|
+
this.detail = options.detail;
|
|
2153
|
+
this.detailLine = options.detailLine;
|
|
2154
|
+
this.detailRule = options.detailRule;
|
|
2155
|
+
}
|
|
2156
|
+
};
|
|
2157
|
+
function serializeSlackErrorData(data) {
|
|
2158
|
+
if (!data || typeof data !== "object") return;
|
|
2159
|
+
const filtered = Object.fromEntries(Object.entries(data).filter(([key]) => key !== "error"));
|
|
2160
|
+
if (Object.keys(filtered).length === 0) return;
|
|
2161
|
+
try {
|
|
2162
|
+
const serialized = JSON.stringify(filtered);
|
|
2163
|
+
return serialized.length <= 600 ? serialized : `${serialized.slice(0, 597)}...`;
|
|
2164
|
+
} catch {
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
/** Extract a header value by case-insensitive name from a raw headers object. */
|
|
2169
|
+
function getHeaderString(headers, name) {
|
|
2170
|
+
if (!headers || typeof headers !== "object") return;
|
|
2171
|
+
const key = name.toLowerCase();
|
|
2172
|
+
const entries = headers;
|
|
2173
|
+
for (const [entryKey, value] of Object.entries(entries)) {
|
|
2174
|
+
if (entryKey.toLowerCase() !== key) continue;
|
|
2175
|
+
if (typeof value === "string") return value;
|
|
2176
|
+
if (Array.isArray(value)) {
|
|
2177
|
+
const first = value.find((entry) => typeof entry === "string");
|
|
2178
|
+
return typeof first === "string" ? first : void 0;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
function parseSlackCanvasDetail(detail) {
|
|
2183
|
+
if (typeof detail !== "string") return {};
|
|
2184
|
+
const trimmed = detail.trim();
|
|
2185
|
+
if (!trimmed) return {};
|
|
2186
|
+
const parsed = { detail: trimmed };
|
|
2187
|
+
const lineMatch = trimmed.match(/line\s+(\d+):/i);
|
|
2188
|
+
if (lineMatch) {
|
|
2189
|
+
const line = Number.parseInt(lineMatch[1] ?? "", 10);
|
|
2190
|
+
if (Number.isFinite(line)) parsed.detailLine = line;
|
|
2191
|
+
}
|
|
2192
|
+
if (/unsupported heading depth/i.test(trimmed)) parsed.detailRule = "unsupported_heading_depth";
|
|
2193
|
+
return parsed;
|
|
2194
|
+
}
|
|
2195
|
+
/**
|
|
2196
|
+
* Ambient destination-installation token for Slack outbound calls, bound by
|
|
2197
|
+
* installation-scoped entry points so writes carry the destination
|
|
2198
|
+
* workspace's credentials instead of the process-global env token.
|
|
2199
|
+
*/
|
|
2200
|
+
const installationTokenStorage = new AsyncLocalStorage();
|
|
2201
|
+
const clientsByToken = /* @__PURE__ */ new Map();
|
|
2202
|
+
/**
|
|
2203
|
+
* Bind a workspace installation token for all Slack Web API calls inside
|
|
2204
|
+
* `fn`, so multi-workspace outbound writes use the destination workspace's
|
|
2205
|
+
* credentials rather than the env fallback token.
|
|
2206
|
+
*/
|
|
2207
|
+
function runWithSlackInstallationToken(token, fn) {
|
|
2208
|
+
const trimmed = token.trim();
|
|
2209
|
+
if (!trimmed) throw new SlackActionError("Slack installation token binding requires a non-empty token", "missing_token");
|
|
2210
|
+
return installationTokenStorage.run({ token: trimmed }, fn);
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* Token precedence: ambient installation token, then env token, then a hard
|
|
2214
|
+
* failure for team-scoped calls — a call scoped to one workspace must never
|
|
2215
|
+
* silently fall through to another workspace's credentials.
|
|
2216
|
+
*/
|
|
2217
|
+
function resolveSlackToken() {
|
|
2218
|
+
const ambientToken = installationTokenStorage.getStore()?.token;
|
|
2219
|
+
if (ambientToken) return ambientToken;
|
|
2220
|
+
const envToken = getSlackBotToken();
|
|
2221
|
+
if (envToken) return envToken;
|
|
2222
|
+
const teamId = getWorkspaceTeamId();
|
|
2223
|
+
if (teamId) throw new SlackActionError(`Slack call is scoped to workspace ${teamId} but no installation token is bound and no default bot token is configured`, "missing_token");
|
|
2224
|
+
throw new SlackActionError("SLACK_BOT_TOKEN (or SLACK_BOT_USER_TOKEN) is required for Slack Web API actions in this service", "missing_token");
|
|
2225
|
+
}
|
|
2226
|
+
/** Normalize Junior Slack references to native Slack conversation IDs. */
|
|
2227
|
+
function normalizeSlackConversationId(channelId) {
|
|
2228
|
+
return parseSlackChannelReferenceId(channelId);
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* Return the per-token cached WebClient. `withSlackRetries` owns retry
|
|
2232
|
+
* classification for this boundary: the WebClient's built-in policy would
|
|
2233
|
+
* re-post timed-out writes (a duplicate-message hazard) and sleep for the
|
|
2234
|
+
* full unbounded Retry-After inside the request, so both internal behaviors
|
|
2235
|
+
* are disabled here.
|
|
2236
|
+
*/
|
|
2237
|
+
function getClient() {
|
|
2238
|
+
const token = resolveSlackToken();
|
|
2239
|
+
let cached = clientsByToken.get(token);
|
|
2240
|
+
if (!cached) {
|
|
2241
|
+
cached = new WebClient(token, {
|
|
2242
|
+
retryConfig: { retries: 0 },
|
|
2243
|
+
rejectRateLimitedCalls: true
|
|
2244
|
+
});
|
|
2245
|
+
clientsByToken.set(token, cached);
|
|
2246
|
+
}
|
|
2247
|
+
return cached;
|
|
2248
|
+
}
|
|
2249
|
+
function mapSlackError(error) {
|
|
2250
|
+
if (error instanceof SlackActionError) return error;
|
|
2251
|
+
const candidate = error;
|
|
2252
|
+
const apiError = candidate.data?.error;
|
|
2253
|
+
const message = candidate.message ?? "Slack action failed";
|
|
2254
|
+
const baseOptions = {
|
|
2255
|
+
apiError,
|
|
2256
|
+
statusCode: candidate.statusCode,
|
|
2257
|
+
requestId: getHeaderString(candidate.headers, "x-slack-req-id"),
|
|
2258
|
+
errorData: serializeSlackErrorData(candidate.data),
|
|
2259
|
+
...parseSlackCanvasDetail(candidate.data?.detail)
|
|
2260
|
+
};
|
|
2261
|
+
if (apiError === "missing_scope") return new SlackActionError(message, "missing_scope", {
|
|
2262
|
+
...baseOptions,
|
|
2263
|
+
needed: candidate.data?.needed,
|
|
2264
|
+
provided: candidate.data?.provided
|
|
2265
|
+
});
|
|
2266
|
+
if (apiError === "not_in_channel") return new SlackActionError(message, "not_in_channel", baseOptions);
|
|
2267
|
+
if (apiError === "restricted_action_read_only_channel") return new SlackActionError(message, "read_only_channel", baseOptions);
|
|
2268
|
+
if (apiError === "already_reacted") return new SlackActionError(message, "already_reacted", baseOptions);
|
|
2269
|
+
if (apiError === "no_reaction") return new SlackActionError(message, "no_reaction", baseOptions);
|
|
2270
|
+
if (apiError === "invalid_arguments") return new SlackActionError(message, "invalid_arguments", baseOptions);
|
|
2271
|
+
if (apiError === "invalid_cursor") return new SlackActionError(message, "invalid_arguments", baseOptions);
|
|
2272
|
+
if (apiError === "invalid_name") return new SlackActionError(message, "invalid_arguments", baseOptions);
|
|
2273
|
+
if (apiError === "not_found" || apiError === "channel_not_found" || apiError === "message_not_found") return new SlackActionError(message, "not_found", baseOptions);
|
|
2274
|
+
if (apiError === "feature_not_enabled" || apiError === "not_allowed_token_type") return new SlackActionError(message, "feature_unavailable", baseOptions);
|
|
2275
|
+
if (apiError === "canvas_creation_failed") return new SlackActionError(message, "canvas_creation_failed", baseOptions);
|
|
2276
|
+
if (apiError === "canvas_editing_failed") return new SlackActionError(message, "canvas_editing_failed", baseOptions);
|
|
2277
|
+
if (candidate.code === "slack_webapi_rate_limited_error" || candidate.statusCode === 429) return new SlackActionError(message, "rate_limited", {
|
|
2278
|
+
...baseOptions,
|
|
2279
|
+
retryAfterSeconds: candidate.retryAfter
|
|
2280
|
+
});
|
|
2281
|
+
return new SlackActionError(message, "internal_error", baseOptions);
|
|
2282
|
+
}
|
|
2283
|
+
const MAX_RETRY_DELAY_MS = 1e4;
|
|
2284
|
+
const MAX_TOTAL_RETRY_DELAY_MS = 2e4;
|
|
2285
|
+
const CONNECTION_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
2286
|
+
"ECONNREFUSED",
|
|
2287
|
+
"ECONNRESET",
|
|
2288
|
+
"ENOTFOUND",
|
|
2289
|
+
"EAI_AGAIN"
|
|
2290
|
+
]);
|
|
2291
|
+
const TIMEOUT_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
2292
|
+
"ETIMEDOUT",
|
|
2293
|
+
"ECONNABORTED",
|
|
2294
|
+
"ESOCKETTIMEDOUT"
|
|
2295
|
+
]);
|
|
2296
|
+
function findNetworkErrorCode(error, depth = 0) {
|
|
2297
|
+
if (!error || typeof error !== "object" || depth > 4) return;
|
|
2298
|
+
const candidate = error;
|
|
2299
|
+
if (typeof candidate.code === "string" && (CONNECTION_ERROR_CODES.has(candidate.code) || TIMEOUT_ERROR_CODES.has(candidate.code))) return candidate.code;
|
|
2300
|
+
return findNetworkErrorCode(candidate.original, depth + 1) ?? findNetworkErrorCode(candidate.cause, depth + 1);
|
|
2301
|
+
}
|
|
2302
|
+
function hasSocketHangUpMessage(error) {
|
|
2303
|
+
return error instanceof Error && error.message.toLowerCase().includes("socket hang up");
|
|
2304
|
+
}
|
|
2305
|
+
/**
|
|
2306
|
+
* Classify a failed Slack call for retry: rate limits, Slack 5xx, and
|
|
2307
|
+
* connection-phase failures are always retryable; timeouts are retried only
|
|
2308
|
+
* for idempotent operations because Slack may already have accepted the
|
|
2309
|
+
* request.
|
|
2310
|
+
*/
|
|
2311
|
+
function classifySlackRetry(raw, mapped) {
|
|
2312
|
+
if (mapped.code === "rate_limited") return "rate_limited";
|
|
2313
|
+
if (mapped.statusCode !== void 0 && mapped.statusCode >= 500) return "server_error";
|
|
2314
|
+
const networkCode = findNetworkErrorCode(raw);
|
|
2315
|
+
if (networkCode && CONNECTION_ERROR_CODES.has(networkCode)) return "connection";
|
|
2316
|
+
if (networkCode && TIMEOUT_ERROR_CODES.has(networkCode)) return "timeout";
|
|
2317
|
+
if (hasSocketHangUpMessage(raw)) return "connection";
|
|
2318
|
+
return "none";
|
|
2319
|
+
}
|
|
2320
|
+
/**
|
|
2321
|
+
* Run a Slack Web API call with bounded retries so transient platform
|
|
2322
|
+
* failures do not surface as turn failures, while non-idempotent posts are
|
|
2323
|
+
* never re-sent when Slack may already have accepted them.
|
|
2324
|
+
*
|
|
2325
|
+
* Retry classes: rate limits (bounded Retry-After), connection-phase network
|
|
2326
|
+
* failures, and Slack 5xx responses always retry; request timeouts retry only
|
|
2327
|
+
* when the caller marks the operation `idempotent`.
|
|
2328
|
+
*/
|
|
2329
|
+
async function withSlackRetries(task, maxAttempts = 3, context = {}) {
|
|
2330
|
+
let attempt = 0;
|
|
2331
|
+
let totalDelayMs = 0;
|
|
2332
|
+
const action = context.action ?? "unknown";
|
|
2333
|
+
while (attempt < maxAttempts) {
|
|
2334
|
+
attempt += 1;
|
|
2335
|
+
const attemptNumber = attempt;
|
|
2336
|
+
try {
|
|
2337
|
+
return await withSpan(`POST slack.com/api/${action}`, "http.client", {}, async () => {
|
|
2338
|
+
try {
|
|
2339
|
+
return await task();
|
|
2340
|
+
} catch (error) {
|
|
2341
|
+
const mapped = mapSlackError(error);
|
|
2342
|
+
const errorAttrs = { "error.type": mapped.code };
|
|
2343
|
+
if (mapped.apiError) errorAttrs["app.slack.api_error_code"] = mapped.apiError;
|
|
2344
|
+
if (mapped.code === "rate_limited") {
|
|
2345
|
+
errorAttrs["http.response.status_code"] = 429;
|
|
2346
|
+
if (mapped.retryAfterSeconds) errorAttrs["app.slack.retry_after_ms"] = mapped.retryAfterSeconds * 1e3;
|
|
2347
|
+
} else if (mapped.statusCode != null) errorAttrs["http.response.status_code"] = mapped.statusCode;
|
|
2348
|
+
setSpanAttributes(errorAttrs);
|
|
2349
|
+
setSpanStatus("error");
|
|
2350
|
+
throw error;
|
|
2351
|
+
}
|
|
2352
|
+
}, {
|
|
2353
|
+
"http.request.method": "POST",
|
|
2354
|
+
"server.address": "slack.com",
|
|
2355
|
+
"url.scheme": "https",
|
|
2356
|
+
"url.path": `/api/${action}`,
|
|
2357
|
+
"app.slack.method": action,
|
|
2358
|
+
"app.retry.max_attempts": maxAttempts,
|
|
2359
|
+
...attemptNumber > 1 ? { "http.request.resend_count": attemptNumber - 1 } : void 0,
|
|
2360
|
+
...context.attributes ?? {},
|
|
2361
|
+
...context.spanAttributes ?? {}
|
|
2362
|
+
});
|
|
2363
|
+
} catch (error) {
|
|
2364
|
+
const mapped = mapSlackError(error);
|
|
2365
|
+
const retryClass = classifySlackRetry(error, mapped);
|
|
2366
|
+
const isRetryable = retryClass === "rate_limited" || retryClass === "connection" || retryClass === "server_error" || retryClass === "timeout" && context.idempotent === true;
|
|
2367
|
+
const remainingDelayBudgetMs = MAX_TOTAL_RETRY_DELAY_MS - totalDelayMs;
|
|
2368
|
+
const baseLogAttributes = {
|
|
2369
|
+
"app.slack.action": action,
|
|
2370
|
+
"app.slack.error_code": mapped.code,
|
|
2371
|
+
...mapped.apiError ? { "app.slack.api_error": mapped.apiError } : void 0,
|
|
2372
|
+
...mapped.detail ? { "app.slack.detail": mapped.detail } : void 0,
|
|
2373
|
+
...mapped.detailLine !== void 0 ? { "app.slack.detail_line": mapped.detailLine } : void 0,
|
|
2374
|
+
...mapped.detailRule ? { "app.slack.detail_rule": mapped.detailRule } : void 0,
|
|
2375
|
+
...mapped.requestId ? { "app.slack.request_id": mapped.requestId } : void 0,
|
|
2376
|
+
...mapped.statusCode !== void 0 ? { "http.response.status_code": mapped.statusCode } : void 0,
|
|
2377
|
+
...context.attributes ?? {}
|
|
2378
|
+
};
|
|
2379
|
+
if (!isRetryable || attempt >= maxAttempts || remainingDelayBudgetMs <= 0) {
|
|
2380
|
+
logWarn("slack.action.failed", {
|
|
2381
|
+
...baseLogAttributes,
|
|
2382
|
+
...mapped.errorData ? { "app.slack.error_data": mapped.errorData } : void 0
|
|
2383
|
+
});
|
|
2384
|
+
throw mapped;
|
|
2385
|
+
}
|
|
2386
|
+
logWarn("slack.action.retrying", {
|
|
2387
|
+
...baseLogAttributes,
|
|
2388
|
+
"app.slack.retry_attempt": attempt,
|
|
2389
|
+
"app.slack.retry_class": retryClass
|
|
2390
|
+
});
|
|
2391
|
+
const retryAfterMs = retryClass === "rate_limited" && mapped.retryAfterSeconds && mapped.retryAfterSeconds > 0 ? mapped.retryAfterSeconds * 1e3 : void 0;
|
|
2392
|
+
const backoffMs = Math.min(2e3, 250 * 2 ** (attempt - 1));
|
|
2393
|
+
const delayMs = Math.min(retryAfterMs ?? backoffMs, MAX_RETRY_DELAY_MS, remainingDelayBudgetMs);
|
|
2394
|
+
totalDelayMs += delayMs;
|
|
2395
|
+
await sleep$1(delayMs);
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
throw new SlackActionError("Slack action exhausted retries", "internal_error");
|
|
2399
|
+
}
|
|
2400
|
+
/**
|
|
2401
|
+
* Slack Web API client for the current destination workspace: the ambient
|
|
2402
|
+
* installation token when one is bound, otherwise the env bot token for
|
|
2403
|
+
* single-workspace deployments. Fails instead of falling back when the call
|
|
2404
|
+
* is workspace-scoped and no installation token can be resolved, so a write
|
|
2405
|
+
* never goes out with another workspace's credentials.
|
|
2406
|
+
*/
|
|
2407
|
+
function getSlackClient() {
|
|
2408
|
+
return getClient();
|
|
2409
|
+
}
|
|
2410
|
+
/**
|
|
2411
|
+
* Slack channel ID prefixes:
|
|
2412
|
+
* - C: channel — modern private channels also use C, so the prefix never
|
|
2413
|
+
* proves a channel is public
|
|
2414
|
+
* - G: legacy private channel / group DM
|
|
2415
|
+
* - D: direct message (1:1)
|
|
2416
|
+
*/
|
|
2417
|
+
function isDmChannel(channelId) {
|
|
2418
|
+
const normalized = normalizeSlackConversationId(channelId);
|
|
2419
|
+
return Boolean(normalized && normalized.startsWith("D"));
|
|
2420
|
+
}
|
|
2421
|
+
/**
|
|
2422
|
+
* Conversation-scoped Slack contexts backed by a concrete conversation ID.
|
|
2423
|
+
* Includes channels/groups/DMs (C/G/D).
|
|
2424
|
+
*/
|
|
2425
|
+
function isConversationScopedChannel(channelId) {
|
|
2426
|
+
const normalized = normalizeSlackConversationId(channelId);
|
|
2427
|
+
if (!normalized) return false;
|
|
2428
|
+
return normalized.startsWith("C") || normalized.startsWith("G") || normalized.startsWith("D");
|
|
2429
|
+
}
|
|
2430
|
+
function isConversationChannel(channelId) {
|
|
2431
|
+
const normalized = normalizeSlackConversationId(channelId);
|
|
2432
|
+
if (!normalized) return false;
|
|
2433
|
+
return normalized.startsWith("C") || normalized.startsWith("G");
|
|
2434
|
+
}
|
|
2435
|
+
async function getFilePermalink(fileId) {
|
|
2436
|
+
const client = getClient();
|
|
2437
|
+
return (await withSlackRetries(() => client.files.info({ file: fileId }), 3, {
|
|
2438
|
+
action: "files.info",
|
|
2439
|
+
idempotent: true,
|
|
2440
|
+
spanAttributes: { "app.slack.file_id": fileId }
|
|
2441
|
+
})).file?.permalink;
|
|
2442
|
+
}
|
|
2443
|
+
async function downloadPrivateSlackFile(url) {
|
|
2444
|
+
const token = resolveSlackToken();
|
|
2445
|
+
return withSpan("GET files.slack.com", "http.client", {}, async () => {
|
|
2446
|
+
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
2447
|
+
setSpanAttributes({ "http.response.status_code": response.status });
|
|
2448
|
+
if (!response.ok) {
|
|
2449
|
+
setSpanAttributes({ "error.type": String(response.status) });
|
|
2450
|
+
setSpanStatus("error");
|
|
2451
|
+
throw new Error(`Slack file download failed: ${response.status}`);
|
|
2452
|
+
}
|
|
2453
|
+
return Buffer.from(await response.arrayBuffer());
|
|
2454
|
+
}, {
|
|
2455
|
+
"http.request.method": "GET",
|
|
2456
|
+
"server.address": "files.slack.com",
|
|
2457
|
+
"url.scheme": "https",
|
|
2458
|
+
"app.slack.method": "files.download"
|
|
2459
|
+
});
|
|
2460
|
+
}
|
|
2461
|
+
//#endregion
|
|
2462
|
+
//#region src/chat/credentials/subject.ts
|
|
2463
|
+
const CREDENTIAL_SUBJECT_HMAC_CONTEXT = "junior.credential_subject.v1";
|
|
2464
|
+
const CREDENTIAL_SUBJECT_SIGNATURE_VERSION = "v1";
|
|
2465
|
+
function getCredentialSubjectSecret() {
|
|
2466
|
+
return process.env.JUNIOR_SECRET?.trim() || void 0;
|
|
2467
|
+
}
|
|
2468
|
+
/** Encode ordered signing fields without allowing delimiter ambiguity. */
|
|
2469
|
+
function buildPayload(parts) {
|
|
2470
|
+
return [CREDENTIAL_SUBJECT_HMAC_CONTEXT, ...parts].join("\0");
|
|
2471
|
+
}
|
|
2472
|
+
function signPayload(secret, payload) {
|
|
2473
|
+
const digest = createHmac("sha256", secret).update(payload).digest("hex");
|
|
2474
|
+
return `${CREDENTIAL_SUBJECT_SIGNATURE_VERSION}=${digest}`;
|
|
2475
|
+
}
|
|
2476
|
+
function timingSafeMatch(expected, actual) {
|
|
2477
|
+
const expectedBuffer = Buffer.from(expected);
|
|
2478
|
+
const actualBuffer = Buffer.from(actual);
|
|
2479
|
+
if (expectedBuffer.length !== actualBuffer.length) return false;
|
|
2480
|
+
return timingSafeEqual(expectedBuffer, actualBuffer);
|
|
2481
|
+
}
|
|
2482
|
+
/** Create a delegated user credential subject for a verified Slack DM turn. */
|
|
2483
|
+
function createSlackDirectCredentialSubject(input) {
|
|
2484
|
+
const channelId = normalizeSlackConversationId(input.channelId);
|
|
2485
|
+
const teamId = input.teamId?.trim();
|
|
2486
|
+
const userId = parseActorUserId(input.userId);
|
|
2487
|
+
if (!channelId || !teamId || !userId || !isDmChannel(channelId)) return;
|
|
2488
|
+
return {
|
|
2489
|
+
type: "user",
|
|
2490
|
+
userId,
|
|
2491
|
+
allowedWhen: "private-direct-conversation"
|
|
2492
|
+
};
|
|
2493
|
+
}
|
|
2494
|
+
/** Bind a delegated user subject to the Slack DM destination being dispatched. */
|
|
2495
|
+
function bindSlackDirectCredentialSubject(input) {
|
|
2496
|
+
const channelId = normalizeSlackConversationId(input.channelId);
|
|
2497
|
+
const teamId = input.teamId.trim();
|
|
2498
|
+
const secret = getCredentialSubjectSecret();
|
|
2499
|
+
const { subject } = input;
|
|
2500
|
+
const userId = parseActorUserId(subject.userId);
|
|
2501
|
+
if (!channelId || !teamId || !secret || !isDmChannel(channelId) || subject.type !== "user" || !userId || subject.allowedWhen !== "private-direct-conversation") return;
|
|
2502
|
+
return {
|
|
2503
|
+
type: "user",
|
|
2504
|
+
userId,
|
|
2505
|
+
allowedWhen: subject.allowedWhen,
|
|
2506
|
+
binding: {
|
|
2507
|
+
type: "slack-direct-conversation",
|
|
2508
|
+
teamId,
|
|
2509
|
+
channelId,
|
|
2510
|
+
signature: signPayload(secret, buildPayload([
|
|
2511
|
+
subject.allowedWhen,
|
|
2512
|
+
teamId,
|
|
2513
|
+
channelId,
|
|
2514
|
+
userId
|
|
2515
|
+
]))
|
|
2516
|
+
}
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
/** Verify that a delegated subject was signed for the dispatch destination. */
|
|
2520
|
+
function verifySlackDirectCredentialSubject(input) {
|
|
2521
|
+
const channelId = normalizeSlackConversationId(input.channelId);
|
|
2522
|
+
const secret = getCredentialSubjectSecret();
|
|
2523
|
+
if (!channelId || !secret) return false;
|
|
2524
|
+
const { subject } = input;
|
|
2525
|
+
const binding = subject.binding;
|
|
2526
|
+
if (subject.type !== "user" || !isActorUserId(subject.userId) || subject.allowedWhen !== "private-direct-conversation" || !binding || binding.type !== "slack-direct-conversation" || typeof binding.signature !== "string" || !binding.signature || binding.teamId !== input.teamId || binding.channelId !== channelId) return false;
|
|
2527
|
+
return timingSafeMatch(signPayload(secret, buildPayload([
|
|
2528
|
+
subject.allowedWhen,
|
|
2529
|
+
binding.teamId,
|
|
2530
|
+
binding.channelId,
|
|
2531
|
+
subject.userId
|
|
2532
|
+
])), binding.signature);
|
|
2533
|
+
}
|
|
2534
|
+
/** Bind a delegated user subject to one scheduler task dispatch. */
|
|
2535
|
+
function bindScheduledTaskCredentialSubject(input) {
|
|
2536
|
+
const secret = getCredentialSubjectSecret();
|
|
2537
|
+
const plugin = input.plugin.trim();
|
|
2538
|
+
const userId = parseActorUserId(input.subject.userId);
|
|
2539
|
+
if (!secret || plugin !== "scheduler" || !userId || input.subject.allowedWhen !== "scheduled-task") return;
|
|
2540
|
+
const taskId = input.subject.taskId;
|
|
2541
|
+
if (!taskId || taskId !== taskId.trim()) return;
|
|
2542
|
+
return {
|
|
2543
|
+
type: "user",
|
|
2544
|
+
userId,
|
|
2545
|
+
allowedWhen: "scheduled-task",
|
|
2546
|
+
taskId,
|
|
2547
|
+
binding: {
|
|
2548
|
+
type: "scheduled-task",
|
|
2549
|
+
plugin,
|
|
2550
|
+
taskId,
|
|
2551
|
+
signature: signPayload(secret, buildPayload([
|
|
2552
|
+
"scheduled-task",
|
|
2553
|
+
plugin,
|
|
2554
|
+
taskId,
|
|
2555
|
+
userId
|
|
2556
|
+
]))
|
|
2557
|
+
}
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
/** Verify that a delegated subject was signed for one scheduler task. */
|
|
2561
|
+
function verifyScheduledTaskCredentialSubject(input) {
|
|
2562
|
+
const secret = getCredentialSubjectSecret();
|
|
2563
|
+
const { subject } = input;
|
|
2564
|
+
const binding = subject.binding;
|
|
2565
|
+
if (!secret || input.plugin !== "scheduler" || subject.type !== "user" || !isActorUserId(subject.userId) || subject.allowedWhen !== "scheduled-task" || !subject.taskId || binding.type !== "scheduled-task" || binding.plugin !== input.plugin || binding.taskId !== subject.taskId) return false;
|
|
2566
|
+
return timingSafeMatch(signPayload(secret, buildPayload([
|
|
2567
|
+
"scheduled-task",
|
|
2568
|
+
binding.plugin,
|
|
2569
|
+
binding.taskId,
|
|
2570
|
+
subject.userId
|
|
2571
|
+
])), binding.signature);
|
|
2572
|
+
}
|
|
2573
|
+
/** Bind a delegated user subject to one event task dispatch. */
|
|
2574
|
+
function bindEventTaskCredentialSubject(input) {
|
|
2575
|
+
const secret = getCredentialSubjectSecret();
|
|
2576
|
+
const plugin = input.plugin.trim();
|
|
2577
|
+
const userId = parseActorUserId(input.subject.userId);
|
|
2578
|
+
if (!secret || plugin !== "junior" || !userId || input.subject.allowedWhen !== "event-task") return;
|
|
2579
|
+
const taskId = input.subject.taskId;
|
|
2580
|
+
if (!taskId || taskId !== taskId.trim()) return;
|
|
2581
|
+
return {
|
|
2582
|
+
type: "user",
|
|
2583
|
+
userId,
|
|
2584
|
+
allowedWhen: "event-task",
|
|
2585
|
+
taskId,
|
|
2586
|
+
binding: {
|
|
2587
|
+
type: "event-task",
|
|
2588
|
+
plugin,
|
|
2589
|
+
taskId,
|
|
2590
|
+
signature: signPayload(secret, buildPayload([
|
|
2591
|
+
"event-task",
|
|
2592
|
+
plugin,
|
|
2593
|
+
taskId,
|
|
2594
|
+
userId
|
|
2595
|
+
]))
|
|
2596
|
+
}
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
/** Verify that a delegated subject was signed for one event task. */
|
|
2600
|
+
function verifyEventTaskCredentialSubject(input) {
|
|
2601
|
+
const secret = getCredentialSubjectSecret();
|
|
2602
|
+
const { subject } = input;
|
|
2603
|
+
const binding = subject.binding;
|
|
2604
|
+
if (!secret || input.plugin !== "junior" || subject.type !== "user" || !isActorUserId(subject.userId) || subject.allowedWhen !== "event-task" || !subject.taskId || binding.type !== "event-task" || binding.plugin !== input.plugin || binding.taskId !== subject.taskId) return false;
|
|
2605
|
+
return timingSafeMatch(signPayload(secret, buildPayload([
|
|
2606
|
+
"event-task",
|
|
2607
|
+
binding.plugin,
|
|
2608
|
+
binding.taskId,
|
|
2609
|
+
subject.userId
|
|
2610
|
+
])), binding.signature);
|
|
2611
|
+
}
|
|
2612
|
+
//#endregion
|
|
2613
|
+
//#region src/chat/slack/tool-support/channel-capabilities.ts
|
|
2614
|
+
/** Resolve channel capabilities from a Slack channel ID. */
|
|
2615
|
+
function resolveChannelCapabilities(channelId) {
|
|
2616
|
+
return {
|
|
2617
|
+
canCreateCanvas: isConversationScopedChannel(channelId),
|
|
2618
|
+
canSendFiles: isConversationScopedChannel(channelId),
|
|
2619
|
+
canPostToChannel: isConversationChannel(channelId),
|
|
2620
|
+
canAddReactions: isConversationScopedChannel(channelId)
|
|
2621
|
+
};
|
|
2622
|
+
}
|
|
2623
|
+
//#endregion
|
|
2624
|
+
//#region src/chat/workspaces/checkout-path.ts
|
|
2625
|
+
/** Derive the fixed sandbox checkout path for one repository, or undefined when invalid. */
|
|
2626
|
+
function tryWorkspaceRepoCheckoutPath(repo) {
|
|
2627
|
+
const name = repo.split("/").filter(Boolean).at(-1);
|
|
2628
|
+
if (!name || name === "." || name === ".." || !/^[A-Za-z0-9._-]+$/.test(name)) return;
|
|
2629
|
+
return `repos/${name}`;
|
|
2630
|
+
}
|
|
2631
|
+
/** Derive the fixed sandbox checkout path for one repository. */
|
|
2632
|
+
function workspaceRepoCheckoutPath(repo) {
|
|
2633
|
+
const path = tryWorkspaceRepoCheckoutPath(repo);
|
|
2634
|
+
if (!path) throw new Error(`Invalid repository name for checkout path: ${repo}`);
|
|
2635
|
+
return path;
|
|
2636
|
+
}
|
|
2637
|
+
//#endregion
|
|
2638
|
+
//#region src/chat/sandbox/snapshot/builder-sandbox.ts
|
|
2639
|
+
/** Resume the named Sandbox that owns one Workspace snapshot build. */
|
|
2640
|
+
async function getWorkspaceSnapshotBuilder(name, signal) {
|
|
2641
|
+
const credentials = getVercelSandboxCredentials();
|
|
2642
|
+
return await Sandbox.get({
|
|
2643
|
+
name,
|
|
2644
|
+
resume: true,
|
|
2645
|
+
signal,
|
|
2646
|
+
...credentials ?? {}
|
|
2647
|
+
});
|
|
2648
|
+
}
|
|
2649
|
+
/** Permanently delete named Workspace snapshot builders without resuming them. */
|
|
2650
|
+
async function deleteWorkspaceSnapshotBuilders(names) {
|
|
2651
|
+
const credentials = getVercelSandboxCredentials();
|
|
2652
|
+
let hasError = false;
|
|
2653
|
+
let firstError;
|
|
2654
|
+
for (const name of new Set(names)) try {
|
|
2655
|
+
await (await Sandbox.get({
|
|
2656
|
+
name,
|
|
2657
|
+
resume: false,
|
|
2658
|
+
...credentials ?? {}
|
|
2659
|
+
})).delete();
|
|
2660
|
+
} catch (error) {
|
|
2661
|
+
if (!isSandboxMissingError(error)) {
|
|
2662
|
+
if (!hasError) firstError = error;
|
|
2663
|
+
hasError = true;
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
if (hasError) throw firstError;
|
|
2667
|
+
}
|
|
2668
|
+
//#endregion
|
|
2669
|
+
//#region src/chat/sandbox/snapshot/runtime.ts
|
|
2670
|
+
/** Runtime used to build and start reusable Sandbox snapshots. */
|
|
2671
|
+
const SANDBOX_RUNTIME = "node22";
|
|
2672
|
+
//#endregion
|
|
2673
|
+
//#region src/chat/sandbox/snapshot/store.ts
|
|
2674
|
+
function builderNames(rows) {
|
|
2675
|
+
return [...new Set(rows.map((row) => row.sandboxName).filter((name) => Boolean(name)))];
|
|
2676
|
+
}
|
|
2677
|
+
function sameWorkspaceRecipe(stored, expected) {
|
|
2678
|
+
if (stored.setupScript !== expected.setupScript || stored.repos.length !== expected.repos.length) return false;
|
|
2679
|
+
const expectedRepos = new Set(expected.repos.map((repo) => JSON.stringify([repo.provider, repo.repo])));
|
|
2680
|
+
return stored.repos.every((repo) => expectedRepos.has(JSON.stringify([repo.provider, repo.repo])));
|
|
2681
|
+
}
|
|
2682
|
+
function snapshotFromRow(row) {
|
|
2683
|
+
if (row.status !== "ready" || !row.snapshotId || !row.generatedAt || row.buildDurationMs == null) throw new Error(`Invalid ready Workspace snapshot row: ${row.id}`);
|
|
2684
|
+
return {
|
|
2685
|
+
id: row.snapshotId,
|
|
2686
|
+
generatedAt: row.generatedAt,
|
|
2687
|
+
buildDurationMs: row.buildDurationMs,
|
|
2688
|
+
sizeBytes: row.sizeBytes,
|
|
2689
|
+
profileHash: row.profileHash
|
|
2690
|
+
};
|
|
2691
|
+
}
|
|
2692
|
+
function snapshotBuildFromRow(row) {
|
|
2693
|
+
if (row.status === "ready" || !row.buildStartedAt || !row.buildPhase) throw new Error(`Invalid Workspace snapshot build row: ${row.id}`);
|
|
2694
|
+
return {
|
|
2695
|
+
id: row.id,
|
|
2696
|
+
status: row.status,
|
|
2697
|
+
phase: row.buildPhase,
|
|
2698
|
+
profileHash: row.profileHash,
|
|
2699
|
+
startedAt: row.buildStartedAt,
|
|
2700
|
+
sandboxName: row.buildSandboxName,
|
|
2701
|
+
commandId: row.buildCommandId,
|
|
2702
|
+
error: row.buildError
|
|
2703
|
+
};
|
|
2704
|
+
}
|
|
2705
|
+
/** Load bounded ready and build views for one Workspace profile hash. */
|
|
2706
|
+
async function loadSnapshotsForProfile(db, workspaceId, profileHash) {
|
|
2707
|
+
const profile = and(eq(juniorSnapshots.workspaceId, workspaceId), eq(juniorSnapshots.profileHash, profileHash));
|
|
2708
|
+
const order = [
|
|
2709
|
+
desc(juniorSnapshots.updatedAt),
|
|
2710
|
+
desc(juniorSnapshots.createdAt),
|
|
2711
|
+
desc(juniorSnapshots.id)
|
|
2712
|
+
];
|
|
2713
|
+
const [readyRows, buildRows] = await Promise.all([db.select().from(juniorSnapshots).where(and(profile, eq(juniorSnapshots.status, "ready"))).orderBy(...order).limit(1), db.select().from(juniorSnapshots).where(and(profile, ne(juniorSnapshots.status, "ready"))).orderBy(asc(juniorSnapshots.status), ...order).limit(1)]);
|
|
2714
|
+
return {
|
|
2715
|
+
ready: readyRows[0] ? snapshotFromRow(readyRows[0]) : null,
|
|
2716
|
+
build: buildRows[0] ? snapshotBuildFromRow(buildRows[0]) : null
|
|
2717
|
+
};
|
|
2718
|
+
}
|
|
2719
|
+
/** Drop every snapshot row before its Workspace is deleted. */
|
|
2720
|
+
async function clearWorkspaceSnapshots(db, workspaceId) {
|
|
2721
|
+
await db.select({ id: juniorWorkspaces.id }).from(juniorWorkspaces).where(eq(juniorWorkspaces.id, workspaceId)).for("update");
|
|
2722
|
+
return builderNames(await db.delete(juniorSnapshots).where(eq(juniorSnapshots.workspaceId, workspaceId)).returning({ sandboxName: juniorSnapshots.buildSandboxName }));
|
|
2723
|
+
}
|
|
2724
|
+
/** Drop one ready snapshot and return the name of its provider owner. */
|
|
2725
|
+
async function invalidateReadySnapshot(params) {
|
|
2726
|
+
const executor = getSqlExecutor();
|
|
2727
|
+
return await executor.transaction(async () => {
|
|
2728
|
+
return (await executor.db().delete(juniorSnapshots).where(and(eq(juniorSnapshots.workspaceId, params.workspaceId), eq(juniorSnapshots.profileHash, params.profileHash), eq(juniorSnapshots.status, "ready"), eq(juniorSnapshots.snapshotId, params.snapshotId))).returning({ sandboxName: juniorSnapshots.buildSandboxName }))[0]?.sandboxName ?? null;
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
/** Complete one owned build and report provider owners that it replaced. */
|
|
2732
|
+
async function setWorkspaceSnapshot(workspaceId, snapshot, options) {
|
|
2733
|
+
const executor = getSqlExecutor();
|
|
2734
|
+
return await executor.transaction(async () => {
|
|
2735
|
+
const db = executor.db();
|
|
2736
|
+
const now = /* @__PURE__ */ new Date();
|
|
2737
|
+
if ((await db.update(juniorSnapshots).set({
|
|
2738
|
+
status: "ready",
|
|
2739
|
+
snapshotId: snapshot.id,
|
|
2740
|
+
buildDurationMs: snapshot.buildDurationMs,
|
|
2741
|
+
sizeBytes: snapshot.sizeBytes,
|
|
2742
|
+
generatedAt: snapshot.generatedAt,
|
|
2743
|
+
buildStartedAt: null,
|
|
2744
|
+
buildPhase: null,
|
|
2745
|
+
buildCommandId: null,
|
|
2746
|
+
buildError: null,
|
|
2747
|
+
updatedAt: now
|
|
2748
|
+
}).where(and(eq(juniorSnapshots.id, options.buildId), eq(juniorSnapshots.workspaceId, workspaceId), eq(juniorSnapshots.profileHash, snapshot.profileHash), eq(juniorSnapshots.status, "building"))).returning({ id: juniorSnapshots.id })).length === 0) return {
|
|
2749
|
+
written: false,
|
|
2750
|
+
replacedBuilderNames: []
|
|
2751
|
+
};
|
|
2752
|
+
return {
|
|
2753
|
+
written: true,
|
|
2754
|
+
replacedBuilderNames: builderNames(await db.delete(juniorSnapshots).where(and(eq(juniorSnapshots.workspaceId, workspaceId), eq(juniorSnapshots.profileHash, snapshot.profileHash), ne(juniorSnapshots.id, options.buildId), or(eq(juniorSnapshots.status, "failed"), eq(juniorSnapshots.status, "ready")))).returning({ sandboxName: juniorSnapshots.buildSandboxName }))
|
|
2755
|
+
};
|
|
2756
|
+
});
|
|
2757
|
+
}
|
|
2758
|
+
/** Update an owned build row. Initial creation locks and verifies its Workspace. */
|
|
2759
|
+
async function setWorkspaceSnapshotBuild(workspaceId, build, options = {}) {
|
|
2760
|
+
const executor = getSqlExecutor();
|
|
2761
|
+
return await executor.transaction(async () => {
|
|
2762
|
+
const db = executor.db();
|
|
2763
|
+
const now = /* @__PURE__ */ new Date();
|
|
2764
|
+
if ((await db.update(juniorSnapshots).set({
|
|
2765
|
+
status: build.status,
|
|
2766
|
+
buildStartedAt: build.startedAt,
|
|
2767
|
+
buildPhase: build.phase,
|
|
2768
|
+
buildSandboxName: build.sandboxName,
|
|
2769
|
+
buildCommandId: build.commandId,
|
|
2770
|
+
buildError: build.error,
|
|
2771
|
+
updatedAt: now
|
|
2772
|
+
}).where(and(eq(juniorSnapshots.id, build.id), eq(juniorSnapshots.workspaceId, workspaceId), eq(juniorSnapshots.profileHash, build.profileHash), eq(juniorSnapshots.status, "building"))).returning({ id: juniorSnapshots.id })).length > 0) return true;
|
|
2773
|
+
if (!options.insertIfMissing) return false;
|
|
2774
|
+
const workspace = (await db.select({ setupScript: juniorWorkspaces.setupScript }).from(juniorWorkspaces).where(eq(juniorWorkspaces.id, workspaceId)).for("update"))[0];
|
|
2775
|
+
if (!workspace) return false;
|
|
2776
|
+
if (options.expectedRecipe) {
|
|
2777
|
+
const repos = await db.select({
|
|
2778
|
+
provider: juniorWorkspaceRepos.provider,
|
|
2779
|
+
repo: juniorWorkspaceRepos.repo
|
|
2780
|
+
}).from(juniorWorkspaceRepos).where(eq(juniorWorkspaceRepos.workspaceId, workspaceId));
|
|
2781
|
+
if (!sameWorkspaceRecipe({
|
|
2782
|
+
...workspace,
|
|
2783
|
+
repos
|
|
2784
|
+
}, options.expectedRecipe)) return false;
|
|
2785
|
+
}
|
|
2786
|
+
return (await db.insert(juniorSnapshots).values({
|
|
2787
|
+
id: build.id,
|
|
2788
|
+
workspaceId,
|
|
2789
|
+
profileHash: build.profileHash,
|
|
2790
|
+
status: build.status,
|
|
2791
|
+
snapshotId: null,
|
|
2792
|
+
buildDurationMs: null,
|
|
2793
|
+
sizeBytes: null,
|
|
2794
|
+
generatedAt: null,
|
|
2795
|
+
buildStartedAt: build.startedAt,
|
|
2796
|
+
buildPhase: build.phase,
|
|
2797
|
+
buildSandboxName: build.sandboxName,
|
|
2798
|
+
buildCommandId: build.commandId,
|
|
2799
|
+
buildError: build.error,
|
|
2800
|
+
createdAt: now,
|
|
2801
|
+
updatedAt: now
|
|
2802
|
+
}).onConflictDoNothing().returning({ id: juniorSnapshots.id })).length > 0;
|
|
2803
|
+
});
|
|
2804
|
+
}
|
|
2805
|
+
//#endregion
|
|
2806
|
+
//#region src/chat/workspaces/validation.ts
|
|
2807
|
+
const WORKSPACE_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
2808
|
+
const PROVIDER_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
2809
|
+
const REPO_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
2810
|
+
const MAX_SETUP_SCRIPT_BYTES = 65536;
|
|
2811
|
+
const MAX_REPOS = 32;
|
|
2812
|
+
/** Validation failure for one Workspace recipe write. */
|
|
2813
|
+
var WorkspaceValidationError = class extends Error {
|
|
2814
|
+
constructor(message) {
|
|
2815
|
+
super(message);
|
|
2816
|
+
this.name = "WorkspaceValidationError";
|
|
2817
|
+
}
|
|
2818
|
+
};
|
|
2819
|
+
/** Normalize and validate one Workspace recipe write payload. */
|
|
2820
|
+
function normalizeWorkspaceRecipe(input) {
|
|
2821
|
+
const name = input.name.trim().toLowerCase();
|
|
2822
|
+
if (!WORKSPACE_NAME_PATTERN.test(name)) throw new WorkspaceValidationError("Workspace name must start with a letter and use only lowercase letters, digits, underscores, or hyphens.");
|
|
2823
|
+
const setupScript = input.setupScript ?? "";
|
|
2824
|
+
if (typeof setupScript !== "string") throw new WorkspaceValidationError("Setup script must be a string.");
|
|
2825
|
+
if (Buffer.byteLength(setupScript, "utf8") > MAX_SETUP_SCRIPT_BYTES) throw new WorkspaceValidationError(`Setup script must be at most ${MAX_SETUP_SCRIPT_BYTES} bytes.`);
|
|
2826
|
+
if (!Array.isArray(input.repos)) throw new WorkspaceValidationError("Repositories must be an array.");
|
|
2827
|
+
if (input.repos.length > MAX_REPOS) throw new WorkspaceValidationError(`A Workspace can include at most ${MAX_REPOS} repositories.`);
|
|
2828
|
+
const repos = input.repos.map((entry, index) => {
|
|
2829
|
+
const provider = entry.provider.trim().toLowerCase();
|
|
2830
|
+
if (!PROVIDER_PATTERN.test(provider)) throw new WorkspaceValidationError(`Repository ${index + 1} provider is invalid.`);
|
|
2831
|
+
const repo = entry.repo.trim();
|
|
2832
|
+
if (!REPO_PATTERN.test(repo)) throw new WorkspaceValidationError(`Repository ${index + 1} must use owner/name format.`);
|
|
2833
|
+
if (!tryWorkspaceRepoCheckoutPath(repo)) throw new WorkspaceValidationError(`Repository ${index + 1} name cannot be used as a checkout path.`);
|
|
2834
|
+
return {
|
|
2835
|
+
provider,
|
|
2836
|
+
repo
|
|
2837
|
+
};
|
|
2838
|
+
});
|
|
2839
|
+
const identities = /* @__PURE__ */ new Set();
|
|
2840
|
+
for (const repo of repos) {
|
|
2841
|
+
const identity = `${repo.provider}:${repo.repo.toLowerCase()}`;
|
|
2842
|
+
if (identities.has(identity)) throw new WorkspaceValidationError(`Duplicate repository in Workspace: ${repo.provider}:${repo.repo}`);
|
|
2843
|
+
identities.add(identity);
|
|
2844
|
+
}
|
|
2845
|
+
const checkoutPaths = /* @__PURE__ */ new Set();
|
|
2846
|
+
for (const repo of repos) {
|
|
2847
|
+
const path = tryWorkspaceRepoCheckoutPath(repo.repo);
|
|
2848
|
+
const key = path.toLowerCase();
|
|
2849
|
+
if (checkoutPaths.has(key)) throw new WorkspaceValidationError(`Workspace checkout path collision: ${path}`);
|
|
2850
|
+
checkoutPaths.add(key);
|
|
2851
|
+
}
|
|
2852
|
+
return {
|
|
2853
|
+
name,
|
|
2854
|
+
setupScript,
|
|
2855
|
+
repos
|
|
2856
|
+
};
|
|
2857
|
+
}
|
|
2858
|
+
//#endregion
|
|
2859
|
+
//#region src/chat/workspaces/store.ts
|
|
2860
|
+
function workspaceFromRows(row, repos, snapshot) {
|
|
2861
|
+
return {
|
|
2862
|
+
id: row.id,
|
|
2863
|
+
name: row.name,
|
|
2864
|
+
setupScript: row.setupScript,
|
|
2865
|
+
repos: repos.map((repo) => ({
|
|
2866
|
+
provider: repo.provider,
|
|
2867
|
+
repo: repo.repo
|
|
2868
|
+
})),
|
|
2869
|
+
snapshot
|
|
2870
|
+
};
|
|
2871
|
+
}
|
|
2872
|
+
async function workspaceWithSnapshot(db, row, repos) {
|
|
2873
|
+
const workspace = workspaceFromRows(row, repos, null);
|
|
2874
|
+
const profileHash = hash(SANDBOX_RUNTIME, workspace);
|
|
2875
|
+
if (!profileHash) return workspace;
|
|
2876
|
+
const snapshots = await loadSnapshotsForProfile(db, workspace.id, profileHash);
|
|
2877
|
+
return {
|
|
2878
|
+
...workspace,
|
|
2879
|
+
snapshot: snapshots.ready
|
|
2880
|
+
};
|
|
2881
|
+
}
|
|
2882
|
+
async function loadWorkspaceRepos(db, workspaceId) {
|
|
2883
|
+
return await db.select().from(juniorWorkspaceRepos).where(eq(juniorWorkspaceRepos.workspaceId, workspaceId)).orderBy(asc(juniorWorkspaceRepos.provider), asc(juniorWorkspaceRepos.repo));
|
|
2884
|
+
}
|
|
2885
|
+
async function replaceWorkspaceRepos(db, workspaceId, repos) {
|
|
2886
|
+
await db.delete(juniorWorkspaceRepos).where(eq(juniorWorkspaceRepos.workspaceId, workspaceId));
|
|
2887
|
+
if (repos.length === 0) return;
|
|
2888
|
+
await db.insert(juniorWorkspaceRepos).values(repos.map((repo) => ({
|
|
2889
|
+
workspaceId,
|
|
2890
|
+
provider: repo.provider,
|
|
2891
|
+
repo: repo.repo
|
|
2892
|
+
})));
|
|
2893
|
+
}
|
|
2894
|
+
function sameWorkspaceRepos(current, next) {
|
|
2895
|
+
if (current.length !== next.length) return false;
|
|
2896
|
+
const identity = (repo) => `${repo.provider}:${repo.repo.toLowerCase()}`;
|
|
2897
|
+
const currentIds = current.map(identity).sort();
|
|
2898
|
+
const nextIds = next.map(identity).sort();
|
|
2899
|
+
return currentIds.every((value, index) => value === nextIds[index]);
|
|
2900
|
+
}
|
|
2901
|
+
function isUniqueViolation(error) {
|
|
2902
|
+
let current = error;
|
|
2903
|
+
for (let depth = 0; current && depth < 6; depth += 1) {
|
|
2904
|
+
if (typeof current === "object" && current !== null && "code" in current && current.code === "23505") return true;
|
|
2905
|
+
current = typeof current === "object" && current !== null && "cause" in current ? current.cause : void 0;
|
|
2906
|
+
}
|
|
2907
|
+
return false;
|
|
2908
|
+
}
|
|
2909
|
+
async function cleanupSnapshotBuilders(workspaceId, names) {
|
|
2910
|
+
if (names.length === 0) return;
|
|
2911
|
+
try {
|
|
2912
|
+
await deleteWorkspaceSnapshotBuilders(names);
|
|
2913
|
+
} catch (error) {
|
|
2914
|
+
logException(error, "sandbox.workspace_snapshot.builder.delete_failed", {
|
|
2915
|
+
"app.workspace.id": workspaceId,
|
|
2916
|
+
"app.sandbox.snapshot.builder_count": names.length
|
|
2917
|
+
});
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
/** List Workspace recipes by stable name. */
|
|
2921
|
+
async function listWorkspaces(db) {
|
|
2922
|
+
const [workspaces, repos] = await Promise.all([db.select().from(juniorWorkspaces).orderBy(asc(juniorWorkspaces.name)), db.select().from(juniorWorkspaceRepos).orderBy(asc(juniorWorkspaceRepos.workspaceId), asc(juniorWorkspaceRepos.provider), asc(juniorWorkspaceRepos.repo))]);
|
|
2923
|
+
return workspaces.map((workspace) => workspaceFromRows(workspace, repos.filter((repo) => repo.workspaceId === workspace.id), null));
|
|
2924
|
+
}
|
|
2925
|
+
/** Find Workspace names that include one provider repository. */
|
|
2926
|
+
async function listWorkspaceNamesByRepository(db, input) {
|
|
2927
|
+
const provider = input.provider.trim().toLowerCase();
|
|
2928
|
+
const repo = input.repo.trim();
|
|
2929
|
+
return (await db.select({ name: juniorWorkspaces.name }).from(juniorWorkspaceRepos).innerJoin(juniorWorkspaces, eq(juniorWorkspaces.id, juniorWorkspaceRepos.workspaceId)).where(and(eq(juniorWorkspaceRepos.provider, provider), sql`lower(${juniorWorkspaceRepos.repo}) = lower(${repo})`)).orderBy(asc(juniorWorkspaces.name))).map((row) => row.name);
|
|
2930
|
+
}
|
|
2931
|
+
/** Resolve one Workspace recipe by name. */
|
|
2932
|
+
async function getWorkspaceByName(db, name) {
|
|
2933
|
+
const workspace = (await db.select().from(juniorWorkspaces).where(eq(juniorWorkspaces.name, name)).limit(1))[0];
|
|
2934
|
+
if (!workspace) return void 0;
|
|
2935
|
+
return await workspaceWithSnapshot(db, workspace, await loadWorkspaceRepos(db, workspace.id));
|
|
2936
|
+
}
|
|
2937
|
+
/** Resolve one Workspace recipe by id. */
|
|
2938
|
+
async function getWorkspace(db, id) {
|
|
2939
|
+
const workspace = (await db.select().from(juniorWorkspaces).where(eq(juniorWorkspaces.id, id)).limit(1))[0];
|
|
2940
|
+
if (!workspace) return void 0;
|
|
2941
|
+
return await workspaceWithSnapshot(db, workspace, await loadWorkspaceRepos(db, workspace.id));
|
|
2942
|
+
}
|
|
2943
|
+
/** Create one install-wide Workspace recipe. */
|
|
2944
|
+
async function createWorkspace(input) {
|
|
2945
|
+
const recipe = normalizeWorkspaceRecipe(input);
|
|
2946
|
+
const executor = getSqlExecutor();
|
|
2947
|
+
const now = /* @__PURE__ */ new Date();
|
|
2948
|
+
const id = randomUUID();
|
|
2949
|
+
try {
|
|
2950
|
+
await executor.transaction(async () => {
|
|
2951
|
+
const db = executor.db();
|
|
2952
|
+
await db.insert(juniorWorkspaces).values({
|
|
2953
|
+
id,
|
|
2954
|
+
name: recipe.name,
|
|
2955
|
+
setupScript: recipe.setupScript,
|
|
2956
|
+
createdAt: now,
|
|
2957
|
+
updatedAt: now
|
|
2958
|
+
});
|
|
2959
|
+
await replaceWorkspaceRepos(db, id, recipe.repos);
|
|
2960
|
+
});
|
|
2961
|
+
} catch (error) {
|
|
2962
|
+
if (isUniqueViolation(error)) throw new WorkspaceValidationError(`Workspace name already exists: ${recipe.name}`);
|
|
2963
|
+
throw error;
|
|
2964
|
+
}
|
|
2965
|
+
return await getWorkspace(executor.db(), id);
|
|
2966
|
+
}
|
|
2967
|
+
/** Replace one install-wide Workspace recipe. */
|
|
2968
|
+
async function updateWorkspace(id, input) {
|
|
2969
|
+
const recipe = normalizeWorkspaceRecipe(input);
|
|
2970
|
+
const executor = getSqlExecutor();
|
|
2971
|
+
const now = /* @__PURE__ */ new Date();
|
|
2972
|
+
try {
|
|
2973
|
+
const result = await executor.transaction(async () => {
|
|
2974
|
+
const db = executor.db();
|
|
2975
|
+
const existing = (await db.select().from(juniorWorkspaces).where(eq(juniorWorkspaces.id, id)).limit(1))[0];
|
|
2976
|
+
if (!existing) return void 0;
|
|
2977
|
+
const existingRepos = await loadWorkspaceRepos(db, id);
|
|
2978
|
+
const snapshotChanged = existing.setupScript !== recipe.setupScript || !sameWorkspaceRepos(existingRepos, recipe.repos);
|
|
2979
|
+
const rows = await db.update(juniorWorkspaces).set({
|
|
2980
|
+
name: recipe.name,
|
|
2981
|
+
setupScript: recipe.setupScript,
|
|
2982
|
+
updatedAt: now
|
|
2983
|
+
}).where(eq(juniorWorkspaces.id, id)).returning();
|
|
2984
|
+
await replaceWorkspaceRepos(db, id, recipe.repos);
|
|
2985
|
+
return {
|
|
2986
|
+
builderNames: snapshotChanged ? await clearWorkspaceSnapshots(db, id) : [],
|
|
2987
|
+
workspace: rows[0]
|
|
2988
|
+
};
|
|
2989
|
+
});
|
|
2990
|
+
if (!result) return void 0;
|
|
2991
|
+
await cleanupSnapshotBuilders(id, result.builderNames);
|
|
2992
|
+
} catch (error) {
|
|
2993
|
+
if (isUniqueViolation(error)) throw new WorkspaceValidationError(`Workspace name already exists: ${recipe.name}`);
|
|
2994
|
+
throw error;
|
|
2995
|
+
}
|
|
2996
|
+
return await getWorkspace(executor.db(), id);
|
|
2997
|
+
}
|
|
2998
|
+
/** Delete one install-wide Workspace recipe. */
|
|
2999
|
+
async function deleteWorkspace(id) {
|
|
3000
|
+
const executor = getSqlExecutor();
|
|
3001
|
+
const result = await executor.transaction(async () => {
|
|
3002
|
+
const db = executor.db();
|
|
3003
|
+
return {
|
|
3004
|
+
builderNames: await clearWorkspaceSnapshots(db, id),
|
|
3005
|
+
deleted: (await db.delete(juniorWorkspaces).where(eq(juniorWorkspaces.id, id)).returning({ id: juniorWorkspaces.id })).length > 0
|
|
3006
|
+
};
|
|
3007
|
+
});
|
|
3008
|
+
await cleanupSnapshotBuilders(id, result.builderNames);
|
|
3009
|
+
return result.deleted;
|
|
3010
|
+
}
|
|
3011
|
+
//#endregion
|
|
3012
|
+
//#region src/chat/code/store.ts
|
|
3013
|
+
/** Record the latest details for one code change created by Junior. */
|
|
3014
|
+
async function recordCodeChange(db, provider, input) {
|
|
3015
|
+
const change = codeChangeInputSchema.parse(input);
|
|
3016
|
+
const repositories = await db.insert(juniorCodeRepositories).values({
|
|
3017
|
+
id: randomUUID(),
|
|
3018
|
+
name: change.repository.name,
|
|
3019
|
+
provider,
|
|
3020
|
+
providerId: change.repository.providerId,
|
|
3021
|
+
url: change.repository.url,
|
|
3022
|
+
updatedAt: change.updatedAt
|
|
3023
|
+
}).onConflictDoUpdate({
|
|
3024
|
+
target: [juniorCodeRepositories.provider, juniorCodeRepositories.providerId],
|
|
3025
|
+
set: {
|
|
3026
|
+
name: change.repository.name,
|
|
3027
|
+
...change.repository.url ? { url: change.repository.url } : void 0,
|
|
3028
|
+
updatedAt: change.updatedAt
|
|
3029
|
+
},
|
|
3030
|
+
where: lte(juniorCodeRepositories.updatedAt, change.updatedAt)
|
|
3031
|
+
}).returning({ id: juniorCodeRepositories.id });
|
|
3032
|
+
const existingRepositories = repositories[0] ? [] : await db.select({ id: juniorCodeRepositories.id }).from(juniorCodeRepositories).where(and(eq(juniorCodeRepositories.provider, provider), eq(juniorCodeRepositories.providerId, change.repository.providerId))).limit(1);
|
|
3033
|
+
const repositoryId = repositories[0]?.id ?? existingRepositories[0]?.id;
|
|
3034
|
+
if (!repositoryId) throw new Error("Code repository upsert returned no row");
|
|
3035
|
+
const conversationIds = sql.join(change.conversationIds.map((conversationId) => sql`${conversationId}`), sql`, `);
|
|
3036
|
+
const values = {
|
|
3037
|
+
closedAt: change.closedAt ?? null,
|
|
3038
|
+
conversationIds: change.conversationIds.length > 0 ? sql`ARRAY(
|
|
3039
|
+
SELECT DISTINCT value
|
|
3040
|
+
FROM unnest(
|
|
3041
|
+
${juniorCodeChanges.conversationIds}
|
|
3042
|
+
|| ARRAY[${conversationIds}]::text[]
|
|
3043
|
+
) AS value
|
|
3044
|
+
ORDER BY value
|
|
3045
|
+
)` : juniorCodeChanges.conversationIds,
|
|
3046
|
+
mergedAt: change.mergedAt ?? null,
|
|
3047
|
+
number: change.number,
|
|
3048
|
+
openedAt: change.openedAt,
|
|
3049
|
+
repositoryId,
|
|
3050
|
+
state: change.state,
|
|
3051
|
+
...change.title ? { title: change.title } : void 0,
|
|
3052
|
+
updatedAt: change.updatedAt,
|
|
3053
|
+
url: change.url ?? null
|
|
3054
|
+
};
|
|
3055
|
+
await db.insert(juniorCodeChanges).values({
|
|
3056
|
+
...values,
|
|
3057
|
+
conversationIds: change.conversationIds,
|
|
3058
|
+
id: randomUUID(),
|
|
3059
|
+
provider,
|
|
3060
|
+
providerId: change.providerId
|
|
3061
|
+
}).onConflictDoUpdate({
|
|
3062
|
+
target: [juniorCodeChanges.provider, juniorCodeChanges.providerId],
|
|
3063
|
+
set: values,
|
|
3064
|
+
where: lte(juniorCodeChanges.updatedAt, change.updatedAt)
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
/** Add conversation links to an existing code change. */
|
|
3068
|
+
async function associateCodeChangeConversations(db, provider, input) {
|
|
3069
|
+
if (input.conversationIds.length === 0) return;
|
|
3070
|
+
const conversationIds = sql.join(input.conversationIds.map((conversationId) => sql`${conversationId}`), sql`, `);
|
|
3071
|
+
await db.update(juniorCodeChanges).set({ conversationIds: sql`ARRAY(
|
|
3072
|
+
SELECT DISTINCT value
|
|
3073
|
+
FROM unnest(
|
|
3074
|
+
${juniorCodeChanges.conversationIds}
|
|
3075
|
+
|| ARRAY[${conversationIds}]::text[]
|
|
3076
|
+
) AS value
|
|
3077
|
+
ORDER BY value
|
|
3078
|
+
)` }).where(and(eq(juniorCodeChanges.provider, provider), eq(juniorCodeChanges.providerId, input.providerId)));
|
|
3079
|
+
}
|
|
3080
|
+
//#endregion
|
|
3081
|
+
//#region src/chat/code/publisher.ts
|
|
3082
|
+
/** Connect a plugin to Junior's code records. */
|
|
3083
|
+
function createCodeChangePublisher(provider) {
|
|
3084
|
+
return {
|
|
3085
|
+
async associateConversations(input) {
|
|
3086
|
+
await associateCodeChangeConversations(getDb(), provider, input);
|
|
3087
|
+
},
|
|
3088
|
+
async record(input) {
|
|
3089
|
+
await recordCodeChange(getDb(), provider, input);
|
|
3090
|
+
}
|
|
3091
|
+
};
|
|
3092
|
+
}
|
|
3093
|
+
//#endregion
|
|
3094
|
+
//#region src/chat/plugins/agent-hooks.ts
|
|
3095
|
+
var agent_hooks_exports = /* @__PURE__ */ __exportAll({
|
|
3096
|
+
PluginHookDeniedError: () => PluginHookDeniedError,
|
|
3097
|
+
applyPluginFormatMarkdown: () => applyPluginFormatMarkdown,
|
|
3098
|
+
createPluginHookRunner: () => createPluginHookRunner,
|
|
3099
|
+
getPluginApiRoutes: () => getPluginApiRoutes,
|
|
3100
|
+
getPluginOperationalReports: () => getPluginOperationalReports,
|
|
3101
|
+
getPluginProfileReports: () => getPluginProfileReports,
|
|
3102
|
+
getPluginRoutes: () => getPluginRoutes,
|
|
3103
|
+
getPluginSlackConversationLink: () => getPluginSlackConversationLink,
|
|
3104
|
+
getPluginSystemPromptContributions: () => getPluginSystemPromptContributions,
|
|
3105
|
+
getPluginTools: () => getPluginTools,
|
|
3106
|
+
getPluginUserPromptContributions: () => getPluginUserPromptContributions,
|
|
3107
|
+
getPlugins: () => getPlugins,
|
|
3108
|
+
setPlugins: () => setPlugins,
|
|
3109
|
+
validatePlugins: () => validatePlugins
|
|
3110
|
+
});
|
|
3111
|
+
/** Signal that a plugin intentionally denied a tool execution. */
|
|
3112
|
+
var PluginHookDeniedError = class extends Error {
|
|
3113
|
+
constructor(message) {
|
|
3114
|
+
super(message);
|
|
3115
|
+
this.name = "PluginHookDeniedError";
|
|
3116
|
+
}
|
|
3117
|
+
};
|
|
3118
|
+
let registeredPlugins = [];
|
|
3119
|
+
const PLUGIN_NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
3120
|
+
const PLUGIN_TOOL_NAME_RE = /^[a-z][A-Za-z0-9]*$/;
|
|
3121
|
+
const OPERATIONAL_REPORT_MAX_METRICS = 8;
|
|
3122
|
+
const OPERATIONAL_REPORT_MAX_WIDGETS = 12;
|
|
3123
|
+
const OPERATIONAL_REPORT_MAX_CHART_SERIES = 8;
|
|
3124
|
+
const OPERATIONAL_REPORT_MAX_RECORD_SETS = 8;
|
|
3125
|
+
const OPERATIONAL_REPORT_MAX_FIELDS = 8;
|
|
3126
|
+
const OPERATIONAL_REPORT_MAX_RECORDS = 25;
|
|
3127
|
+
const OPERATIONAL_REPORT_MAX_LABEL_LENGTH = 80;
|
|
3128
|
+
const OPERATIONAL_REPORT_MAX_VALUE_LENGTH = 160;
|
|
3129
|
+
const PLUGIN_ROUTE_METHODS = /* @__PURE__ */ new Set([
|
|
3130
|
+
"GET",
|
|
3131
|
+
"POST",
|
|
3132
|
+
"PUT",
|
|
3133
|
+
"PATCH",
|
|
3134
|
+
"DELETE",
|
|
3135
|
+
"HEAD",
|
|
3136
|
+
"OPTIONS",
|
|
3137
|
+
"ALL"
|
|
3138
|
+
]);
|
|
3139
|
+
const PLUGIN_PROMPT_CONTRIBUTION_TOTAL_MAX_CHARS = 16e3;
|
|
3140
|
+
const PLUGIN_PROMPT_CONTEXT_MAX_BYTES = 8e3;
|
|
3141
|
+
const PLUGIN_PROMPT_CONTEXT_TOTAL_MAX_BYTES = 16e3;
|
|
3142
|
+
const systemPromptMessageArraySchema = z.array(promptMessageSchema);
|
|
3143
|
+
function isRecord(value) {
|
|
3144
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
3145
|
+
}
|
|
3146
|
+
function basePluginContext(plugin) {
|
|
3147
|
+
const name = plugin.manifest.name;
|
|
3148
|
+
return {
|
|
3149
|
+
plugin: { name },
|
|
3150
|
+
log: createPluginLogger(name),
|
|
3151
|
+
db: getDb()
|
|
3152
|
+
};
|
|
3153
|
+
}
|
|
3154
|
+
/** Convert manifest names into model-facing tool namespaces. */
|
|
3155
|
+
function pluginToolNamespace(pluginName) {
|
|
3156
|
+
const [first = "plugin", ...rest] = pluginName.split("-").filter(Boolean);
|
|
3157
|
+
return `${first}${rest.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`).join("")}`;
|
|
3158
|
+
}
|
|
3159
|
+
function systemPromptPluginContext(plugin) {
|
|
3160
|
+
return { ...basePluginContext(plugin) };
|
|
3161
|
+
}
|
|
3162
|
+
/** Bind Source, Destination, and Actor for one plugin invocation. */
|
|
3163
|
+
function pluginInvocationContext(context) {
|
|
3164
|
+
const common = {
|
|
3165
|
+
conversationId: context.conversationId,
|
|
3166
|
+
locationId: context.locationId
|
|
3167
|
+
};
|
|
3168
|
+
switch (context.source.kind) {
|
|
3169
|
+
case "slack":
|
|
3170
|
+
if (context.destination.platform !== "slack") throw new TypeError("Slack plugin context requires Slack destination");
|
|
3171
|
+
return {
|
|
3172
|
+
...common,
|
|
3173
|
+
actor: context.actor?.platform === "slack" ? context.actor : void 0,
|
|
3174
|
+
destination: context.destination,
|
|
3175
|
+
source: context.source
|
|
3176
|
+
};
|
|
3177
|
+
case "local":
|
|
3178
|
+
if (context.destination.platform !== "local") throw new TypeError("Local plugin context requires local destination");
|
|
3179
|
+
return {
|
|
3180
|
+
...common,
|
|
3181
|
+
actor: context.actor?.platform === "local" ? context.actor : void 0,
|
|
3182
|
+
destination: context.destination,
|
|
3183
|
+
source: context.source
|
|
3184
|
+
};
|
|
3185
|
+
case "web": return {
|
|
3186
|
+
...common,
|
|
3187
|
+
actor: context.actor?.platform === "web" ? context.actor : void 0,
|
|
3188
|
+
destination: context.destination,
|
|
3189
|
+
source: context.source
|
|
3190
|
+
};
|
|
3191
|
+
case "resource_event":
|
|
3192
|
+
case "scheduled_task":
|
|
3193
|
+
case "event_task":
|
|
3194
|
+
case "plugin_dispatch":
|
|
3195
|
+
case "agent_invocation": return {
|
|
3196
|
+
...common,
|
|
3197
|
+
actor: context.actor,
|
|
3198
|
+
destination: context.destination,
|
|
3199
|
+
source: context.source
|
|
3200
|
+
};
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
function invocationPluginContext(plugin, context, turnId) {
|
|
3204
|
+
return {
|
|
3205
|
+
...basePluginContext(plugin),
|
|
3206
|
+
conversationId: context.conversationId,
|
|
3207
|
+
locationId: context.locationId,
|
|
3208
|
+
embedder: createPluginEmbedder(plugin.manifest.name),
|
|
3209
|
+
...context.conversationId && turnId ? { events: createPluginConversationEvents({
|
|
3210
|
+
conversationId: context.conversationId,
|
|
3211
|
+
operationId: `user-prompt:${turnId}`,
|
|
3212
|
+
plugin,
|
|
3213
|
+
turnId
|
|
3214
|
+
}) } : void 0,
|
|
3215
|
+
model: createPluginModel(plugin.manifest.name, plugin.model),
|
|
3216
|
+
source: context.source,
|
|
3217
|
+
text: context.userText ?? "",
|
|
3218
|
+
state: createPluginState(plugin.manifest.name),
|
|
3219
|
+
users: { resolveActor: context.resolveActorIdentity ?? (async () => void 0) },
|
|
3220
|
+
...pluginInvocationContext(context)
|
|
3221
|
+
};
|
|
3222
|
+
}
|
|
3223
|
+
function pluginMcpContext(plugin, context) {
|
|
3224
|
+
const manager = context.mcpToolManager;
|
|
3225
|
+
const wrappedTools = new Set(plugin.manifest.mcp?.wrappedTools ?? []);
|
|
3226
|
+
if (!manager || wrappedTools.size === 0) return;
|
|
3227
|
+
const provider = plugin.manifest.name;
|
|
3228
|
+
const prepare = async () => {
|
|
3229
|
+
await manager.activateProvider(provider);
|
|
3230
|
+
return manager.getActiveProviders().includes(provider) ? "ready" : "authorization_pending";
|
|
3231
|
+
};
|
|
3232
|
+
return {
|
|
3233
|
+
prepare,
|
|
3234
|
+
async callTool(input) {
|
|
3235
|
+
if (await prepare() === "authorization_pending") return { status: "authorization_pending" };
|
|
3236
|
+
return await manager.callWrappedTool(provider, input.name, input.arguments ?? {}, input.toolCallId ? { toolCallId: input.toolCallId } : void 0);
|
|
3237
|
+
}
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3240
|
+
function safeErrorMessage(error) {
|
|
3241
|
+
return error instanceof Error ? error.message : String(error);
|
|
3242
|
+
}
|
|
3243
|
+
function toPromptContributionContext(args) {
|
|
3244
|
+
return {
|
|
3245
|
+
id: `${args.hookName}:${args.index}`,
|
|
3246
|
+
pluginName: args.pluginName,
|
|
3247
|
+
text: args.message.text
|
|
3248
|
+
};
|
|
3249
|
+
}
|
|
3250
|
+
function normalizePromptContext(value) {
|
|
3251
|
+
const serialized = JSON.stringify(value);
|
|
3252
|
+
if (serialized === void 0 || Buffer.byteLength(serialized, "utf8") > PLUGIN_PROMPT_CONTEXT_MAX_BYTES) throw new TypeError("Plugin prompt context exceeded its serialized budget");
|
|
3253
|
+
const parsed = JSON.parse(serialized);
|
|
3254
|
+
if (!isRecord(parsed)) throw new TypeError("Plugin prompt context must be a JSON object");
|
|
3255
|
+
return parsed;
|
|
3256
|
+
}
|
|
3257
|
+
function toUserPromptContributionContext(args) {
|
|
3258
|
+
const message = promptMessageSchema.safeParse(args.value);
|
|
3259
|
+
if (message.success) return toPromptContributionContext({
|
|
3260
|
+
hookName: "userPrompt",
|
|
3261
|
+
index: args.index,
|
|
3262
|
+
message: message.data,
|
|
3263
|
+
pluginName: args.pluginName
|
|
3264
|
+
});
|
|
3265
|
+
if (!isRecord(args.value) || typeof args.value.renderPrompt !== "function") return;
|
|
3266
|
+
const context = promptContextSchema.safeParse(args.value.context);
|
|
3267
|
+
if (!context.success) return;
|
|
3268
|
+
const text = promptMessageSchema.safeParse({ text: args.value.renderPrompt() });
|
|
3269
|
+
if (!text.success) return;
|
|
3270
|
+
return {
|
|
3271
|
+
id: `userPrompt:${args.index}`,
|
|
3272
|
+
pluginName: args.pluginName,
|
|
3273
|
+
text: text.data.text,
|
|
3274
|
+
context: {
|
|
3275
|
+
content: normalizePromptContext(context.data.content),
|
|
3276
|
+
kind: context.data.kind,
|
|
3277
|
+
loadedAtMs: Date.now(),
|
|
3278
|
+
pluginName: args.pluginName,
|
|
3279
|
+
version: context.data.version
|
|
3280
|
+
}
|
|
3281
|
+
};
|
|
3282
|
+
}
|
|
3283
|
+
function logInvalidPromptContributions(args) {
|
|
3284
|
+
logWarn("plugin.prompt.contribution_result.invalid", {
|
|
3285
|
+
"app.plugin.hook": args.hookName,
|
|
3286
|
+
"app.plugin.name": args.pluginName,
|
|
3287
|
+
"app.plugin.validation_reason": "invalid_shape"
|
|
3288
|
+
});
|
|
3289
|
+
}
|
|
3290
|
+
/** Validate plugin identity before it can affect process-wide hooks. */
|
|
3291
|
+
function validatePlugins(plugins) {
|
|
3292
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3293
|
+
for (const plugin of plugins) {
|
|
3294
|
+
const name = plugin.manifest.name;
|
|
3295
|
+
if (!PLUGIN_NAME_RE.test(name)) throw new Error(`Plugin name "${name}" must be a lowercase plugin identifier`);
|
|
3296
|
+
if (seen.has(name)) throw new Error(`Duplicate plugin name "${name}"`);
|
|
3297
|
+
if (plugin.resourceEvents !== void 0 && !pluginResourceEventsSchema.safeParse(plugin.resourceEvents).success) throw new Error(`Plugin "${name}" resourceEvents is invalid`);
|
|
3298
|
+
for (const [taskName, task] of Object.entries(plugin.tasks ?? {})) {
|
|
3299
|
+
if (!PLUGIN_TOOL_NAME_RE.test(taskName)) throw new Error(`Plugin task "${taskName}" from plugin "${name}" must be a camelCase identifier`);
|
|
3300
|
+
if (typeof task.run !== "function") throw new Error(`Plugin task "${taskName}" from plugin "${name}" must define a run function`);
|
|
3301
|
+
}
|
|
3302
|
+
seen.add(name);
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
/** Replace runtime hook plugins and return the previous list for rollback. */
|
|
3306
|
+
function setPlugins(nextPlugins) {
|
|
3307
|
+
validatePlugins(nextPlugins);
|
|
3308
|
+
const previous = registeredPlugins;
|
|
3309
|
+
registeredPlugins = [...nextPlugins].sort((left, right) => left.manifest.name.localeCompare(right.manifest.name));
|
|
3310
|
+
return previous;
|
|
3311
|
+
}
|
|
3312
|
+
/** Return the current runtime hook plugins without exposing mutable state. */
|
|
3313
|
+
function getPlugins() {
|
|
3314
|
+
return [...registeredPlugins];
|
|
3315
|
+
}
|
|
3316
|
+
/** Apply plugin Markdown rewrites before destination delivery formatting. */
|
|
3317
|
+
function applyPluginFormatMarkdown(text) {
|
|
3318
|
+
let transformed = text;
|
|
3319
|
+
for (const plugin of getPlugins()) {
|
|
3320
|
+
const hook = plugin.hooks?.formatMarkdown;
|
|
3321
|
+
if (!hook) continue;
|
|
3322
|
+
try {
|
|
3323
|
+
const next = hook({ text: transformed });
|
|
3324
|
+
if (typeof next === "string") transformed = next;
|
|
3325
|
+
} catch (error) {
|
|
3326
|
+
logWarn("plugin.format_markdown.hook.failed", {
|
|
3327
|
+
"app.plugin.name": plugin.manifest.name,
|
|
3328
|
+
"exception.message": safeErrorMessage(error)
|
|
3329
|
+
});
|
|
3330
|
+
}
|
|
3331
|
+
}
|
|
3332
|
+
return transformed;
|
|
3333
|
+
}
|
|
3334
|
+
/** Collect stable plugin prompt contributions for the static system prompt. */
|
|
3335
|
+
async function getPluginSystemPromptContributions(platform) {
|
|
3336
|
+
const contributions = [];
|
|
3337
|
+
let totalChars = 0;
|
|
3338
|
+
for (const plugin of getPlugins()) {
|
|
3339
|
+
const pluginName = plugin.manifest.name;
|
|
3340
|
+
const hook = plugin.hooks?.systemPrompt;
|
|
3341
|
+
if (!hook) continue;
|
|
3342
|
+
try {
|
|
3343
|
+
const pluginContributions = await hook({
|
|
3344
|
+
...systemPromptPluginContext(plugin),
|
|
3345
|
+
platform
|
|
3346
|
+
});
|
|
3347
|
+
const result = systemPromptMessageArraySchema.safeParse(pluginContributions);
|
|
3348
|
+
if (!result.success) {
|
|
3349
|
+
logInvalidPromptContributions({
|
|
3350
|
+
hookName: "systemPrompt",
|
|
3351
|
+
pluginName
|
|
3352
|
+
});
|
|
3353
|
+
continue;
|
|
3354
|
+
}
|
|
3355
|
+
const acceptedContributions = result.data.map((message, index) => toPromptContributionContext({
|
|
3356
|
+
hookName: "systemPrompt",
|
|
3357
|
+
index,
|
|
3358
|
+
message,
|
|
3359
|
+
pluginName
|
|
3360
|
+
}));
|
|
3361
|
+
const pluginContributionChars = acceptedContributions.reduce((sum, contribution) => sum + contribution.text.length, 0);
|
|
3362
|
+
if (totalChars + pluginContributionChars > PLUGIN_PROMPT_CONTRIBUTION_TOTAL_MAX_CHARS) {
|
|
3363
|
+
logWarn("plugin.system_prompt.contribution_budget_exceeded", { "app.plugin.name": pluginName });
|
|
3364
|
+
continue;
|
|
3365
|
+
}
|
|
3366
|
+
totalChars += pluginContributionChars;
|
|
3367
|
+
contributions.push(...acceptedContributions);
|
|
3368
|
+
} catch (error) {
|
|
3369
|
+
logWarn("plugin.system_prompt.hook.failed", {
|
|
3370
|
+
"app.plugin.name": pluginName,
|
|
3371
|
+
"exception.message": safeErrorMessage(error)
|
|
3372
|
+
});
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
return contributions;
|
|
3376
|
+
}
|
|
3377
|
+
/** Collect request-scoped plugin prompt contributions. */
|
|
3378
|
+
async function getPluginUserPromptContributions(args) {
|
|
3379
|
+
const contributions = [];
|
|
3380
|
+
let totalChars = 0;
|
|
3381
|
+
let totalContextBytes = 0;
|
|
3382
|
+
for (const plugin of getPlugins()) {
|
|
3383
|
+
const pluginName = plugin.manifest.name;
|
|
3384
|
+
const hook = plugin.hooks?.userPrompt;
|
|
3385
|
+
if (!hook) continue;
|
|
3386
|
+
try {
|
|
3387
|
+
const rawResult = await hook({ ...invocationPluginContext(plugin, args.context, args.turnId) });
|
|
3388
|
+
if (rawResult === void 0) continue;
|
|
3389
|
+
if (!Array.isArray(rawResult)) {
|
|
3390
|
+
logInvalidPromptContributions({
|
|
3391
|
+
hookName: "userPrompt",
|
|
3392
|
+
pluginName
|
|
3393
|
+
});
|
|
3394
|
+
continue;
|
|
3395
|
+
}
|
|
3396
|
+
const acceptedContributions = rawResult.map((value, index) => toUserPromptContributionContext({
|
|
3397
|
+
index,
|
|
3398
|
+
pluginName,
|
|
3399
|
+
value
|
|
3400
|
+
}));
|
|
3401
|
+
if (acceptedContributions.some((contribution) => !contribution)) {
|
|
3402
|
+
logInvalidPromptContributions({
|
|
3403
|
+
hookName: "userPrompt",
|
|
3404
|
+
pluginName
|
|
3405
|
+
});
|
|
3406
|
+
continue;
|
|
3407
|
+
}
|
|
3408
|
+
const validContributions = acceptedContributions.filter((contribution) => contribution !== void 0);
|
|
3409
|
+
const pluginContributionChars = validContributions.reduce((sum, contribution) => sum + contribution.text.length, 0);
|
|
3410
|
+
const pluginContextBytes = validContributions.reduce((sum, contribution) => sum + (contribution.context ? Buffer.byteLength(JSON.stringify(contribution.context.content), "utf8") : 0), 0);
|
|
3411
|
+
if (totalChars + pluginContributionChars > PLUGIN_PROMPT_CONTRIBUTION_TOTAL_MAX_CHARS || totalContextBytes + pluginContextBytes > PLUGIN_PROMPT_CONTEXT_TOTAL_MAX_BYTES) {
|
|
3412
|
+
logWarn("plugin.user_prompt.contribution_budget_exceeded", { "app.plugin.name": pluginName });
|
|
3413
|
+
continue;
|
|
3414
|
+
}
|
|
3415
|
+
totalChars += pluginContributionChars;
|
|
3416
|
+
totalContextBytes += pluginContextBytes;
|
|
3417
|
+
contributions.push(...validContributions);
|
|
3418
|
+
} catch (error) {
|
|
3419
|
+
logWarn("plugin.user_prompt.hook.failed", {
|
|
3420
|
+
"app.plugin.name": pluginName,
|
|
3421
|
+
"exception.message": safeErrorMessage(error)
|
|
3422
|
+
});
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
return contributions;
|
|
3426
|
+
}
|
|
3427
|
+
/** Collect turn-scoped tools exposed by plugins. */
|
|
3428
|
+
function getPluginTools(context, sandbox = createSandboxCapability(context.workspace)) {
|
|
3429
|
+
const tools = {};
|
|
3430
|
+
for (const plugin of getPlugins()) {
|
|
3431
|
+
const pluginName = plugin.manifest.name;
|
|
3432
|
+
const hook = plugin.hooks?.tools;
|
|
3433
|
+
if (!hook) continue;
|
|
3434
|
+
const slackToolContext = getSlackToolContext(context);
|
|
3435
|
+
const credentialSubject = slackToolContext ? createSlackDirectCredentialSubject({
|
|
3436
|
+
channelId: slackToolContext.locationChannelId,
|
|
3437
|
+
teamId: slackToolContext.teamId,
|
|
3438
|
+
userId: slackToolContext.actor?.userId
|
|
3439
|
+
}) : void 0;
|
|
3440
|
+
const dashboardConversationUrl = getDashboardConversationLink(context.conversationId);
|
|
3441
|
+
const slackContext = slackToolContext ? {
|
|
3442
|
+
channelCapabilities: resolveChannelCapabilities(slackToolContext.locationChannelId),
|
|
3443
|
+
...dashboardConversationUrl ? { conversationLink: { url: dashboardConversationUrl } } : void 0,
|
|
3444
|
+
...credentialSubject ? { credentialSubject } : void 0
|
|
3445
|
+
} : void 0;
|
|
3446
|
+
const annotations = createPluginAnnotations({
|
|
3447
|
+
conversationId: context.conversationId,
|
|
3448
|
+
db: getDb(),
|
|
3449
|
+
plugin: pluginName
|
|
3450
|
+
});
|
|
3451
|
+
const mcp = pluginMcpContext(plugin, context);
|
|
3452
|
+
const canSubscribe = Boolean(plugin.resourceEvents) && plugin.resourceEvents?.isEnabled?.() !== false;
|
|
3453
|
+
const resourceEvents = {
|
|
3454
|
+
canSubscribe,
|
|
3455
|
+
async subscribe(input) {
|
|
3456
|
+
if (!canSubscribe) throw new Error("Resource subscriptions are not available in this conversation.");
|
|
3457
|
+
const registration = plugin.resourceEvents;
|
|
3458
|
+
const resourceType = registration?.resourceTypes.find((candidate) => candidate.type === input.resource.type);
|
|
3459
|
+
if (input.resource.namespace !== pluginName || !resourceType || input.events.length === 0 || input.events.some((eventType) => !resourceType.supportedEvents.includes(eventType))) throw new Error("Resource subscription contains an event or resource that the plugin does not support.");
|
|
3460
|
+
const subscription = await createResourceEventSubscription({
|
|
3461
|
+
conversationId: context.conversationId,
|
|
3462
|
+
events: input.events,
|
|
3463
|
+
expiresAtMs: Date.now() + RESOURCE_SUBSCRIPTION_DEFAULT_TTL_MS,
|
|
3464
|
+
intent: input.intent,
|
|
3465
|
+
label: input.resource.label,
|
|
3466
|
+
namespace: pluginName,
|
|
3467
|
+
identifier: normalizeResourceEventIdentifier(registration, input.resource.identifier),
|
|
3468
|
+
resourceType: input.resource.type
|
|
3469
|
+
});
|
|
3470
|
+
return {
|
|
3471
|
+
events: subscription.events,
|
|
3472
|
+
id: subscription.id
|
|
3473
|
+
};
|
|
3474
|
+
}
|
|
3475
|
+
};
|
|
3476
|
+
const resolveActor = context.resolveActorIdentity ?? (async () => void 0);
|
|
3477
|
+
const common = {
|
|
3478
|
+
...basePluginContext(plugin),
|
|
3479
|
+
annotations,
|
|
3480
|
+
conversationId: context.conversationId,
|
|
3481
|
+
locationId: context.locationId,
|
|
3482
|
+
...slackContext ? { slack: slackContext } : void 0,
|
|
3483
|
+
userText: context.userText,
|
|
3484
|
+
embedder: createPluginEmbedder(pluginName),
|
|
3485
|
+
egress: context.egress,
|
|
3486
|
+
...mcp ? { mcp } : void 0,
|
|
3487
|
+
model: createPluginModel(pluginName, plugin.model),
|
|
3488
|
+
resourceEvents,
|
|
3489
|
+
sandbox,
|
|
3490
|
+
state: createPluginState(pluginName),
|
|
3491
|
+
users: { resolveActor },
|
|
3492
|
+
workspaces: { async findByRepository(input) {
|
|
3493
|
+
return await listWorkspaceNamesByRepository(getDb(), input);
|
|
3494
|
+
} }
|
|
3495
|
+
};
|
|
3496
|
+
let pluginContext;
|
|
3497
|
+
switch (context.source.kind) {
|
|
3498
|
+
case "slack":
|
|
3499
|
+
if (context.destination.platform !== "slack") throw new TypeError("Slack plugin context requires Slack destination");
|
|
3500
|
+
pluginContext = {
|
|
3501
|
+
...common,
|
|
3502
|
+
actor: context.actor?.platform === "slack" ? context.actor : void 0,
|
|
3503
|
+
destination: context.destination,
|
|
3504
|
+
source: context.source
|
|
3505
|
+
};
|
|
3506
|
+
break;
|
|
3507
|
+
case "local":
|
|
3508
|
+
if (context.destination.platform !== "local") throw new TypeError("Local plugin context requires local destination");
|
|
3509
|
+
pluginContext = {
|
|
3510
|
+
...common,
|
|
3511
|
+
actor: context.actor?.platform === "local" ? context.actor : void 0,
|
|
3512
|
+
destination: context.destination,
|
|
3513
|
+
source: context.source
|
|
3514
|
+
};
|
|
3515
|
+
break;
|
|
3516
|
+
case "web":
|
|
3517
|
+
pluginContext = {
|
|
3518
|
+
...common,
|
|
3519
|
+
actor: context.actor?.platform === "web" ? context.actor : void 0,
|
|
3520
|
+
destination: context.destination,
|
|
3521
|
+
source: context.source
|
|
3522
|
+
};
|
|
3523
|
+
break;
|
|
3524
|
+
case "resource_event":
|
|
3525
|
+
case "scheduled_task":
|
|
3526
|
+
case "event_task":
|
|
3527
|
+
case "plugin_dispatch":
|
|
3528
|
+
case "agent_invocation": pluginContext = {
|
|
3529
|
+
...common,
|
|
3530
|
+
actor: context.actor,
|
|
3531
|
+
destination: context.destination,
|
|
3532
|
+
source: context.source
|
|
3533
|
+
};
|
|
3534
|
+
}
|
|
3535
|
+
const pluginTools = hook(pluginContext);
|
|
3536
|
+
const namespace = pluginToolNamespace(pluginName);
|
|
3537
|
+
for (const [localName, tool] of Object.entries(pluginTools)) {
|
|
3538
|
+
if (!PLUGIN_TOOL_NAME_RE.test(localName)) throw new Error(`Plugin tool "${localName}" from plugin "${pluginName}" must be a camelCase identifier`);
|
|
3539
|
+
const name = `${namespace}_${localName}`;
|
|
3540
|
+
if (tools[name]) throw new Error(`Duplicate plugin tool "${name}" from plugin "${pluginName}"`);
|
|
3541
|
+
const definition = tool;
|
|
3542
|
+
const missingAnnotationKeys = missingToolAnnotationKeys(definition.annotations);
|
|
3543
|
+
if (missingAnnotationKeys.length > 0) logWarn("plugin.tool_annotations.missing", {
|
|
3544
|
+
"app.plugin.name": pluginName,
|
|
3545
|
+
"gen_ai.tool.name": localName,
|
|
3546
|
+
"app.tool.missing_annotations": missingAnnotationKeys.join(",")
|
|
3547
|
+
});
|
|
3548
|
+
definition.approvalMode ??= "auto";
|
|
3549
|
+
definition.identity = {
|
|
3550
|
+
id: `${pluginName}.${localName}`,
|
|
3551
|
+
name: localName,
|
|
3552
|
+
plugin: pluginName
|
|
3553
|
+
};
|
|
3554
|
+
definition.source = {
|
|
3555
|
+
id: pluginName,
|
|
3556
|
+
description: plugin.manifest.description
|
|
3557
|
+
};
|
|
3558
|
+
definition.exposure ??= "deferred";
|
|
3559
|
+
tools[name] = definition;
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
return tools;
|
|
3563
|
+
}
|
|
3564
|
+
/** Normalize route methods so JS plugins cannot register invalid verbs. */
|
|
3565
|
+
function routeMethods(route, pluginName) {
|
|
3566
|
+
const methods = Array.isArray(route.method) ? route.method : [route.method ?? "ALL"];
|
|
3567
|
+
if (methods.length === 0) throw new Error(`Plugin route "${route.path}" from plugin "${pluginName}" must declare at least one method`);
|
|
3568
|
+
for (const method of methods) if (!PLUGIN_ROUTE_METHODS.has(method)) throw new Error(`Plugin route "${route.path}" from plugin "${pluginName}" has invalid method "${String(method)}"`);
|
|
3569
|
+
if (methods.includes("ALL") && methods.length > 1) throw new Error(`Plugin route "${route.path}" from plugin "${pluginName}" must not combine ALL with explicit methods`);
|
|
3570
|
+
return methods;
|
|
3571
|
+
}
|
|
3572
|
+
function requirePublishedResourceEvent(plugin, eventType) {
|
|
3573
|
+
const registration = plugin.resourceEvents;
|
|
3574
|
+
if (!registration || registration.isEnabled?.() === false) throw new Error(`Plugin "${plugin.manifest.name}" cannot publish resource events without an active registration`);
|
|
3575
|
+
if (!registration.resourceTypes.some((resourceType) => resourceType.supportedEvents.includes(eventType))) throw new Error(`Plugin "${plugin.manifest.name}" did not register resource event "${eventType}"`);
|
|
3576
|
+
}
|
|
3577
|
+
/** Collect route handlers exposed by plugins for app-level mounting. */
|
|
3578
|
+
function getPluginRoutes(options) {
|
|
3579
|
+
const routes = [];
|
|
3580
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3581
|
+
const methodsByPath = /* @__PURE__ */ new Map();
|
|
3582
|
+
for (const plugin of getPlugins()) {
|
|
3583
|
+
const pluginName = plugin.manifest.name;
|
|
3584
|
+
const hook = plugin.hooks?.routes;
|
|
3585
|
+
if (!hook) continue;
|
|
3586
|
+
const pluginRoutes = hook({
|
|
3587
|
+
...basePluginContext(plugin),
|
|
3588
|
+
annotations: { forConversation: (conversationId) => createPluginAnnotations({
|
|
3589
|
+
conversationId,
|
|
3590
|
+
db: getDb(),
|
|
3591
|
+
plugin: pluginName
|
|
3592
|
+
}) },
|
|
3593
|
+
codeChanges: createCodeChangePublisher(pluginName),
|
|
3594
|
+
resourceEvents: {
|
|
3595
|
+
async hasMatch(event) {
|
|
3596
|
+
if (!options.resourceEvents.hasMatch) return false;
|
|
3597
|
+
const parsed = resourceEventInputSchema.parse(event);
|
|
3598
|
+
requirePublishedResourceEvent(plugin, parsed.eventType);
|
|
3599
|
+
return await options.resourceEvents.hasMatch({
|
|
3600
|
+
...parsed,
|
|
3601
|
+
identifier: normalizeResourceEventIdentifier(plugin.resourceEvents, parsed.identifier),
|
|
3602
|
+
namespace: pluginName
|
|
3603
|
+
});
|
|
3604
|
+
},
|
|
3605
|
+
async neededMatchKeys(input) {
|
|
3606
|
+
if (!options.resourceEvents.neededMatchKeys) return [];
|
|
3607
|
+
const identifiers = [...new Set(input.identifiers.map((identifier) => normalizeResourceEventIdentifier(plugin.resourceEvents, identifier)).filter(Boolean))];
|
|
3608
|
+
if (identifiers.length === 0 || input.eventTypes.length === 0) return [];
|
|
3609
|
+
return await options.resourceEvents.neededMatchKeys({
|
|
3610
|
+
eventTypes: input.eventTypes,
|
|
3611
|
+
identifiers,
|
|
3612
|
+
namespace: pluginName
|
|
3613
|
+
});
|
|
3614
|
+
},
|
|
3615
|
+
async publish(event) {
|
|
3616
|
+
const parsed = resourceEventInputSchema.parse(event);
|
|
3617
|
+
requirePublishedResourceEvent(plugin, parsed.eventType);
|
|
3618
|
+
await options.resourceEvents.publish({
|
|
3619
|
+
...parsed,
|
|
3620
|
+
identifier: normalizeResourceEventIdentifier(plugin.resourceEvents, parsed.identifier),
|
|
3621
|
+
namespace: pluginName
|
|
3622
|
+
});
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
});
|
|
3626
|
+
if (!Array.isArray(pluginRoutes)) throw new Error(`Plugin routes hook from plugin "${pluginName}" must return an array`);
|
|
3627
|
+
for (const route of pluginRoutes) {
|
|
3628
|
+
if (!isRecord(route)) throw new Error(`Plugin route from plugin "${pluginName}" must be an object`);
|
|
3629
|
+
if (typeof route.path !== "string" || !route.path.startsWith("/")) throw new Error(`Plugin route "${route.path}" from plugin "${pluginName}" must start with /`);
|
|
3630
|
+
if (typeof route.handler !== "function") throw new Error(`Plugin route "${route.path}" from plugin "${pluginName}" must provide a handler`);
|
|
3631
|
+
const methods = routeMethods(route, pluginName);
|
|
3632
|
+
const pathMethods = methodsByPath.get(route.path) ?? /* @__PURE__ */ new Set();
|
|
3633
|
+
if (pathMethods.has("ALL") || methods.includes("ALL") && pathMethods.size > 0) throw new Error(`Plugin route "${route.path}" conflicts with an ALL route for the same path`);
|
|
3634
|
+
for (const method of methods) {
|
|
3635
|
+
const key = `${method}:${route.path}`;
|
|
3636
|
+
if (seen.has(key)) throw new Error(`Duplicate plugin route "${method} ${route.path}"`);
|
|
3637
|
+
seen.add(key);
|
|
3638
|
+
pathMethods.add(method);
|
|
3639
|
+
}
|
|
3640
|
+
methodsByPath.set(route.path, pathMethods);
|
|
3641
|
+
routes.push({
|
|
3642
|
+
...route,
|
|
3643
|
+
pluginName
|
|
3644
|
+
});
|
|
3645
|
+
}
|
|
3646
|
+
}
|
|
3647
|
+
return routes;
|
|
3648
|
+
}
|
|
3649
|
+
/** Collect authenticated product API route apps exposed by plugins. */
|
|
3650
|
+
function getPluginApiRoutes() {
|
|
3651
|
+
const routes = [];
|
|
3652
|
+
for (const plugin of getPlugins()) {
|
|
3653
|
+
const pluginName = plugin.manifest.name;
|
|
3654
|
+
const hook = plugin.hooks?.apiRoutes;
|
|
3655
|
+
if (!hook) continue;
|
|
3656
|
+
const app = hook({
|
|
3657
|
+
...basePluginContext(plugin),
|
|
3658
|
+
eventStats: createPluginConversationEventStats(plugin),
|
|
3659
|
+
users: { resolve: resolveViewerUser }
|
|
3660
|
+
});
|
|
3661
|
+
if (app === void 0) continue;
|
|
3662
|
+
if (!isRecord(app) || typeof app.fetch !== "function") throw new Error(`Plugin apiRoutes hook from plugin "${pluginName}" must return a fetch-compatible app`);
|
|
3663
|
+
routes.push({
|
|
3664
|
+
app,
|
|
3665
|
+
pluginName
|
|
3666
|
+
});
|
|
3667
|
+
}
|
|
3668
|
+
return routes;
|
|
3669
|
+
}
|
|
3670
|
+
/** Return only absolute HTTP(S) URLs that Slack can render as footer links. */
|
|
3671
|
+
function trustedSlackConversationUrl(pluginName, link) {
|
|
3672
|
+
const url = typeof link?.url === "string" ? link.url.trim() : "";
|
|
3673
|
+
if (!url) return;
|
|
3674
|
+
let parsed;
|
|
3675
|
+
try {
|
|
3676
|
+
parsed = new URL(url);
|
|
3677
|
+
} catch (error) {
|
|
3678
|
+
throw new Error(`Plugin "${pluginName}" slackConversationLink must return an absolute http(s) URL`, { cause: error });
|
|
3679
|
+
}
|
|
3680
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Plugin "${pluginName}" slackConversationLink must return an absolute http(s) URL`);
|
|
3681
|
+
return parsed.toString();
|
|
3682
|
+
}
|
|
3683
|
+
/** Resolve the first plugin conversation URL for finalized Slack footers. */
|
|
3684
|
+
function getPluginSlackConversationLink(conversationId) {
|
|
3685
|
+
for (const plugin of getPlugins()) {
|
|
3686
|
+
const pluginName = plugin.manifest.name;
|
|
3687
|
+
const hook = plugin.hooks?.slackConversationLink;
|
|
3688
|
+
if (!hook) continue;
|
|
3689
|
+
const url = trustedSlackConversationUrl(pluginName, hook({
|
|
3690
|
+
...basePluginContext(plugin),
|
|
3691
|
+
conversationId
|
|
3692
|
+
}));
|
|
3693
|
+
if (url) return { url };
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
function pluginReadState(state) {
|
|
3697
|
+
return { get: state.get };
|
|
3698
|
+
}
|
|
3699
|
+
function operationalReportText(value, maxLength) {
|
|
3700
|
+
if (typeof value !== "string") return;
|
|
3701
|
+
const trimmed = value.trim();
|
|
3702
|
+
if (!trimmed) return;
|
|
3703
|
+
return trimmed.length <= maxLength ? trimmed : `${trimmed.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
3704
|
+
}
|
|
3705
|
+
function operationalReportTone(tone) {
|
|
3706
|
+
return tone === "danger" || tone === "good" || tone === "neutral" || tone === "warning" ? tone : void 0;
|
|
3707
|
+
}
|
|
3708
|
+
function sanitizeOperationalReport(args) {
|
|
3709
|
+
const metrics = args.report.metrics?.slice(0, OPERATIONAL_REPORT_MAX_METRICS).map((metric) => {
|
|
3710
|
+
const label = operationalReportText(metric.label, OPERATIONAL_REPORT_MAX_LABEL_LENGTH);
|
|
3711
|
+
const value = operationalReportText(metric.value, OPERATIONAL_REPORT_MAX_VALUE_LENGTH);
|
|
3712
|
+
if (!label || !value) return;
|
|
3713
|
+
const sanitizedMetric = {
|
|
3714
|
+
label,
|
|
3715
|
+
value
|
|
3716
|
+
};
|
|
3717
|
+
const tone = operationalReportTone(metric.tone);
|
|
3718
|
+
if (tone) sanitizedMetric.tone = tone;
|
|
3719
|
+
return sanitizedMetric;
|
|
3720
|
+
}).filter((metric) => Boolean(metric));
|
|
3721
|
+
const recordSets = args.report.recordSets?.slice(0, OPERATIONAL_REPORT_MAX_RECORD_SETS).map((recordSet, recordSetIndex) => {
|
|
3722
|
+
const title = operationalReportText(recordSet.title, OPERATIONAL_REPORT_MAX_LABEL_LENGTH);
|
|
3723
|
+
if (!title) return;
|
|
3724
|
+
const fields = recordSet.fields?.slice(0, OPERATIONAL_REPORT_MAX_FIELDS).map((field) => {
|
|
3725
|
+
const key = operationalReportText(field.key, OPERATIONAL_REPORT_MAX_LABEL_LENGTH);
|
|
3726
|
+
const label = operationalReportText(field.label, OPERATIONAL_REPORT_MAX_LABEL_LENGTH);
|
|
3727
|
+
return key && label ? {
|
|
3728
|
+
key,
|
|
3729
|
+
label
|
|
3730
|
+
} : void 0;
|
|
3731
|
+
}).filter((field) => Boolean(field));
|
|
3732
|
+
const records = recordSet.records?.slice(0, OPERATIONAL_REPORT_MAX_RECORDS).map((record, recordIndex) => {
|
|
3733
|
+
const sanitizedRecord = {
|
|
3734
|
+
id: operationalReportText(record.id, OPERATIONAL_REPORT_MAX_LABEL_LENGTH) ?? `${recordSetIndex}:${recordIndex}`,
|
|
3735
|
+
values: Object.fromEntries((fields ?? []).map((field) => [field.key, operationalReportText(record.values[field.key], OPERATIONAL_REPORT_MAX_VALUE_LENGTH) ?? ""]))
|
|
3736
|
+
};
|
|
3737
|
+
const tone = operationalReportTone(record.tone);
|
|
3738
|
+
if (tone) sanitizedRecord.tone = tone;
|
|
3739
|
+
return sanitizedRecord;
|
|
3740
|
+
});
|
|
3741
|
+
const sanitizedRecordSet = { title };
|
|
3742
|
+
if (fields?.length) sanitizedRecordSet.fields = fields;
|
|
3743
|
+
const emptyText = operationalReportText(recordSet.emptyText, OPERATIONAL_REPORT_MAX_VALUE_LENGTH);
|
|
3744
|
+
if (emptyText) sanitizedRecordSet.emptyText = emptyText;
|
|
3745
|
+
if (records?.length) sanitizedRecordSet.records = records;
|
|
3746
|
+
return sanitizedRecordSet;
|
|
3747
|
+
}).filter((recordSet) => Boolean(recordSet));
|
|
3748
|
+
const widgets = args.report.widgets?.slice(0, OPERATIONAL_REPORT_MAX_WIDGETS).map((widget, widgetIndex) => {
|
|
3749
|
+
const id = operationalReportText(widget.id, OPERATIONAL_REPORT_MAX_LABEL_LENGTH) ?? String(widgetIndex);
|
|
3750
|
+
const title = operationalReportText(widget.title, OPERATIONAL_REPORT_MAX_LABEL_LENGTH);
|
|
3751
|
+
if (!title) return;
|
|
3752
|
+
const seriesEntries = widget.series.slice(0, OPERATIONAL_REPORT_MAX_CHART_SERIES).map((item) => {
|
|
3753
|
+
const key = operationalReportText(item.key, OPERATIONAL_REPORT_MAX_LABEL_LENGTH);
|
|
3754
|
+
const label = operationalReportText(item.label, OPERATIONAL_REPORT_MAX_LABEL_LENGTH);
|
|
3755
|
+
if (!key || !label) return;
|
|
3756
|
+
const sanitizedSeries = {
|
|
3757
|
+
key,
|
|
3758
|
+
label
|
|
3759
|
+
};
|
|
3760
|
+
if (item.format === "usd") sanitizedSeries.format = "usd";
|
|
3761
|
+
const tone = operationalReportTone(item.tone);
|
|
3762
|
+
if (tone) sanitizedSeries.tone = tone;
|
|
3763
|
+
return {
|
|
3764
|
+
sanitizedSeries,
|
|
3765
|
+
sourceKey: item.key
|
|
3766
|
+
};
|
|
3767
|
+
}).filter((item) => Boolean(item));
|
|
3768
|
+
if (!seriesEntries.length) return;
|
|
3769
|
+
const series = seriesEntries.map((item) => item.sanitizedSeries);
|
|
3770
|
+
const sanitizedWidget = {
|
|
3771
|
+
categories: widget.categories.slice(-100).map((category, categoryIndex) => ({
|
|
3772
|
+
id: operationalReportText(category.id, OPERATIONAL_REPORT_MAX_LABEL_LENGTH) ?? `${widgetIndex}:${categoryIndex}`,
|
|
3773
|
+
label: operationalReportText(category.label, OPERATIONAL_REPORT_MAX_LABEL_LENGTH) ?? String(categoryIndex + 1),
|
|
3774
|
+
values: Object.fromEntries(seriesEntries.map(({ sanitizedSeries, sourceKey }) => [sanitizedSeries.key, Number.isFinite(category.values[sourceKey]) ? category.values[sourceKey] : 0]))
|
|
3775
|
+
})),
|
|
3776
|
+
id,
|
|
3777
|
+
series,
|
|
3778
|
+
title,
|
|
3779
|
+
type: "bar_chart"
|
|
3780
|
+
};
|
|
3781
|
+
const description = operationalReportText(widget.description, OPERATIONAL_REPORT_MAX_VALUE_LENGTH);
|
|
3782
|
+
if (description) sanitizedWidget.description = description;
|
|
3783
|
+
const emptyText = operationalReportText(widget.emptyText, OPERATIONAL_REPORT_MAX_VALUE_LENGTH);
|
|
3784
|
+
if (emptyText) sanitizedWidget.emptyText = emptyText;
|
|
3785
|
+
const timeRangeDays = widget.timeRangeDays?.filter((days) => days === 1 || days === 7 || days === 30 || days === 90);
|
|
3786
|
+
if (timeRangeDays?.length) sanitizedWidget.timeRangeDays = [...new Set(timeRangeDays)];
|
|
3787
|
+
return sanitizedWidget;
|
|
3788
|
+
}).filter((widget) => Boolean(widget));
|
|
3789
|
+
const sanitized = { pluginName: args.pluginName };
|
|
3790
|
+
const generatedAt = operationalReportText(args.report.generatedAt, OPERATIONAL_REPORT_MAX_VALUE_LENGTH);
|
|
3791
|
+
if (generatedAt) sanitized.generatedAt = generatedAt;
|
|
3792
|
+
if (recordSets?.length) sanitized.recordSets = recordSets;
|
|
3793
|
+
if (metrics?.length) sanitized.metrics = metrics;
|
|
3794
|
+
if (widgets?.length) sanitized.widgets = widgets;
|
|
3795
|
+
const title = operationalReportText(args.report.title, OPERATIONAL_REPORT_MAX_LABEL_LENGTH);
|
|
3796
|
+
if (title) sanitized.title = title;
|
|
3797
|
+
return sanitized;
|
|
3798
|
+
}
|
|
3799
|
+
function failedOperationalReport(args) {
|
|
3800
|
+
return {
|
|
3801
|
+
generatedAt: new Date(args.nowMs).toISOString(),
|
|
3802
|
+
pluginName: args.pluginName,
|
|
3803
|
+
metrics: [{
|
|
3804
|
+
label: "report",
|
|
3805
|
+
tone: "danger",
|
|
3806
|
+
value: "failed"
|
|
3807
|
+
}],
|
|
3808
|
+
title: args.pluginName,
|
|
3809
|
+
recordSets: [{
|
|
3810
|
+
emptyText: "This plugin report failed to load.",
|
|
3811
|
+
title: "Error"
|
|
3812
|
+
}]
|
|
3813
|
+
};
|
|
3814
|
+
}
|
|
3815
|
+
/** Collect read-only operational summaries exposed by plugins. */
|
|
3816
|
+
async function getPluginOperationalReports(nowMs) {
|
|
3817
|
+
const reports = [];
|
|
3818
|
+
for (const plugin of getPlugins()) {
|
|
3819
|
+
const pluginName = plugin.manifest.name;
|
|
3820
|
+
const hook = plugin.hooks?.operationalReport;
|
|
3821
|
+
if (!hook) continue;
|
|
3822
|
+
try {
|
|
3823
|
+
const state = createPluginState(pluginName);
|
|
3824
|
+
const report = await hook({
|
|
3825
|
+
...basePluginContext(plugin),
|
|
3826
|
+
eventStats: createPluginConversationEventStats(plugin, () => nowMs),
|
|
3827
|
+
nowMs,
|
|
3828
|
+
state: pluginReadState(state)
|
|
3829
|
+
});
|
|
3830
|
+
if (!report) continue;
|
|
3831
|
+
reports.push(sanitizeOperationalReport({
|
|
3832
|
+
pluginName,
|
|
3833
|
+
report
|
|
3834
|
+
}));
|
|
3835
|
+
} catch (error) {
|
|
3836
|
+
createPluginLogger(pluginName).error("Plugin operational report failed", { error: error instanceof Error ? error.message : String(error) });
|
|
3837
|
+
reports.push(failedOperationalReport({
|
|
3838
|
+
nowMs,
|
|
3839
|
+
pluginName
|
|
3840
|
+
}));
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
return reports;
|
|
3844
|
+
}
|
|
3845
|
+
/** Collect person-scoped plugin reports for one profile subject. */
|
|
3846
|
+
async function getPluginProfileReports(args) {
|
|
3847
|
+
const reports = [];
|
|
3848
|
+
for (const plugin of getPlugins()) {
|
|
3849
|
+
const pluginName = plugin.manifest.name;
|
|
3850
|
+
const hook = plugin.hooks?.profileReport;
|
|
3851
|
+
if (!hook) continue;
|
|
3852
|
+
try {
|
|
3853
|
+
const state = createPluginState(pluginName);
|
|
3854
|
+
const report = await hook({
|
|
3855
|
+
...basePluginContext(plugin),
|
|
3856
|
+
nowMs: args.nowMs,
|
|
3857
|
+
state: pluginReadState(state),
|
|
3858
|
+
subject: args.subject,
|
|
3859
|
+
viewer: args.viewer
|
|
3860
|
+
});
|
|
3861
|
+
if (!report) continue;
|
|
3862
|
+
reports.push(sanitizeOperationalReport({
|
|
3863
|
+
pluginName,
|
|
3864
|
+
report
|
|
3865
|
+
}));
|
|
3866
|
+
} catch (error) {
|
|
3867
|
+
createPluginLogger(pluginName).error("Plugin profile report failed", { error: error instanceof Error ? error.message : String(error) });
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
return reports;
|
|
3871
|
+
}
|
|
3872
|
+
function normalizeEnv(value) {
|
|
3873
|
+
if (!isRecord(value)) return {};
|
|
3874
|
+
const env = {};
|
|
3875
|
+
for (const [key, rawValue] of Object.entries(value)) if (typeof rawValue === "string") env[key] = rawValue;
|
|
3876
|
+
return env;
|
|
3877
|
+
}
|
|
3878
|
+
function preparationSignal(inputSignal, ownerSignal) {
|
|
3879
|
+
if (!inputSignal) return ownerSignal;
|
|
3880
|
+
if (!ownerSignal) return inputSignal;
|
|
3881
|
+
return AbortSignal.any([inputSignal, ownerSignal]);
|
|
3882
|
+
}
|
|
3883
|
+
function createSandboxCapability(workspace, ownerSignal) {
|
|
3884
|
+
return {
|
|
3885
|
+
root: SANDBOX_WORKSPACE_ROOT,
|
|
3886
|
+
juniorRoot: `${SANDBOX_WORKSPACE_ROOT}/.junior`,
|
|
3887
|
+
async readFile(filePath) {
|
|
3888
|
+
return await workspace.readFileToBuffer({ path: filePath }) ?? null;
|
|
3889
|
+
},
|
|
3890
|
+
async run(input) {
|
|
3891
|
+
const signal = preparationSignal(input.signal, ownerSignal);
|
|
3892
|
+
const result = await runNonInteractiveCommand(workspace, {
|
|
3893
|
+
...input,
|
|
3894
|
+
...signal ? { signal } : void 0
|
|
3895
|
+
});
|
|
3896
|
+
return {
|
|
3897
|
+
exitCode: result.exitCode,
|
|
3898
|
+
stdout: result.stdout,
|
|
3899
|
+
stderr: result.stderr
|
|
3900
|
+
};
|
|
3901
|
+
},
|
|
3902
|
+
async writeFile(input) {
|
|
3903
|
+
await workspace.writeFiles([{
|
|
3904
|
+
path: input.path,
|
|
3905
|
+
content: input.content,
|
|
3906
|
+
...input.mode !== void 0 ? { mode: input.mode } : void 0
|
|
3907
|
+
}]);
|
|
3908
|
+
}
|
|
3909
|
+
};
|
|
3910
|
+
}
|
|
3911
|
+
/** Create one runner over runtime hook plugins registered by the app. */
|
|
3912
|
+
function createPluginHookRunner(input = {}) {
|
|
3913
|
+
const loaded = getPlugins();
|
|
3914
|
+
return {
|
|
3915
|
+
async afterMcpTool(tool) {
|
|
3916
|
+
for (const plugin of loaded) {
|
|
3917
|
+
if (plugin.manifest.name !== tool.provider) continue;
|
|
3918
|
+
const hook = plugin.hooks?.afterMcpTool;
|
|
3919
|
+
if (!hook) continue;
|
|
3920
|
+
const annotations = tool.conversationId ? createPluginAnnotations({
|
|
3921
|
+
conversationId: tool.conversationId,
|
|
3922
|
+
db: getDb(),
|
|
3923
|
+
plugin: plugin.manifest.name
|
|
3924
|
+
}) : void 0;
|
|
3925
|
+
try {
|
|
3926
|
+
await hook({
|
|
3927
|
+
...basePluginContext(plugin),
|
|
3928
|
+
...tool.conversationId ? { conversationId: tool.conversationId } : void 0,
|
|
3929
|
+
...annotations ? { annotations } : void 0,
|
|
3930
|
+
result: tool.structuredContent !== void 0 ? { structuredContent: tool.structuredContent } : {},
|
|
3931
|
+
tool: {
|
|
3932
|
+
arguments: tool.arguments,
|
|
3933
|
+
name: tool.toolName
|
|
3934
|
+
}
|
|
3935
|
+
});
|
|
3936
|
+
} catch (error) {
|
|
3937
|
+
logWarn("agent.plugin.after_mcp_tool.failed", {
|
|
3938
|
+
"app.plugin.name": plugin.manifest.name,
|
|
3939
|
+
"gen_ai.tool.name": tool.toolName,
|
|
3940
|
+
"exception.message": error instanceof Error ? error.message : String(error)
|
|
3941
|
+
});
|
|
3942
|
+
}
|
|
3943
|
+
}
|
|
3944
|
+
},
|
|
3945
|
+
async prepareWorkspace(sandbox, repos, signal) {
|
|
3946
|
+
const preparers = new Set(loaded.filter((plugin) => plugin.hooks?.workspacePrepare).map((plugin) => plugin.manifest.name));
|
|
3947
|
+
const unhandledProviders = [...new Set(repos.map((repo) => repo.provider).filter((provider) => !preparers.has(provider)))].sort();
|
|
3948
|
+
if (unhandledProviders.length > 0) throw new Error(`Workspace repository providers have no preparation hook: ${unhandledProviders.join(", ")}`);
|
|
3949
|
+
const selectedRepos = repos.map((repo) => ({
|
|
3950
|
+
provider: repo.provider,
|
|
3951
|
+
repo: repo.repo,
|
|
3952
|
+
path: workspaceRepoCheckoutPath(repo.repo)
|
|
3953
|
+
}));
|
|
3954
|
+
const paths = /* @__PURE__ */ new Set();
|
|
3955
|
+
for (const entry of selectedRepos) {
|
|
3956
|
+
const key = entry.path.toLowerCase();
|
|
3957
|
+
if (paths.has(key)) throw new Error(`Workspace checkout path collision: ${entry.path}`);
|
|
3958
|
+
paths.add(key);
|
|
3959
|
+
}
|
|
3960
|
+
const sandboxCapability = createSandboxCapability(sandbox, signal);
|
|
3961
|
+
for (const plugin of loaded) {
|
|
3962
|
+
const hook = plugin.hooks?.workspacePrepare;
|
|
3963
|
+
if (!hook) continue;
|
|
3964
|
+
const selected = selectedRepos.filter((repo) => repo.provider === plugin.manifest.name).map((repo) => ({
|
|
3965
|
+
path: repo.path,
|
|
3966
|
+
repo: repo.repo
|
|
3967
|
+
}));
|
|
3968
|
+
if (selected.length === 0) continue;
|
|
3969
|
+
await hook({
|
|
3970
|
+
...basePluginContext(plugin),
|
|
3971
|
+
repos: selected,
|
|
3972
|
+
sandbox: sandboxCapability
|
|
3973
|
+
});
|
|
3974
|
+
}
|
|
3975
|
+
},
|
|
3976
|
+
async prepareSandbox(sandbox) {
|
|
3977
|
+
const sandboxCapability = createSandboxCapability(sandbox);
|
|
3978
|
+
for (const plugin of loaded) {
|
|
3979
|
+
const pluginName = plugin.manifest.name;
|
|
3980
|
+
const hook = plugin.hooks?.sandboxPrepare;
|
|
3981
|
+
if (!hook) continue;
|
|
3982
|
+
logInfo("agent.plugin.sandbox_preparation.started", { "app.plugin.name": pluginName });
|
|
3983
|
+
await hook({
|
|
3984
|
+
...basePluginContext(plugin),
|
|
3985
|
+
actor: input.actor,
|
|
3986
|
+
sandbox: sandboxCapability
|
|
3987
|
+
});
|
|
3988
|
+
}
|
|
3989
|
+
},
|
|
3990
|
+
async beforeToolExecute(tool) {
|
|
3991
|
+
const env = {};
|
|
3992
|
+
let nextInput = { ...tool.input };
|
|
3993
|
+
const actors = input.actors?.() ?? (input.actor ? [input.actor] : []);
|
|
3994
|
+
for (const plugin of loaded) {
|
|
3995
|
+
const pluginName = plugin.manifest.name;
|
|
3996
|
+
const hook = plugin.hooks?.beforeToolExecute;
|
|
3997
|
+
if (!hook) continue;
|
|
3998
|
+
let replacement;
|
|
3999
|
+
let denied;
|
|
4000
|
+
await hook({
|
|
4001
|
+
...basePluginContext(plugin),
|
|
4002
|
+
actor: input.actor,
|
|
4003
|
+
actors,
|
|
4004
|
+
tool: {
|
|
4005
|
+
name: tool.name,
|
|
4006
|
+
input: nextInput
|
|
4007
|
+
},
|
|
4008
|
+
users: { resolveActor: async () => input.actor ? await readActorIdentity(input.actor) : void 0 },
|
|
4009
|
+
env: {
|
|
4010
|
+
get(key) {
|
|
4011
|
+
return env[key] ?? normalizeEnv(nextInput.env)[key];
|
|
4012
|
+
},
|
|
4013
|
+
set(key, value) {
|
|
4014
|
+
env[key] = value;
|
|
4015
|
+
}
|
|
4016
|
+
},
|
|
4017
|
+
decision: {
|
|
4018
|
+
deny(message) {
|
|
4019
|
+
denied = message;
|
|
4020
|
+
},
|
|
4021
|
+
replaceInput(input) {
|
|
4022
|
+
replacement = input;
|
|
4023
|
+
}
|
|
4024
|
+
}
|
|
4025
|
+
});
|
|
4026
|
+
if (denied) throw new PluginHookDeniedError(denied);
|
|
4027
|
+
if (replacement !== void 0) {
|
|
4028
|
+
if (!isRecord(replacement)) throw new Error(`Plugin "${pluginName}" replaced tool input with a non-object value`);
|
|
4029
|
+
nextInput = { ...replacement };
|
|
4030
|
+
}
|
|
4031
|
+
}
|
|
4032
|
+
return {
|
|
4033
|
+
input: nextInput,
|
|
4034
|
+
env
|
|
4035
|
+
};
|
|
4036
|
+
}
|
|
4037
|
+
};
|
|
4038
|
+
}
|
|
4039
|
+
//#endregion
|
|
4040
|
+
export { RESOURCE_SUBSCRIPTION_DEFAULT_TTL_MS as $, bindEventTaskCredentialSubject as A, sanitizePostgresJson as At, getSlackClient as B, parseDestination as Bt, setWorkspaceSnapshotBuild as C, getConversationEventStore as Ct, tryWorkspaceRepoCheckoutPath as D, getSqlExecutor as Dt, getWorkspaceSnapshotBuilder as E, getDb as Et, verifySlackDirectCredentialSubject as F, resolveRootConversationId as Ft, withSlackRetries as G, isDmChannel as H, sameDestination as Ht, SlackActionError as I, deleteProviderIdentityForSlackUser as It, findUserByEmail as J, getWorkspaceTeamId as K, downloadPrivateSlackFile as L, upsertIdentity as Lt, bindSlackDirectCredentialSubject as M, locationFromRow as Mt, verifyEventTaskCredentialSubject as N, bindProviderConversation as Nt, workspaceRepoCheckoutPath as O, createSqlConversationEventStore as Ot, verifyScheduledTaskCredentialSubject as P, parseSessionSource as Pt, getSlackToolContext as Q, getFilePermalink as R, upsertLinkedIdentity as Rt, setWorkspaceSnapshot as S, db_exports as St, deleteWorkspaceSnapshotBuilders as T, getConversationStore as Tt, normalizeSlackConversationId as U, isConversationScopedChannel as V, requireSlackDestination as Vt, runWithSlackInstallationToken as W, resolveViewerUser as X, readActorIdentity as Y, updateViewerDisplayName as Z, listWorkspaces as _, createPluginLogger as _t, getPluginRoutes as a, collectResourceEventMatchKeys as at, invalidateReadySnapshot as b, listConversationAnnotations as bt, getPluginTools as c, findMatchingResourceEventSubscriptions as ct, setPlugins as d, getDashboardConversationLink as dt, RESOURCE_SUBSCRIPTION_MAX_TTL_MS as et, validatePlugins as f, getDashboardTaskLink as ft, getWorkspaceByName as g, createPluginModel as gt, getWorkspace as h, createPluginEmbedder as ht, getPluginApiRoutes as i, cancelSubscriptions as it, bindScheduledTaskCredentialSubject as j, withConversationMutationLock as jt, resolveChannelCapabilities as k, withConversationEventLock as kt, getPluginUserPromptContributions as l, listResourceEventSubscriptions as lt, deleteWorkspace as m, createPluginState as mt, applyPluginFormatMarkdown as n, STOP_WATCHING_TOOL_NAME as nt, getPluginSlackConversationLink as o, createResourceEventSubscription as ot, createWorkspace as p, setDashboardConversationLinkOptions as pt, runWithWorkspaceTeamId as q, createPluginHookRunner as r, cancelResourceEventSubscription as rt, getPluginSystemPromptContributions as s, deliverResourceEventSubscription as st, agent_hooks_exports as t, RESOURCE_WATCH_TOOL_SOURCE as tt, getPlugins as u, JUNIOR_THREAD_STATE_TTL_MS as ut, updateWorkspace as v, createPluginConversationEvents as vt, SANDBOX_RUNTIME as w, getConversationMessageSearchStore as wt, loadSnapshotsForProfile as x, listConversationAnnotationsById as xt, WorkspaceValidationError as y, renderPluginConversationEvent as yt, getHeaderString as z, createSlackDestination as zt };
|