@sentry/junior 0.267.0 → 0.269.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.
Files changed (95) hide show
  1. package/README.md +2 -9
  2. package/dist/{actor-DZSokpwj.js → actor-CP9K2Idu.js} +2 -1
  3. package/dist/{adapter-BybMEezj.js → adapter-2-CG25be.js} +3 -51
  4. package/dist/{admin-DOs1_EiW.js → admin-DSs8S5Uy.js} +1 -1
  5. package/dist/{agent-invocations-CxKldAeB.js → agent-invocations-C1RNOCs8.js} +2 -0
  6. package/dist/{agent-runner-DgNATN4D.js → agent-runner-CrDrPUtY.js} +3411 -1640
  7. package/dist/api/schema.d.ts +2 -5630
  8. package/dist/api/schema.js +7 -7
  9. package/dist/api.d.ts +3 -1
  10. package/dist/api.js +268 -287
  11. package/dist/{app-DAB3fzyp.d.ts → app-BrMMzjYm.d.ts} +10 -3
  12. package/dist/app.d.ts +1 -1
  13. package/dist/app.js +371 -223
  14. package/dist/{automation-CmeFBSEh.js → automation-p87hg_AV.js} +74 -74
  15. package/dist/{briefs-CWKjka7v.js → briefs-PtODdLNu.js} +4 -4
  16. package/dist/{catalog-runtime-cMlAI5LY.js → catalog-runtime-BsoSmTM0.js} +1 -1
  17. package/dist/cli/chat.js +16 -26
  18. package/dist/cli/check.js +3 -3
  19. package/dist/cli/main.js +5 -3
  20. package/dist/cli/plugins.js +4 -4
  21. package/dist/cli/snapshot-create.js +4 -4
  22. package/dist/cli/upgrade.d.ts +1 -1
  23. package/dist/cli/upgrade.js +3 -3
  24. package/dist/{client-BjgbMawj.js → client-Cqf4TboL.js} +20 -15
  25. package/dist/{config-CdB6yJj5.js → config-CPsE4T5L.js} +2 -2
  26. package/dist/{connections-RE_65jzK.js → connections-upFXLmhG.js} +10 -11
  27. package/dist/{conversation-DSo310Ps.js → conversation-CTpD_PGk.js} +7 -84
  28. package/dist/{conversation-privacy-BzgJC5k0.js → conversation-privacy-D6zJc8ff.js} +1 -1
  29. package/dist/{credential-sync-DhEGEpgq.js → credential-sync-CS_6IC-f.js} +30 -34
  30. package/dist/{credentialed-CIGwoLU2.js → credentialed-BU-OW7HP.js} +577 -74
  31. package/dist/{store-B8CABbmR.d.ts → db-BzOCzVb1.d.ts} +17 -7
  32. package/dist/{executor-VYrx_K4w.js → executor-CNS7ONt-.js} +78 -4
  33. package/dist/instrumentation.js +2 -2
  34. package/dist/{assistant-reply-CT7VcXQe.js → interruption-marker-g6Xup3Fk.js} +8 -2
  35. package/dist/{job-queue-C0BZSa7r.js → job-queue-DI-k-Bbk.js} +2 -2
  36. package/dist/{list-1XQP-o6K.js → list-CGoQdXTp.js} +429 -1401
  37. package/dist/locks-D-0h111u.js +48 -0
  38. package/dist/{logging-DuZ1A4ab.js → logging-n7knse-O.js} +1 -1
  39. package/dist/{history-C84mlAwO.js → media-85PDJxO_.js} +83 -1
  40. package/dist/{message-NKSbbnA6.js → message-BpaWd8TT.js} +4 -4
  41. package/dist/nitro.d.ts +1 -1
  42. package/dist/nitro.js +3 -3
  43. package/dist/{oauth-callback-server-qAHLPtml.js → oauth-callback-server-CpGmEw_i.js} +2 -2
  44. package/dist/{prompt-BW9jFsqZ.js → prompt-BlQB0QQt.js} +1 -1
  45. package/dist/{registry-FQutO926.js → registry-By36_xsm.js} +2 -2
  46. package/dist/reporting-schema-CZREhYc-.js +104 -0
  47. package/dist/reporting-window-CVarfId9.js +255 -0
  48. package/dist/{resolve-Bk4yeuHy.js → resolve-CUIBcdPd.js} +3 -3
  49. package/dist/{result-B9e9ATOU.js → result-BemsOMOO.js} +31 -12
  50. package/dist/{runner-C3S2ObH-.js → runner-BVok7PAA.js} +12 -11
  51. package/dist/{sandbox-egress-signals-DqpGzzMB.js → sandbox-egress-signals-BSUWQI95.js} +1 -1
  52. package/dist/{schema-X7Fj4fv1.js → schema-ChnFo5f2.js} +98 -6
  53. package/dist/schema-jK4-1u_H.d.ts +5927 -0
  54. package/dist/{service-account-Do30HUNv.js → service-account-DHvlLnZw.js} +4 -3
  55. package/dist/{service-account-BQasl-qq.js → service-account-hMmESDcr.js} +3 -3
  56. package/dist/{session-stop-DkOjt46_.js → session-stop-BQW_iFcd.js} +499 -23
  57. package/dist/{skills-CQHrwfOa.js → skills-DmJJIuLF.js} +2 -2
  58. package/dist/{spawn-BHuqN14f.js → spawn-ChCm46i-.js} +2 -2
  59. package/dist/{store-6EetVP-m.js → store-1DVhoKOL.js} +4 -4
  60. package/dist/{store-DvNZIsCr.js → store-DCo2n5iJ.js} +3 -3
  61. package/dist/{task-queue-B0DLgd2P.js → task-queue-HMC4QjFm.js} +3 -3
  62. package/dist/task-runner-cA6yjn_E.js +269 -0
  63. package/dist/{thread-state-DRmdJ2QN.js → thread-state-CHy8LS0C.js} +8 -18
  64. package/dist/{turn-failure-response-G11cs2gE.js → turn-failure-response-Dr7erLWs.js} +11 -16
  65. package/dist/{turn-lifecycle-OzLIwvi1.js → turn-lifecycle-bf2fIYi_.js} +1 -1
  66. package/dist/{tasks-0ilmuCux.js → turn-session-routing-BqO0qXLk.js} +959 -44
  67. package/dist/{validation-CxEmt1id.js → validation-DHR1ivfh.js} +1 -1
  68. package/dist/{version-Bapedn4A.js → version-CXCHLgWm.js} +1 -1
  69. package/dist/version.js +1 -1
  70. package/dist/{viewer-DrN3oC2X.js → viewer-DSbD6DF6.js} +503 -241
  71. package/dist/{work-DUQoNcqP.js → work-BE_qTYGi.js} +17 -36
  72. package/dist/{worker-D1l4Ugk0.js → worker-Cm9_sWXg.js} +15 -7
  73. package/migrations/0065_user_customization.sql +1 -0
  74. package/migrations/0066_review_items.sql +53 -0
  75. package/migrations/0067_review_item_dedupe_tenant.sql +2 -0
  76. package/migrations/0068_review_item_slack_cards.sql +10 -0
  77. package/migrations/0069_system_identity.sql +25 -0
  78. package/migrations/0070_review_item_foreign_keys.sql +5 -0
  79. package/migrations/AGENTS.md +17 -0
  80. package/migrations/meta/0065_snapshot.json +3511 -0
  81. package/migrations/meta/0066_snapshot.json +4099 -0
  82. package/migrations/meta/0067_snapshot.json +3921 -0
  83. package/migrations/meta/0068_snapshot.json +4176 -0
  84. package/migrations/meta/0069_snapshot.json +3979 -0
  85. package/migrations/meta/0070_snapshot.json +3997 -0
  86. package/migrations/meta/_journal.json +42 -0
  87. package/package.json +5 -5
  88. package/dist/db-Bg-zC3r5.d.ts +0 -5
  89. package/dist/history-BgI33Kie.d.ts +0 -78
  90. package/dist/images-CecjXAAz.js +0 -64
  91. package/dist/task-runner-B-PD_JOj.js +0 -1074
  92. package/dist/turn-session-routing-Dyut9l7Y.js +0 -76
  93. package/dist/user-input-request-BDFBdF8r.js +0 -116
  94. package/dist/web-message-id-CJSUiksE.js +0 -236
  95. package/migrations/README.md +0 -30
@@ -1,12 +1,13 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-nwUcYXjP.js";
2
- import { i as getChatConfig } from "./config-CdB6yJj5.js";
3
- import { L as GOOGLE_IDENTITY_PROVIDER, z as normalizeIdentityEmail } from "./logging-DuZ1A4ab.js";
4
- import { c as toStoredSlackActor, d as parseSlackTeamId, o as parseActorUserId, u as parseSlackChannelReferenceId } from "./actor-DZSokpwj.js";
5
- import { c as juniorIdentities, d as juniorDestinations, l as juniorUsers, o as conversationReadColumns, s as juniorConversations } from "./agent-invocations-CxKldAeB.js";
6
- import { _ as juniorConversationAnnotations, d as juniorConversationBindings, h as juniorConversationEvents, m as juniorConversationMetrics, p as CONVERSATION_METRICS, t as createJuniorSqlExecutor, u as juniorConversationParticipants } from "./executor-VYrx_K4w.js";
7
- import { c as newConversationEventSchema, h as usageSchema, l as encodeHistoryPayload, o as decodeStoredConversationEvent, r as conversationEventDataSchema, s as historyReplacementSchema } from "./history-C84mlAwO.js";
8
- import { randomUUID } from "node:crypto";
9
- import { and, asc, desc, eq, gt, gte, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
2
+ import { i as getChatConfig } from "./config-CPsE4T5L.js";
3
+ import { L as GOOGLE_IDENTITY_PROVIDER, h as logWarn, z as normalizeIdentityEmail } from "./logging-n7knse-O.js";
4
+ import { a as isSlackUserId, d as parseSlackChannelReferenceId, f as parseSlackTeamId, s as parseActorUserId } from "./actor-CP9K2Idu.js";
5
+ import { c as juniorIdentities, d as juniorDestinations, l as juniorUsers, o as conversationReadColumns, s as juniorConversations } from "./agent-invocations-C1RNOCs8.js";
6
+ import { _ as CONVERSATION_METRICS, h as juniorConversationBindings, m as juniorConversationParticipants, t as createJuniorSqlExecutor, v as juniorConversationMetrics, x as juniorConversationAnnotations, y as juniorConversationEvents } from "./executor-CNS7ONt-.js";
7
+ import { d as newConversationEventSchema, f as encodeHistoryPayload, l as decodeStoredConversationEvent, n as detectImageMediaType, o as conversationEventDataSchema, u as historyReplacementSchema, v as usageSchema } from "./media-85PDJxO_.js";
8
+ import { a as withLock } from "./locks-D-0h111u.js";
9
+ import { createHash, randomUUID } from "node:crypto";
10
+ import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
10
11
  import { destinationSchema, identitySchema, locationSchema, sourceSchema, userSchema } from "@sentry/junior-plugin-api";
11
12
  // src/chat/destination.ts
12
13
  /** Build Junior's canonical destination from Slack workspace and channel ids. */
@@ -37,161 +38,6 @@ function sameDestination(left, right) {
37
38
  if (left.platform === "slack" && right.platform === "slack") return left.teamId === right.teamId && left.channelId === right.channelId;
38
39
  return false;
39
40
  }
40
- // src/chat/identities/sql.ts
41
- function dateFromMs$1(ms) {
42
- return new Date(ms);
43
- }
44
- function tenantId$1(value) {
45
- return value ?? "";
46
- }
47
- async function upsertUser(executor, args) {
48
- const id = (await executor.db().insert(juniorUsers).values({
49
- id: randomUUID(),
50
- primaryEmail: args.email,
51
- primaryEmailNormalized: args.emailNormalized,
52
- displayName: args.displayName ?? null,
53
- createdAt: dateFromMs$1(args.nowMs),
54
- updatedAt: dateFromMs$1(args.nowMs)
55
- }).onConflictDoUpdate({
56
- target: juniorUsers.primaryEmailNormalized,
57
- set: {
58
- displayName: sql`coalesce(${juniorUsers.displayName}, excluded.display_name)`,
59
- updatedAt: sql`excluded.updated_at`
60
- }
61
- }).returning({ id: juniorUsers.id }))[0]?.id;
62
- if (!id) throw new Error("User identity upsert returned no row");
63
- return id;
64
- }
65
- async function existingIdentity(executor, identity) {
66
- 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];
67
- }
68
- async function upsertIdentityRecord(executor, identity, linkedUserId, nowMs) {
69
- const emailNormalized = normalizeIdentityEmail(identity.email);
70
- const email = emailNormalized ? identity.email?.trim() || emailNormalized : void 0;
71
- const existing = await existingIdentity(executor, identity);
72
- const userEmailNormalized = existing?.emailVerified && existing.emailNormalized ? existing.emailNormalized : identity.emailVerified ? emailNormalized : void 0;
73
- const userEmail = existing?.emailVerified && existing.email ? existing.email : email ?? userEmailNormalized;
74
- const verifiedUserId = identity.kind === "user" && userEmailNormalized ? await upsertUser(executor, {
75
- email: userEmail ?? userEmailNormalized,
76
- emailNormalized: userEmailNormalized,
77
- nowMs,
78
- ...existing?.displayName || identity.displayName ? { displayName: existing?.displayName ?? identity.displayName } : void 0
79
- }) : void 0;
80
- if (existing?.userId && verifiedUserId && existing.userId !== verifiedUserId) throw new Error("Identity verified email conflicts with linked user");
81
- if (linkedUserId && verifiedUserId && linkedUserId !== verifiedUserId) throw new Error("Linked identity conflicts with verified email user");
82
- if (existing?.userId && linkedUserId && existing.userId !== linkedUserId) throw new Error("Identity conflicts with linked user");
83
- const userId = existing?.userId ?? linkedUserId ?? verifiedUserId;
84
- const row = (await executor.db().insert(juniorIdentities).values({
85
- id: randomUUID(),
86
- userId: userId ?? null,
87
- kind: identity.kind,
88
- provider: identity.provider,
89
- providerTenantId: tenantId$1(identity.providerTenantId),
90
- providerSubjectId: identity.providerSubjectId,
91
- displayName: identity.displayName ?? null,
92
- handle: identity.handle ?? null,
93
- email: email ?? null,
94
- emailNormalized: emailNormalized ?? null,
95
- emailVerified: Boolean(identity.emailVerified && emailNormalized),
96
- avatarUrl: null,
97
- metadata: identity.metadata ?? null,
98
- createdAt: dateFromMs$1(nowMs),
99
- updatedAt: dateFromMs$1(nowMs)
100
- }).onConflictDoUpdate({
101
- target: [
102
- juniorIdentities.provider,
103
- juniorIdentities.providerTenantId,
104
- juniorIdentities.providerSubjectId
105
- ],
106
- set: {
107
- kind: sql`excluded.kind`,
108
- userId: sql`coalesce(${juniorIdentities.userId}, excluded.user_id)`,
109
- displayName: linkedUserId ? sql`coalesce(excluded.display_name, ${juniorIdentities.displayName})` : sql`coalesce(${juniorIdentities.displayName}, excluded.display_name)`,
110
- handle: linkedUserId ? sql`coalesce(excluded.handle, ${juniorIdentities.handle})` : sql`coalesce(${juniorIdentities.handle}, excluded.handle)`,
111
- 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`,
112
- 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`,
113
- emailVerified: sql`${juniorIdentities.emailVerified} OR excluded.email_verified`,
114
- avatarUrl: sql`coalesce(${juniorIdentities.avatarUrl}, excluded.avatar_url)`,
115
- metadata: sql`coalesce(${juniorIdentities.metadata}, excluded.metadata_json)`,
116
- updatedAt: sql`excluded.updated_at`
117
- },
118
- ...linkedUserId ? { setWhere: sql`${juniorIdentities.userId} IS NULL OR ${juniorIdentities.userId} = excluded.user_id` } : void 0
119
- }).returning({
120
- id: juniorIdentities.id,
121
- userId: juniorIdentities.userId
122
- }))[0];
123
- if (!row) {
124
- if (linkedUserId) throw new Error("Identity conflicts with linked user");
125
- throw new Error("Identity upsert returned no row");
126
- }
127
- return {
128
- id: row.id,
129
- ...row.userId ? { userId: row.userId } : void 0
130
- };
131
- }
132
- /** Read the saved timezone for one user. */
133
- async function readUserTimezone(db, userId) {
134
- return (await db.select({ timezone: juniorUsers.timezone }).from(juniorUsers).where(eq(juniorUsers.id, userId)).limit(1))[0]?.timezone ?? void 0;
135
- }
136
- /** Save the IANA timezone for one user. */
137
- async function saveUserTimezone(db, userId, timezone, nowMs) {
138
- await db.update(juniorUsers).set({
139
- timezone,
140
- updatedAt: dateFromMs$1(nowMs)
141
- }).where(and(eq(juniorUsers.id, userId), sql`${juniorUsers.timezone} is distinct from ${timezone}`));
142
- }
143
- /** Persist one provider identity observation and link verified emails to users. */
144
- async function upsertIdentity(executor, identity, nowMs = Date.now()) {
145
- return await upsertIdentityRecord(executor, identity, void 0, nowMs);
146
- }
147
- /**
148
- * Persist one provider identity after a trusted account-linking flow.
149
- * Callers must use provider-verified account data, such as GitHub's authenticated
150
- * user response, never identity claims inferred from content such as Git commits.
151
- */
152
- async function upsertLinkedIdentity(executor, userId, identity, nowMs = Date.now()) {
153
- return await upsertIdentityRecord(executor, identity, userId, nowMs);
154
- }
155
- /**
156
- * Find the one user whose primary email is in a provider-verified email list.
157
- * Return undefined when no user or more than one user matches.
158
- */
159
- async function findUserByVerifiedEmails(executor, emails) {
160
- const normalized = new Set(emails.map((email) => normalizeIdentityEmail(email) ?? ""));
161
- normalized.delete("");
162
- if (normalized.size === 0) return void 0;
163
- const rows = await executor.db().select({
164
- email: juniorUsers.primaryEmail,
165
- userId: juniorUsers.id
166
- }).from(juniorUsers).where(inArray(juniorUsers.primaryEmailNormalized, Array.from(normalized))).limit(2);
167
- return rows.length === 1 ? rows[0] : void 0;
168
- }
169
- /** Remove one exact provider identity owned by a workspace Slack user. */
170
- async function deleteProviderIdentityForSlackUser(executor, slackTeamId, slackUserId, provider, providerSubjectId) {
171
- 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;
172
- if (!userId) return;
173
- await deleteProviderIdentityForUser(executor, userId, provider, providerSubjectId);
174
- }
175
- /** Remove one exact provider identity owned by a user. */
176
- async function deleteProviderIdentityForUser(executor, userId, provider, providerSubjectId) {
177
- await executor.db().delete(juniorIdentities).where(and(eq(juniorIdentities.userId, userId), eq(juniorIdentities.provider, provider), eq(juniorIdentities.providerTenantId, ""), eq(juniorIdentities.providerSubjectId, providerSubjectId)));
178
- }
179
- /**
180
- * Save one Google sign-in. Link the Google identity to the user of its
181
- * verified email, and set its sign-in time.
182
- */
183
- async function recordGoogleSignIn(executor, account, nowMs = Date.now()) {
184
- const stored = await upsertIdentity(executor, {
185
- kind: "user",
186
- provider: GOOGLE_IDENTITY_PROVIDER,
187
- providerSubjectId: account.subject,
188
- email: account.email,
189
- emailVerified: true,
190
- displayName: account.displayName
191
- }, nowMs);
192
- await executor.db().update(juniorIdentities).set({ signedInAt: dateFromMs$1(nowMs) }).where(eq(juniorIdentities.id, stored.id));
193
- return stored;
194
- }
195
41
  // src/chat/conversations/sql/participants.ts
196
42
  /** Resolve the root conversation id that owns personal-feed membership. */
197
43
  async function resolveRootConversationId(executor, conversationId) {
@@ -339,6 +185,38 @@ function conversationMetricValues(args) {
339
185
  cost_usd: agentTurnCostUsd(usage)
340
186
  };
341
187
  }
188
+ /**
189
+ * Read one Run's stored totals from its metric rows.
190
+ *
191
+ * Returns undefined when the Run has no rows. Cost keeps only its total.
192
+ */
193
+ async function readConversationRunMetrics(executor, args) {
194
+ const rows = await executor.db().select({
195
+ metric: juniorConversationMetrics.metric,
196
+ value: juniorConversationMetrics.value
197
+ }).from(juniorConversationMetrics).where(and(eq(juniorConversationMetrics.conversationId, args.conversationId), eq(juniorConversationMetrics.runId, args.runId)));
198
+ const values = new Map(rows.map((row) => [row.metric, row.value]));
199
+ const durationMs = values.get("duration_ms");
200
+ if (durationMs === void 0) return void 0;
201
+ const components = {
202
+ inputTokens: values.get("input_tokens"),
203
+ outputTokens: values.get("output_tokens"),
204
+ cachedInputTokens: values.get("cached_input_tokens"),
205
+ cacheCreationTokens: values.get("cache_creation_tokens")
206
+ };
207
+ const hasComponents = Object.values(components).some((value) => value !== void 0);
208
+ const costUsd = values.get("cost_usd");
209
+ const usage = Object.fromEntries(Object.entries({
210
+ ...components,
211
+ reasoningTokens: values.get("reasoning_tokens"),
212
+ totalTokens: hasComponents ? void 0 : values.get("total_tokens"),
213
+ cost: costUsd === void 0 ? void 0 : { total: costUsd }
214
+ }).filter(([, value]) => value !== void 0));
215
+ return {
216
+ durationMs,
217
+ ...Object.keys(usage).length > 0 ? { usage } : void 0
218
+ };
219
+ }
342
220
  /** Replace one Run's metric rows and refresh its Conversation totals. */
343
221
  async function replaceConversationMetrics(executor, args) {
344
222
  const [current] = await executor.db().select({ updatedAt: juniorConversationMetrics.updatedAt }).from(juniorConversationMetrics).where(and(eq(juniorConversationMetrics.conversationId, args.conversationId), eq(juniorConversationMetrics.runId, args.runId))).limit(1);
@@ -448,44 +326,271 @@ async function bindProviderConversation(executor, args) {
448
326
  createdAt: /* @__PURE__ */ new Date()
449
327
  }).onConflictDoNothing().returning({ conversationId: juniorConversationBindings.conversationId }))[0]?.conversationId ?? await getConversationIdByProviderConversation(executor, args)) !== args.conversationId) throw new Error("Provider conversation is already bound to another conversation");
450
328
  }
329
+ // src/chat/identities/sql.ts
330
+ function dateFromMs$1(ms) {
331
+ return new Date(ms);
332
+ }
333
+ function tenantId$1(value) {
334
+ return value ?? "";
335
+ }
336
+ async function upsertUser(executor, args) {
337
+ const id = (await executor.db().insert(juniorUsers).values({
338
+ id: randomUUID(),
339
+ primaryEmail: args.email,
340
+ primaryEmailNormalized: args.emailNormalized,
341
+ displayName: args.displayName ?? null,
342
+ createdAt: dateFromMs$1(args.nowMs),
343
+ updatedAt: dateFromMs$1(args.nowMs)
344
+ }).onConflictDoUpdate({
345
+ target: juniorUsers.primaryEmailNormalized,
346
+ set: {
347
+ displayName: sql`coalesce(${juniorUsers.displayName}, excluded.display_name)`,
348
+ updatedAt: sql`excluded.updated_at`
349
+ }
350
+ }).returning({ id: juniorUsers.id }))[0]?.id;
351
+ if (!id) throw new Error("User identity upsert returned no row");
352
+ return id;
353
+ }
354
+ async function existingIdentity(executor, identity) {
355
+ 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];
356
+ }
357
+ async function upsertIdentityRecord(executor, identity, linkedUserId, nowMs) {
358
+ const emailNormalized = normalizeIdentityEmail(identity.email);
359
+ const email = emailNormalized ? identity.email?.trim() || emailNormalized : void 0;
360
+ const existing = await existingIdentity(executor, identity);
361
+ const userEmailNormalized = existing?.emailVerified && existing.emailNormalized ? existing.emailNormalized : identity.emailVerified ? emailNormalized : void 0;
362
+ const userEmail = existing?.emailVerified && existing.email ? existing.email : email ?? userEmailNormalized;
363
+ const verifiedUserId = identity.kind === "user" && userEmailNormalized ? await upsertUser(executor, {
364
+ email: userEmail ?? userEmailNormalized,
365
+ emailNormalized: userEmailNormalized,
366
+ nowMs,
367
+ ...existing?.displayName || identity.displayName ? { displayName: existing?.displayName ?? identity.displayName } : void 0
368
+ }) : void 0;
369
+ if (existing?.userId && verifiedUserId && existing.userId !== verifiedUserId) throw new Error("Identity verified email conflicts with linked user");
370
+ if (linkedUserId && verifiedUserId && linkedUserId !== verifiedUserId) throw new Error("Linked identity conflicts with verified email user");
371
+ if (existing?.userId && linkedUserId && existing.userId !== linkedUserId) throw new Error("Identity conflicts with linked user");
372
+ const userId = existing?.userId ?? linkedUserId ?? verifiedUserId;
373
+ const row = (await executor.db().insert(juniorIdentities).values({
374
+ id: randomUUID(),
375
+ userId: userId ?? null,
376
+ kind: identity.kind,
377
+ provider: identity.provider,
378
+ providerTenantId: tenantId$1(identity.providerTenantId),
379
+ providerSubjectId: identity.providerSubjectId,
380
+ displayName: identity.displayName ?? null,
381
+ handle: identity.handle ?? null,
382
+ email: email ?? null,
383
+ emailNormalized: emailNormalized ?? null,
384
+ emailVerified: Boolean(identity.emailVerified && emailNormalized),
385
+ avatarUrl: identity.avatarUrl ?? null,
386
+ metadata: identity.metadata ?? null,
387
+ createdAt: dateFromMs$1(nowMs),
388
+ updatedAt: dateFromMs$1(nowMs)
389
+ }).onConflictDoUpdate({
390
+ target: [
391
+ juniorIdentities.provider,
392
+ juniorIdentities.providerTenantId,
393
+ juniorIdentities.providerSubjectId
394
+ ],
395
+ set: {
396
+ kind: sql`excluded.kind`,
397
+ userId: sql`coalesce(${juniorIdentities.userId}, excluded.user_id)`,
398
+ displayName: linkedUserId ? sql`coalesce(excluded.display_name, ${juniorIdentities.displayName})` : sql`coalesce(${juniorIdentities.displayName}, excluded.display_name)`,
399
+ handle: linkedUserId ? sql`coalesce(excluded.handle, ${juniorIdentities.handle})` : sql`coalesce(${juniorIdentities.handle}, excluded.handle)`,
400
+ 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`,
401
+ 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`,
402
+ emailVerified: sql`${juniorIdentities.emailVerified} OR excluded.email_verified`,
403
+ avatarUrl: identity.avatarUrl === void 0 ? sql`${juniorIdentities.avatarUrl}` : sql`excluded.avatar_url`,
404
+ metadata: sql`coalesce(${juniorIdentities.metadata}, excluded.metadata_json)`,
405
+ updatedAt: sql`excluded.updated_at`
406
+ },
407
+ ...linkedUserId ? { setWhere: sql`${juniorIdentities.userId} IS NULL OR ${juniorIdentities.userId} = excluded.user_id` } : void 0
408
+ }).returning({
409
+ id: juniorIdentities.id,
410
+ userId: juniorIdentities.userId
411
+ }))[0];
412
+ if (!row) {
413
+ if (linkedUserId) throw new Error("Identity conflicts with linked user");
414
+ throw new Error("Identity upsert returned no row");
415
+ }
416
+ return {
417
+ id: row.id,
418
+ ...row.userId ? { userId: row.userId } : void 0
419
+ };
420
+ }
421
+ /** Read the saved timezone for one user. */
422
+ async function readUserTimezone(db, userId) {
423
+ return (await db.select({ timezone: juniorUsers.timezone }).from(juniorUsers).where(eq(juniorUsers.id, userId)).limit(1))[0]?.timezone ?? void 0;
424
+ }
425
+ /** Save the IANA timezone for one user. */
426
+ async function saveUserTimezone(db, userId, timezone, nowMs) {
427
+ await db.update(juniorUsers).set({
428
+ timezone,
429
+ updatedAt: dateFromMs$1(nowMs)
430
+ }).where(and(eq(juniorUsers.id, userId), sql`${juniorUsers.timezone} is distinct from ${timezone}`));
431
+ }
432
+ /** Persist one provider identity observation and link verified emails to users. */
433
+ async function upsertIdentity(executor, identity, nowMs = Date.now()) {
434
+ return await upsertIdentityRecord(executor, identity, void 0, nowMs);
435
+ }
436
+ /**
437
+ * Persist one provider identity after a trusted account-linking flow.
438
+ * Callers must use provider-verified account data, such as GitHub's authenticated
439
+ * user response, never identity claims inferred from content such as Git commits.
440
+ */
441
+ async function upsertLinkedIdentity(executor, userId, identity, nowMs = Date.now()) {
442
+ return await upsertIdentityRecord(executor, identity, userId, nowMs);
443
+ }
444
+ /**
445
+ * Find the one user whose primary email is in a provider-verified email list.
446
+ * Return undefined when no user or more than one user matches.
447
+ */
448
+ async function findUserByVerifiedEmails(executor, emails) {
449
+ const normalized = new Set(emails.map((email) => normalizeIdentityEmail(email) ?? ""));
450
+ normalized.delete("");
451
+ if (normalized.size === 0) return void 0;
452
+ const rows = await executor.db().select({
453
+ email: juniorUsers.primaryEmail,
454
+ userId: juniorUsers.id
455
+ }).from(juniorUsers).where(inArray(juniorUsers.primaryEmailNormalized, Array.from(normalized))).limit(2);
456
+ return rows.length === 1 ? rows[0] : void 0;
457
+ }
458
+ /** Remove one exact provider identity owned by a workspace Slack user. */
459
+ async function deleteProviderIdentityForSlackUser(executor, slackTeamId, slackUserId, provider, providerSubjectId) {
460
+ 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;
461
+ if (!userId) return;
462
+ await deleteProviderIdentityForUser(executor, userId, provider, providerSubjectId);
463
+ }
464
+ /** Remove one exact provider identity owned by a user. */
465
+ async function deleteProviderIdentityForUser(executor, userId, provider, providerSubjectId) {
466
+ await executor.db().delete(juniorIdentities).where(and(eq(juniorIdentities.userId, userId), eq(juniorIdentities.provider, provider), eq(juniorIdentities.providerTenantId, ""), eq(juniorIdentities.providerSubjectId, providerSubjectId)));
467
+ }
468
+ /**
469
+ * Save one Google sign-in. Link the Google identity to the user of its
470
+ * verified email, and set its sign-in time.
471
+ */
472
+ async function recordGoogleSignIn(executor, account, nowMs = Date.now()) {
473
+ const stored = await upsertIdentity(executor, {
474
+ kind: "user",
475
+ provider: GOOGLE_IDENTITY_PROVIDER,
476
+ providerSubjectId: account.subject,
477
+ email: account.email,
478
+ emailVerified: true,
479
+ displayName: account.displayName,
480
+ avatarUrl: account.avatarUrl ?? null
481
+ }, nowMs);
482
+ await executor.db().update(juniorIdentities).set({ signedInAt: dateFromMs$1(nowMs) }).where(eq(juniorIdentities.id, stored.id));
483
+ return stored;
484
+ }
485
+ // src/chat/identities/actor.ts
486
+ /**
487
+ * Resolve runtime Actors to the Identity rows that Junior stores for them.
488
+ *
489
+ * The Conversation store, OAuth, the viewer, and message events use this
490
+ * module, so they agree on one Identity per person. The key for each Actor
491
+ * stays private here, so it can change without changing callers.
492
+ */
493
+ /**
494
+ * The fixed id of the one system Identity: kind `system`, provider `junior`,
495
+ * an empty tenant, and subject `system`. A migration creates the row, so
496
+ * callers use this id without a database read.
497
+ */
498
+ const SYSTEM_IDENTITY_ID = "system";
499
+ /**
500
+ * Return whether an Actor uses the system Identity.
501
+ *
502
+ * `SystemActor.name` is provenance, not part of the Identity. A LocalActor
503
+ * uses the system Identity because the local runner has no per-person user id.
504
+ */
505
+ function usesSystemIdentity(actor) {
506
+ return actor.platform === "local" || actor.platform === "system";
507
+ }
508
+ /** Return the Identity key for one Slack or web Actor. */
509
+ function identityKey(actor) {
510
+ switch (actor.platform) {
511
+ case "slack": return {
512
+ kind: "user",
513
+ provider: "slack",
514
+ providerTenantId: actor.teamId,
515
+ providerSubjectId: actor.userId,
516
+ ...actor.fullName ? { displayName: actor.fullName } : void 0,
517
+ ...actor.userName ? { handle: actor.userName } : void 0,
518
+ ...actor.email ? {
519
+ email: actor.email,
520
+ emailVerified: true
521
+ } : void 0,
522
+ metadata: { platform: "slack" }
523
+ };
524
+ case "web": {
525
+ const email = normalizeIdentityEmail(actor.email);
526
+ if (!email) return;
527
+ return {
528
+ kind: "user",
529
+ provider: "junior",
530
+ providerSubjectId: email,
531
+ email,
532
+ emailVerified: true,
533
+ ...actor.fullName ? { displayName: actor.fullName } : void 0,
534
+ metadata: { platform: "web" }
535
+ };
536
+ }
537
+ }
538
+ }
539
+ /**
540
+ * Return the Identity id for one Actor. Save a Slack or web Identity first.
541
+ * Return undefined only for a web Actor without a verified email.
542
+ */
543
+ async function resolveActorIdentityId(executor, actor, nowMs = Date.now()) {
544
+ if (usesSystemIdentity(actor)) return SYSTEM_IDENTITY_ID;
545
+ const key = identityKey(actor);
546
+ return key ? (await upsertIdentity(executor, key, nowMs)).id : void 0;
547
+ }
548
+ /** Save the profile photo on the Identity of one Slack Actor. Null clears it. */
549
+ async function saveActorAvatar(executor, actor, avatarUrl, nowMs = Date.now()) {
550
+ const key = identityKey(actor);
551
+ if (key) await upsertIdentity(executor, {
552
+ ...key,
553
+ avatarUrl
554
+ }, nowMs);
555
+ }
556
+ /** Find the stored Identity id for one Actor without writing. */
557
+ async function findActorIdentityId(db, actor) {
558
+ if (usesSystemIdentity(actor)) return SYSTEM_IDENTITY_ID;
559
+ const key = identityKey(actor);
560
+ if (!key) return;
561
+ return (await db.select({ id: juniorIdentities.id }).from(juniorIdentities).where(and(eq(juniorIdentities.provider, key.provider), eq(juniorIdentities.providerTenantId, key.providerTenantId ?? ""), eq(juniorIdentities.providerSubjectId, key.providerSubjectId))).limit(1))[0]?.id;
562
+ }
451
563
  // src/chat/conversations/sql/actor-identity.ts
452
- /** Build a durable identity observation from a stored conversation actor. */
453
- function identityFromActor(actor) {
564
+ /**
565
+ * Convert an Actor in the stored Conversation shape to a runtime Actor.
566
+ *
567
+ * TODO(dcramer): Remove after Conversations store the Identity id instead of
568
+ * StoredSlackActor.
569
+ */
570
+ function actorFromStoredActor(actor) {
454
571
  if (actor?.slackUserId) return {
455
- kind: "user",
456
- provider: "slack",
457
- providerTenantId: actor.teamId,
458
- providerSubjectId: actor.slackUserId,
459
- ...actor.fullName ? { displayName: actor.fullName } : void 0,
460
- ...actor.slackUserName ? { handle: actor.slackUserName } : void 0,
461
- ...actor.email ? {
462
- email: actor.email,
463
- emailVerified: true
464
- } : void 0,
465
- metadata: { platform: "slack" }
572
+ ...actor.email ? { email: actor.email } : void 0,
573
+ ...actor.fullName ? { fullName: actor.fullName } : void 0,
574
+ platform: "slack",
575
+ teamId: actor.teamId ?? "",
576
+ userId: actor.slackUserId,
577
+ ...actor.slackUserName ? { userName: actor.slackUserName } : void 0
466
578
  };
467
- const email = actor?.email?.trim().toLowerCase();
468
- if (!email) return;
579
+ if (!actor?.email) return;
469
580
  return {
470
- kind: "user",
471
- provider: "junior",
472
- providerSubjectId: email,
473
- email,
474
- emailVerified: true,
475
- ...actor?.fullName ? { displayName: actor.fullName } : void 0,
476
- metadata: { platform: "web" }
581
+ email: actor.email,
582
+ ...actor.fullName ? { fullName: actor.fullName } : void 0,
583
+ platform: "web",
584
+ userId: actor.email
477
585
  };
478
586
  }
479
587
  /**
480
- * Build the identity observation for one runtime actor with the same key the
481
- * Conversation write path stores. Local and system actors have no user
482
- * identity.
588
+ * Build the legacy system Identity for a Conversation that has no Actor.
589
+ *
590
+ * TODO(dcramer): Remove after every ingress supplies its Actor. These rows use
591
+ * one subject per Source (`scheduler`, `local-cli`, `event`). An Actor
592
+ * uses the one system Identity instead, so the backfill must merge them.
483
593
  */
484
- function identityFromRuntimeActor(actor) {
485
- if (actor.platform === "slack") return identityFromActor(toStoredSlackActor(actor));
486
- if (actor.platform === "web") return identityFromActor(actor.email ? { email: actor.email } : void 0);
487
- }
488
- /** Build a system identity when a conversation has no user actor. */
489
594
  function systemIdentityFromSource(source) {
490
595
  if (source === "scheduler") return {
491
596
  kind: "system",
@@ -506,9 +611,15 @@ function systemIdentityFromSource(source) {
506
611
  displayName: "Event"
507
612
  };
508
613
  }
509
- /** Prefer the user actor identity, else a source-derived system identity. */
510
- function actorIdentityForConversation(conversation) {
511
- return identityFromActor(conversation.actor) ?? systemIdentityFromSource(conversation.source);
614
+ /**
615
+ * Save the Identity of a Conversation's actor and return its id. Use the
616
+ * legacy source system Identity when the Conversation has no actor.
617
+ */
618
+ async function resolveConversationActorIdentityId(executor, conversation) {
619
+ const actor = actorFromStoredActor(conversation.actor);
620
+ if (actor) return await resolveActorIdentityId(executor, actor, conversation.updatedAtMs);
621
+ const fallback = systemIdentityFromSource(conversation.source);
622
+ return fallback ? (await upsertIdentity(executor, fallback, conversation.updatedAtMs)).id : void 0;
512
623
  }
513
624
  /**
514
625
  * Reconstruct a stored actor with the linked user name and identity-scoped
@@ -780,6 +891,9 @@ var SqlStore = class {
780
891
  if (!row) return;
781
892
  return conversationFromRow(row);
782
893
  }
894
+ async getRunMetrics(args) {
895
+ return await readConversationRunMetrics(this.executor, args);
896
+ }
783
897
  async recordActivity(args) {
784
898
  const nowMs = args.nowMs ?? now();
785
899
  const activityAtMs = args.activityAtMs ?? nowMs;
@@ -824,7 +938,12 @@ var SqlStore = class {
824
938
  const existing = existingRow ? conversationFromRow(existingRow) : void 0;
825
939
  if (!existing && !args.destination) return;
826
940
  const incomingExecutionAt = args.execution.updatedAtMs ?? args.updatedAtMs;
827
- const incomingIsFresh = incomingExecutionAt >= (existing?.execution.updatedAtMs ?? existing?.updatedAtMs ?? 0);
941
+ const existingExecutionAt = existing?.execution.updatedAtMs ?? existing?.updatedAtMs ?? 0;
942
+ const latestMetricRunId = existing?.executionMetrics?.runId;
943
+ const incomingIsFresh = !(args.metrics !== null && args.execution.runId !== void 0 && latestMetricRunId !== void 0 && latestMetricRunId !== args.execution.runId && (args.execution.status === "idle" || args.execution.status === "failed") && await readConversationRunMetrics(this.executor, {
944
+ conversationId: args.conversationId,
945
+ runId: args.execution.runId
946
+ }) !== void 0) && incomingExecutionAt >= existingExecutionAt;
828
947
  const execution = incomingIsFresh ? args.execution : existing?.execution ?? args.execution;
829
948
  const actor = mergeActor(existing?.actor, args.actor);
830
949
  const source = existing?.source ?? args.source;
@@ -924,8 +1043,7 @@ var SqlStore = class {
924
1043
  location: conversation.location,
925
1044
  sessionSource: conversation.sessionSource
926
1045
  });
927
- const actorIdentityObservation = actorIdentityForConversation(conversation);
928
- const actorIdentity = actorIdentityObservation ? await upsertIdentity(this.executor, actorIdentityObservation, conversation.updatedAtMs) : void 0;
1046
+ const actorIdentityId = await resolveConversationActorIdentityId(this.executor, conversation);
929
1047
  const rootConversationId = conversation.parentConversationId ? sql`(
930
1048
  select parent.root_conversation_id
931
1049
  from junior_conversations parent
@@ -939,7 +1057,7 @@ var SqlStore = class {
939
1057
  location: location ?? null,
940
1058
  destinationId: destinationId ?? null,
941
1059
  destination: null,
942
- actorIdentityId: actorIdentity?.id ?? null,
1060
+ actorIdentityId: actorIdentityId ?? null,
943
1061
  creatorIdentityId: null,
944
1062
  credentialSubjectIdentityId: null,
945
1063
  actor: null,
@@ -962,7 +1080,7 @@ var SqlStore = class {
962
1080
  sessionSource: sql`coalesce(${juniorConversations.sessionSource}, excluded.source_json)`,
963
1081
  location: sql`coalesce(excluded.location_json, ${juniorConversations.location})`,
964
1082
  destinationId: sql`coalesce(excluded.destination_id, ${juniorConversations.destinationId})`,
965
- actorIdentityId: sql`coalesce(excluded.actor_identity_id, ${juniorConversations.actorIdentityId})`,
1083
+ actorIdentityId: sql`coalesce(${juniorConversations.actorIdentityId}, excluded.actor_identity_id)`,
966
1084
  creatorIdentityId: sql`coalesce(excluded.creator_identity_id, ${juniorConversations.creatorIdentityId})`,
967
1085
  credentialSubjectIdentityId: sql`coalesce(excluded.credential_subject_identity_id, ${juniorConversations.credentialSubjectIdentityId})`,
968
1086
  channelName: sql`coalesce(excluded.channel_name, ${juniorConversations.channelName})`,
@@ -1061,7 +1179,6 @@ async function ensureConversationRow(executor, conversationId, atMs, options = {
1061
1179
  });
1062
1180
  }
1063
1181
  // src/chat/conversations/sql/event-actor.ts
1064
- const SLACK_USER_ID_PATTERN = /^[UW][A-Z0-9]{5,}$/;
1065
1182
  function isRecord(value) {
1066
1183
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1067
1184
  }
@@ -1118,48 +1235,41 @@ async function findJuniorIdentityIdByDashboardAuthorId(executor, dashboardAuthor
1118
1235
  }
1119
1236
  /**
1120
1237
  * Resolve the durable actor identity for one event payload.
1121
- * Human user messages get an identity when author data is enough; other events
1122
- * keep a null actor.
1238
+ * Ingress sets `authorIdentityId` on new user Messages. Legacy writes without
1239
+ * it still guess the author from its data, and a new Message logs the guess.
1123
1240
  */
1124
1241
  async function resolveEventActorIdentityId(executor, args) {
1125
1242
  const data = args.data;
1126
1243
  if (data.type !== "message" && data.type !== "message_updated") return;
1127
1244
  if (typeof data.authorIdentityId === "string" && data.authorIdentityId) return data.authorIdentityId;
1128
1245
  if (data.role !== "user") return;
1246
+ if (data.type === "message") logWarn("conversation.message.author_identity.missing", { "app.message.id": data.messageId });
1129
1247
  const author = readAuthor(data.meta);
1130
1248
  if (author.isBot === true) return;
1131
1249
  const userId = parseActorUserId(author.userId);
1132
1250
  if (!userId) return;
1133
- if (SLACK_USER_ID_PATTERN.test(userId)) {
1251
+ if (isSlackUserId(userId)) {
1134
1252
  const tenantId = await loadConversationSlackTenantId(executor, args.conversationId);
1135
- const observation = {
1136
- kind: "user",
1137
- provider: "slack",
1138
- providerSubjectId: userId,
1139
- ...tenantId ? { providerTenantId: tenantId } : void 0,
1140
- ...author.fullName ? { displayName: author.fullName } : void 0,
1141
- ...author.userName ? { handle: author.userName } : void 0,
1142
- ...author.email ? {
1143
- email: author.email,
1144
- emailVerified: true
1145
- } : void 0
1146
- };
1147
- if (tenantId) return (await upsertIdentity(executor, observation, args.nowMs)).id;
1148
- return await findIdentityIdByProviderSubject(executor, {
1253
+ if (!tenantId) return await findIdentityIdByProviderSubject(executor, {
1149
1254
  provider: "slack",
1150
1255
  providerSubjectId: userId
1151
- }) ?? void 0;
1256
+ });
1257
+ return await resolveActorIdentityId(executor, {
1258
+ platform: "slack",
1259
+ teamId: tenantId,
1260
+ userId,
1261
+ ...author.email ? { email: author.email } : void 0,
1262
+ ...author.fullName ? { fullName: author.fullName } : void 0,
1263
+ ...author.userName ? { userName: author.userName } : void 0
1264
+ }, args.nowMs);
1152
1265
  }
1153
- const email = typeof author.email === "string" && author.email.includes("@") ? author.email.trim().toLowerCase() : userId.includes("@") ? userId.trim().toLowerCase() : void 0;
1154
- if (email) return (await upsertIdentity(executor, {
1155
- kind: "user",
1156
- provider: "junior",
1157
- providerSubjectId: email,
1266
+ const email = typeof author.email === "string" && author.email.includes("@") ? author.email : userId.includes("@") ? userId : void 0;
1267
+ if (email) return await resolveActorIdentityId(executor, {
1268
+ platform: "web",
1269
+ userId,
1158
1270
  email,
1159
- emailVerified: true,
1160
- ...author.fullName ? { displayName: author.fullName } : void 0,
1161
- metadata: { platform: "web" }
1162
- }, args.nowMs)).id;
1271
+ ...author.fullName ? { fullName: author.fullName } : void 0
1272
+ }, args.nowMs);
1163
1273
  if (userId.startsWith("dashboard:")) return findJuniorIdentityIdByDashboardAuthorId(executor, userId);
1164
1274
  return findJuniorIdentityIdByEmail(executor, userId);
1165
1275
  }
@@ -1556,6 +1666,163 @@ async function closeDb() {
1556
1666
  current = void 0;
1557
1667
  await previous?.db.close();
1558
1668
  }
1669
+ // src/chat/state/cache.ts
1670
+ const MAX_CACHE_KEY_LENGTH = 512;
1671
+ const LOAD_WAIT_MS = 1e4;
1672
+ /**
1673
+ * Create a cache in the state adapter under `junior:cache:{namespace}:`.
1674
+ * Entries are wrapped, so `null` and `false` values are cache hits.
1675
+ */
1676
+ function createCache(state, namespace) {
1677
+ return { async getOrLoad(key, load, options) {
1678
+ if (!key.trim()) throw new Error("Cache key is required");
1679
+ if (key.length > MAX_CACHE_KEY_LENGTH) throw new Error("Cache key exceeds the maximum length");
1680
+ const cacheKey = `junior:cache:${namespace}:${key}`;
1681
+ const cached = async () => options?.refresh ? void 0 : await state.get(cacheKey) ?? void 0;
1682
+ await state.connect();
1683
+ const hit = await cached();
1684
+ if (hit) return hit.value;
1685
+ const attempt = await withLock(state, `${cacheKey}:load`, async () => {
1686
+ const loadedMeanwhile = await cached();
1687
+ if (loadedMeanwhile) return loadedMeanwhile.value;
1688
+ const entry = await load();
1689
+ if (entry.ttlMs !== void 0) {
1690
+ if (!(entry.ttlMs > 0)) throw new Error("Cache ttlMs must be positive");
1691
+ await state.set(cacheKey, { value: entry.value }, entry.ttlMs);
1692
+ }
1693
+ return entry.value;
1694
+ }, {
1695
+ keepAlive: true,
1696
+ waitMs: LOAD_WAIT_MS
1697
+ });
1698
+ if (attempt.acquired) return attempt.value;
1699
+ return (await load()).value;
1700
+ } };
1701
+ }
1702
+ // src/chat/identities/avatar.ts
1703
+ /**
1704
+ * Profile photos of people, saved on their native Slack and Google
1705
+ * identities. Junior serves each photo from its own API, so browsers never
1706
+ * load a photo from Slack or Google.
1707
+ */
1708
+ const AVATAR_HOSTS = {
1709
+ slack: ["avatars.slack-edge.com"],
1710
+ [GOOGLE_IDENTITY_PROVIDER]: ["googleusercontent.com"]
1711
+ };
1712
+ const AVATAR_PROVIDERS = Object.keys(AVATAR_HOSTS);
1713
+ const AVATAR_CACHE_TTL_MS = 864e5;
1714
+ const AVATAR_FAILURE_TTL_MS = 3e5;
1715
+ const AVATAR_FETCH_TIMEOUT_MS = 5e3;
1716
+ const MAX_AVATAR_BYTES = 1048576;
1717
+ /**
1718
+ * Return the URL when it is HTTPS on a photo host of the provider or a
1719
+ * subdomain. Junior fetches only these URLs.
1720
+ */
1721
+ function parseAvatarUrl(value, provider) {
1722
+ const hosts = AVATAR_HOSTS[provider];
1723
+ if (!hosts || !value || !URL.canParse(value)) return void 0;
1724
+ const url = new URL(value);
1725
+ if (url.protocol !== "https:" || url.username || url.password) return;
1726
+ const host = url.hostname.toLowerCase();
1727
+ return hosts.some((allowed) => host === allowed || host.endsWith(`.${allowed}`)) ? url.href : void 0;
1728
+ }
1729
+ function avatarPath(identityId) {
1730
+ return `/api/people/avatars/${encodeURIComponent(identityId)}`;
1731
+ }
1732
+ /**
1733
+ * Read the photo path of each user: the Slack photo, then the Google photo.
1734
+ * Users without a photo are not in the map.
1735
+ */
1736
+ async function readUserAvatarUrls(db, userIds) {
1737
+ if (userIds.length === 0) return /* @__PURE__ */ new Map();
1738
+ const rows = await db.select({
1739
+ identityId: juniorIdentities.id,
1740
+ provider: juniorIdentities.provider,
1741
+ updatedAt: juniorIdentities.updatedAt,
1742
+ userId: juniorIdentities.userId
1743
+ }).from(juniorIdentities).where(and(inArray(juniorIdentities.userId, [...new Set(userIds)]), eq(juniorIdentities.kind, "user"), inArray(juniorIdentities.provider, AVATAR_PROVIDERS), isNotNull(juniorIdentities.avatarUrl)));
1744
+ const rank = (row) => AVATAR_PROVIDERS.indexOf(row.provider);
1745
+ rows.sort((left, right) => rank(left) - rank(right) || right.updatedAt.getTime() - left.updatedAt.getTime());
1746
+ const avatars = /* @__PURE__ */ new Map();
1747
+ for (const row of rows) if (row.userId && !avatars.has(row.userId)) avatars.set(row.userId, avatarPath(row.identityId));
1748
+ return avatars;
1749
+ }
1750
+ /**
1751
+ * Return the photo path for one Identity row. A linked Identity uses the
1752
+ * photo of its user. An Identity without a user uses its own photo.
1753
+ */
1754
+ function identityAvatarUrl(row, userAvatarUrls) {
1755
+ if (row.userId) return userAvatarUrls.get(row.userId);
1756
+ return row.avatarUrl ? avatarPath(row.identityId) : void 0;
1757
+ }
1758
+ async function readBoundedBody(response, maxBytes) {
1759
+ if (!response.body) return /* @__PURE__ */ new Uint8Array();
1760
+ const reader = response.body.getReader();
1761
+ const chunks = [];
1762
+ let total = 0;
1763
+ while (true) {
1764
+ const { done, value } = await reader.read();
1765
+ if (done) return Buffer.concat(chunks);
1766
+ total += value.byteLength;
1767
+ if (total > maxBytes) {
1768
+ await reader.cancel();
1769
+ return;
1770
+ }
1771
+ chunks.push(value);
1772
+ }
1773
+ }
1774
+ function avatarFetchFailed(host, attributes) {
1775
+ logWarn("avatar.fetch.failed", {
1776
+ "server.address": host,
1777
+ ...attributes
1778
+ });
1779
+ return null;
1780
+ }
1781
+ async function fetchAvatarImage(url) {
1782
+ const host = new URL(url).hostname;
1783
+ let data;
1784
+ try {
1785
+ const response = await fetch(url, {
1786
+ redirect: "error",
1787
+ signal: AbortSignal.timeout(AVATAR_FETCH_TIMEOUT_MS)
1788
+ });
1789
+ if (!response.ok) {
1790
+ await response.body?.cancel();
1791
+ return avatarFetchFailed(host, { "http.response.status_code": response.status });
1792
+ }
1793
+ data = await readBoundedBody(response, MAX_AVATAR_BYTES);
1794
+ } catch (error) {
1795
+ return avatarFetchFailed(host, { "exception.message": error instanceof Error ? error.message : String(error) });
1796
+ }
1797
+ if (!data) return avatarFetchFailed(host, { "app.reason": "too_large" });
1798
+ const contentType = detectImageMediaType(data);
1799
+ if (!contentType) return avatarFetchFailed(host, { "app.reason": "not_an_image" });
1800
+ return {
1801
+ contentType,
1802
+ data: Buffer.from(data).toString("base64")
1803
+ };
1804
+ }
1805
+ /**
1806
+ * Read the saved photo of one Identity. Junior fetches it from the provider
1807
+ * once and keeps it in the state cache for a day. Return undefined when the
1808
+ * Identity has no photo or the provider does not return one.
1809
+ */
1810
+ async function readAvatarImage(db, state, identityId) {
1811
+ const [row] = await db.select({
1812
+ avatarUrl: juniorIdentities.avatarUrl,
1813
+ provider: juniorIdentities.provider
1814
+ }).from(juniorIdentities).where(and(eq(juniorIdentities.id, identityId), eq(juniorIdentities.kind, "user"))).limit(1);
1815
+ const url = row ? parseAvatarUrl(row.avatarUrl, row.provider) : void 0;
1816
+ if (!url) return void 0;
1817
+ const key = `avatar:${createHash("sha256").update(url).digest("hex")}`;
1818
+ return await createCache(state, "core").getOrLoad(key, async () => {
1819
+ const loaded = await fetchAvatarImage(url);
1820
+ return {
1821
+ value: loaded,
1822
+ ttlMs: loaded ? AVATAR_CACHE_TTL_MS : AVATAR_FAILURE_TTL_MS
1823
+ };
1824
+ }) ?? void 0;
1825
+ }
1559
1826
  // src/chat/plugins/viewer.ts
1560
1827
  function identityFromRow(row) {
1561
1828
  return identitySchema.parse({
@@ -1615,6 +1882,10 @@ async function updateViewerDisplayNameFromSql(db, userId, displayName) {
1615
1882
  }).where(eq(juniorUsers.id, userId)).returning())[0];
1616
1883
  return userRow ? await readUserById(db, userRow) : void 0;
1617
1884
  }
1885
+ /** Read the photo path of one canonical user: Slack, then Google. */
1886
+ async function readViewerAvatarUrl(userId) {
1887
+ return (await readUserAvatarUrls(getDb(), [userId])).get(userId);
1888
+ }
1618
1889
  /** Update one canonical user's display name. */
1619
1890
  async function updateViewerDisplayName(userId, displayName) {
1620
1891
  return await updateViewerDisplayNameFromSql(getDb(), userId, displayName);
@@ -1641,17 +1912,8 @@ async function listAdminUsersFromSql(db) {
1641
1912
  }
1642
1913
  /** Resolve the stored identity and linked user for one runtime actor. */
1643
1914
  async function readActorIdentityFromSql(db, actor) {
1644
- const key = identityFromRuntimeActor(actor);
1645
- if (!key) return void 0;
1646
- const row = (await db.select({
1647
- identity: juniorIdentities,
1648
- user: juniorUsers
1649
- }).from(juniorIdentities).leftJoin(juniorUsers, eq(juniorUsers.id, juniorIdentities.userId)).where(and(eq(juniorIdentities.kind, "user"), eq(juniorIdentities.provider, key.provider), eq(juniorIdentities.providerTenantId, key.providerTenantId ?? ""), eq(juniorIdentities.providerSubjectId, key.providerSubjectId))).limit(1))[0];
1650
- if (!row) return void 0;
1651
- return {
1652
- identity: identityFromRow(row.identity),
1653
- ...row.user ? { user: await readUserById(db, row.user) } : void 0
1654
- };
1915
+ const identityId = await findActorIdentityId(db, actor);
1916
+ return identityId ? await readIdentityByIdFromSql(db, identityId) : void 0;
1655
1917
  }
1656
1918
  /** Resolve the stored identity and linked user for one runtime actor. */
1657
1919
  async function readActorIdentity(actor) {
@@ -1673,4 +1935,4 @@ async function readIdentityByIdFromSql(db, identityId) {
1673
1935
  async function readIdentityById(identityId) {
1674
1936
  return await readIdentityByIdFromSql(getDb(), identityId);
1675
1937
  }
1676
- export { resolveRootConversationId as A, parseDestination as B, bindProviderConversation as C, agentTurnTotalTokens as D, agentTurnCostUsd as E, recordGoogleSignIn as F, sameDestination as H, saveUserTimezone as I, upsertIdentity as L, deleteProviderIdentityForUser as M, findUserByVerifiedEmails as N, agentTurnUsageSchema as O, readUserTimezone as P, upsertLinkedIdentity as R, identityFromActor as S, addAgentTurnUsage as T, requireSlackDestination as V, loadConversationSlackTenantIds as _, resolveViewerUser as a, locationFromRow as b, closeDb as c, getConversationMessageSearchStore as d, getConversationStore as f, withConversationEventLock as g, createSqlConversationEventStore as h, readIdentityById as i, deleteProviderIdentityForSlackUser as j, hasAgentTurnUsage as k, db_exports as l, getSqlExecutor as m, listAdminUsersFromSql as n, setUserAdminFromSql as o, getDb as p, readActorIdentity as r, updateViewerDisplayName as s, findUserByEmail as t, getConversationEventStore as u, createSqlStore as v, parseSessionSource as w, startLocationThread as x, withConversationMutationLock as y, createSlackDestination as z };
1938
+ export { startLocationThread as A, upsertLinkedIdentity as B, getSqlExecutor as C, createSqlStore as D, loadConversationSlackTenantIds as E, deleteProviderIdentityForUser as F, agentTurnTotalTokens as G, parseSessionSource as H, findUserByVerifiedEmails as I, resolveRootConversationId as J, agentTurnUsageSchema as K, readUserTimezone as L, resolveActorIdentityId as M, saveActorAvatar as N, withConversationMutationLock as O, deleteProviderIdentityForSlackUser as P, sameDestination as Q, recordGoogleSignIn as R, getDb as S, withConversationEventLock as T, addAgentTurnUsage as U, bindProviderConversation as V, agentTurnCostUsd as W, parseDestination as X, createSlackDestination as Y, requireSlackDestination as Z, closeDb as _, readActorIdentityFromSql as a, getConversationMessageSearchStore as b, readViewerAvatarUrl as c, updateViewerDisplayName as d, identityAvatarUrl as f, createCache as g, readUserAvatarUrls as h, readActorIdentity as i, SYSTEM_IDENTITY_ID as j, locationFromRow as k, resolveViewerUser as l, readAvatarImage as m, findUserByEmailFromSql as n, readIdentityById as o, parseAvatarUrl as p, hasAgentTurnUsage as q, listAdminUsersFromSql as r, readIdentityByIdFromSql as s, findUserByEmail as t, setUserAdminFromSql as u, db_exports as v, createSqlConversationEventStore as w, getConversationStore as x, getConversationEventStore as y, saveUserTimezone as z };