@sentry/junior 0.268.0 → 0.270.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 (83) hide show
  1. package/README.md +2 -9
  2. package/dist/{adapter-04AcD2SO.js → adapter-BQNTKVTF.js} +3 -49
  3. package/dist/{admin-DIU1QyG3.js → admin-CKVEoXa4.js} +1 -1
  4. package/dist/{agent-runner-CAYfFmU7.js → agent-runner-MSQSMFkq.js} +4538 -3251
  5. package/dist/api/schema.d.ts +2 -3
  6. package/dist/api/schema.js +7 -7
  7. package/dist/api.d.ts +3 -1
  8. package/dist/api.js +190 -301
  9. package/dist/{app-z6Uuyq3j.d.ts → app-Bz8t4P8p.d.ts} +9 -3
  10. package/dist/app.d.ts +1 -1
  11. package/dist/app.js +129 -209
  12. package/dist/{automation-CmeFBSEh.js → automation-Dwh_xcqM.js} +74 -74
  13. package/dist/{briefs-DQbV2jNr.js → briefs-DNFzFFVx.js} +5 -5
  14. package/dist/{catalog-runtime-BD9GSpc6.js → catalog-runtime-Bdym_wKy.js} +1 -1
  15. package/dist/cli/chat.js +16 -26
  16. package/dist/cli/check.js +3 -3
  17. package/dist/cli/main.js +5 -3
  18. package/dist/cli/plugins.js +4 -4
  19. package/dist/cli/snapshot-create.js +4 -4
  20. package/dist/cli/upgrade.d.ts +1 -1
  21. package/dist/cli/upgrade.js +3 -3
  22. package/dist/{client-C1yDYjoS.js → client-B2MjENki.js} +31 -16
  23. package/dist/{config-DrTeF80T.js → config-Dm3pWRqY.js} +3 -3
  24. package/dist/{connections-D46f8vvi.js → connections-0BBhiD-p.js} +10 -10
  25. package/dist/{conversation-DSo310Ps.js → conversation-AhOVzN_k.js} +11 -86
  26. package/dist/{conversation-privacy-CxlOCiUo.js → conversation-privacy-C1pJ89rs.js} +1 -1
  27. package/dist/{credential-sync-sm9fegLY.js → credential-sync-BRiWYs6_.js} +15 -14
  28. package/dist/{credentialed-lukuD-cc.js → credentialed-BeBaJMZ_.js} +565 -83
  29. package/dist/{store-xMuuXRE0.d.ts → db-BYOuu6v2.d.ts} +4 -0
  30. package/dist/{executor-4vhvsMZJ.js → executor-BB6eJkD4.js} +6 -6
  31. package/dist/instrumentation.js +2 -2
  32. package/dist/{interruption-marker-CkncDT8d.js → interruption-marker-CdblrIQK.js} +1 -1
  33. package/dist/{job-queue-In9QKz-I.js → job-queue-DiaXoSDg.js} +2 -2
  34. package/dist/{list-BkgnXEEB.js → list-BTDRxkOg.js} +331 -662
  35. package/dist/locks-D-0h111u.js +48 -0
  36. package/dist/{logging-DLSpkSSr.js → logging-C6EdT6PP.js} +1 -1
  37. package/dist/{history-C84mlAwO.js → media-U6Tuz6Mi.js} +84 -2
  38. package/dist/{message-BObyg59s.js → message-DsjiWRVg.js} +4 -4
  39. package/dist/{model-profile-Bg8EoCKD.js → model-profile-B998sjVd.js} +9 -2
  40. package/dist/nitro.d.ts +1 -1
  41. package/dist/nitro.js +5 -3
  42. package/dist/{oauth-callback-server-B-WbSdoH.js → oauth-callback-server-CjFFVGqa.js} +2 -2
  43. package/dist/{prompt-BW9jFsqZ.js → prompt-BPSdXYXV.js} +1 -1
  44. package/dist/{registry-BgecKva0.js → registry-CndmkP2P.js} +1 -1
  45. package/dist/reporting-schema-CZREhYc-.js +104 -0
  46. package/dist/{web-message-id-DlHNANtd.js → reporting-window-DRCi1Ls_.js} +139 -7
  47. package/dist/{resolve-Cpa4Bu1O.js → resolve-BkAA1wH7.js} +3 -3
  48. package/dist/{result-D3TSmZZ8.js → result-9TJTpuYP.js} +30 -11
  49. package/dist/{runner-DS5OKhEp.js → runner-BmKLSyJm.js} +12 -11
  50. package/dist/{sandbox-egress-signals-CnDjY5rx.js → sandbox-egress-signals-CjLaogMo.js} +1 -1
  51. package/dist/{schema-0050HFnB.js → schema-DNLJ_MzW.js} +36 -20
  52. package/dist/{schema-ZngqcOWM.d.ts → schema-WayPSHFu.d.ts} +149 -12
  53. package/dist/{service-account-CKV2WTY7.js → service-account-D6e9cVmH.js} +4 -3
  54. package/dist/{service-account-DGjnDw9y.js → service-account-DlhYl-Mu.js} +3 -3
  55. package/dist/{session-stop-Dam1EI7B.js → session-stop-MZRgYqYH.js} +24 -16
  56. package/dist/{skills-CfAnfImD.js → skills-BggTR_ue.js} +2 -2
  57. package/dist/{spawn-cmq9p4Ly.js → spawn-DmtlcAT_.js} +2 -2
  58. package/dist/{store-zPEoJPBB.js → store-BeWGgaMA.js} +3 -3
  59. package/dist/{store-C5PN02QO.js → store-Byarin7b.js} +4 -4
  60. package/dist/{task-queue-_mf28dSu.js → task-queue-Dt1_ZoI_.js} +3 -3
  61. package/dist/task-runner-DyKqAPpm.js +269 -0
  62. package/dist/{thread-state-BbbPh0Sn.js → thread-state-BHBLMvgb.js} +5 -5
  63. package/dist/{turn-failure-response-BKKmnMB5.js → turn-failure-response-DWXy_0oJ.js} +9 -9
  64. package/dist/{turn-lifecycle-oP0kVeGz.js → turn-lifecycle-AiXkVqCB.js} +1 -1
  65. package/dist/{tasks-SB9z-VTU.js → turn-session-routing-B9KLBhaT.js} +939 -120
  66. package/dist/{validation--JsAGLwc.js → validation-BW60Vtx_.js} +1 -1
  67. package/dist/{version-jHwJp4hC.js → version-CivPYmkH.js} +1 -1
  68. package/dist/version.js +1 -1
  69. package/dist/{viewer-CpXTGlep.js → viewer-BTgAfdUg.js} +216 -27
  70. package/dist/{work-3N-GHY9m.js → work-Bjm52fHo.js} +17 -36
  71. package/dist/{worker-CCdbcItd.js → worker-CMbgtHLu.js} +15 -7
  72. package/migrations/0069_system_identity.sql +25 -0
  73. package/migrations/0070_review_item_foreign_keys.sql +5 -0
  74. package/migrations/AGENTS.md +17 -0
  75. package/migrations/meta/0069_snapshot.json +3979 -0
  76. package/migrations/meta/0070_snapshot.json +3997 -0
  77. package/migrations/meta/_journal.json +15 -1
  78. package/package.json +5 -4
  79. package/dist/db-BU4LQD8s.d.ts +0 -6
  80. package/dist/history-BgI33Kie.d.ts +0 -78
  81. package/dist/reporting-window-DVCD_10C.js +0 -227
  82. package/dist/task-runner-DMI2prDO.js +0 -1073
  83. package/migrations/README.md +0 -30
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-nwUcYXjP.js";
2
- import { n as pluginCatalogRuntime } from "./catalog-runtime-BD9GSpc6.js";
2
+ import { n as pluginCatalogRuntime } from "./catalog-runtime-Bdym_wKy.js";
3
3
  import { isDeepStrictEqual } from "node:util";
4
4
  // src/chat/plugins/validation.ts
5
5
  var validation_exports = /* @__PURE__ */ __exportAll({
@@ -1,5 +1,5 @@
1
1
  // package.json
2
- var version = "0.268.0";
2
+ var version = "0.270.0";
3
3
  // src/version.ts
4
4
  /** Installed Junior package version, or `unknown` when package metadata is unavailable. */
5
5
  const JUNIOR_VERSION = typeof version === "string" && version.trim() ? version : "unknown";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export { t as JUNIOR_VERSION } from "./version-jHwJp4hC.js";
1
+ export { t as JUNIOR_VERSION } from "./version-CivPYmkH.js";
@@ -1,12 +1,13 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-nwUcYXjP.js";
2
- import { i as getChatConfig } from "./config-DrTeF80T.js";
3
- import { L as GOOGLE_IDENTITY_PROVIDER, z as normalizeIdentityEmail } from "./logging-DLSpkSSr.js";
2
+ import { i as getChatConfig } from "./config-Dm3pWRqY.js";
3
+ import { L as GOOGLE_IDENTITY_PROVIDER, h as logWarn, z as normalizeIdentityEmail } from "./logging-C6EdT6PP.js";
4
4
  import { a as isSlackUserId, d as parseSlackChannelReferenceId, f as parseSlackTeamId, s as parseActorUserId } from "./actor-CP9K2Idu.js";
5
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-4vhvsMZJ.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";
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-BB6eJkD4.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-U6Tuz6Mi.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. */
@@ -381,7 +382,7 @@ async function upsertIdentityRecord(executor, identity, linkedUserId, nowMs) {
381
382
  email: email ?? null,
382
383
  emailNormalized: emailNormalized ?? null,
383
384
  emailVerified: Boolean(identity.emailVerified && emailNormalized),
384
- avatarUrl: null,
385
+ avatarUrl: identity.avatarUrl ?? null,
385
386
  metadata: identity.metadata ?? null,
386
387
  createdAt: dateFromMs$1(nowMs),
387
388
  updatedAt: dateFromMs$1(nowMs)
@@ -399,7 +400,7 @@ async function upsertIdentityRecord(executor, identity, linkedUserId, nowMs) {
399
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`,
400
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`,
401
402
  emailVerified: sql`${juniorIdentities.emailVerified} OR excluded.email_verified`,
402
- avatarUrl: sql`coalesce(${juniorIdentities.avatarUrl}, excluded.avatar_url)`,
403
+ avatarUrl: identity.avatarUrl === void 0 ? sql`${juniorIdentities.avatarUrl}` : sql`excluded.avatar_url`,
403
404
  metadata: sql`coalesce(${juniorIdentities.metadata}, excluded.metadata_json)`,
404
405
  updatedAt: sql`excluded.updated_at`
405
406
  },
@@ -475,7 +476,8 @@ async function recordGoogleSignIn(executor, account, nowMs = Date.now()) {
475
476
  providerSubjectId: account.subject,
476
477
  email: account.email,
477
478
  emailVerified: true,
478
- displayName: account.displayName
479
+ displayName: account.displayName,
480
+ avatarUrl: account.avatarUrl ?? null
479
481
  }, nowMs);
480
482
  await executor.db().update(juniorIdentities).set({ signedInAt: dateFromMs$1(nowMs) }).where(eq(juniorIdentities.id, stored.id));
481
483
  return stored;
@@ -488,20 +490,22 @@ async function recordGoogleSignIn(executor, account, nowMs = Date.now()) {
488
490
  * module, so they agree on one Identity per person. The key for each Actor
489
491
  * stays private here, so it can change without changing callers.
490
492
  */
491
- /** The one Identity for work that Junior does on its own. */
492
- const SYSTEM_IDENTITY = {
493
- kind: "system",
494
- provider: "junior",
495
- providerSubjectId: "system",
496
- displayName: "System"
497
- };
498
493
  /**
499
- * Return the Identity key for one Actor.
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.
500
501
  *
501
- * Every SystemActor uses the one system Identity. `SystemActor.name` is
502
- * provenance, not part of the key. A LocalActor uses the system Identity too,
503
- * because the local runner has no per-person user id.
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
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. */
505
509
  function identityKey(actor) {
506
510
  switch (actor.platform) {
507
511
  case "slack": return {
@@ -530,20 +534,28 @@ function identityKey(actor) {
530
534
  metadata: { platform: "web" }
531
535
  };
532
536
  }
533
- case "local":
534
- case "system": return SYSTEM_IDENTITY;
535
537
  }
536
538
  }
537
539
  /**
538
- * Save the Identity for one Actor and return its id.
540
+ * Return the Identity id for one Actor. Save a Slack or web Identity first.
539
541
  * Return undefined only for a web Actor without a verified email.
540
542
  */
541
543
  async function resolveActorIdentityId(executor, actor, nowMs = Date.now()) {
544
+ if (usesSystemIdentity(actor)) return SYSTEM_IDENTITY_ID;
542
545
  const key = identityKey(actor);
543
546
  return key ? (await upsertIdentity(executor, key, nowMs)).id : void 0;
544
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
+ }
545
556
  /** Find the stored Identity id for one Actor without writing. */
546
557
  async function findActorIdentityId(db, actor) {
558
+ if (usesSystemIdentity(actor)) return SYSTEM_IDENTITY_ID;
547
559
  const key = identityKey(actor);
548
560
  if (!key) return;
549
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;
@@ -1068,7 +1080,7 @@ var SqlStore = class {
1068
1080
  sessionSource: sql`coalesce(${juniorConversations.sessionSource}, excluded.source_json)`,
1069
1081
  location: sql`coalesce(excluded.location_json, ${juniorConversations.location})`,
1070
1082
  destinationId: sql`coalesce(excluded.destination_id, ${juniorConversations.destinationId})`,
1071
- actorIdentityId: sql`coalesce(excluded.actor_identity_id, ${juniorConversations.actorIdentityId})`,
1083
+ actorIdentityId: sql`coalesce(${juniorConversations.actorIdentityId}, excluded.actor_identity_id)`,
1072
1084
  creatorIdentityId: sql`coalesce(excluded.creator_identity_id, ${juniorConversations.creatorIdentityId})`,
1073
1085
  credentialSubjectIdentityId: sql`coalesce(excluded.credential_subject_identity_id, ${juniorConversations.credentialSubjectIdentityId})`,
1074
1086
  channelName: sql`coalesce(excluded.channel_name, ${juniorConversations.channelName})`,
@@ -1223,14 +1235,15 @@ async function findJuniorIdentityIdByDashboardAuthorId(executor, dashboardAuthor
1223
1235
  }
1224
1236
  /**
1225
1237
  * Resolve the durable actor identity for one event payload.
1226
- * Human user messages get an identity when author data is enough; other events
1227
- * 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.
1228
1240
  */
1229
1241
  async function resolveEventActorIdentityId(executor, args) {
1230
1242
  const data = args.data;
1231
1243
  if (data.type !== "message" && data.type !== "message_updated") return;
1232
1244
  if (typeof data.authorIdentityId === "string" && data.authorIdentityId) return data.authorIdentityId;
1233
1245
  if (data.role !== "user") return;
1246
+ if (data.type === "message") logWarn("conversation.message.author_identity.missing", { "app.message.id": data.messageId });
1234
1247
  const author = readAuthor(data.meta);
1235
1248
  if (author.isBot === true) return;
1236
1249
  const userId = parseActorUserId(author.userId);
@@ -1653,6 +1666,178 @@ async function closeDb() {
1653
1666
  current = void 0;
1654
1667
  await previous?.db.close();
1655
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
+ const MAX_AVATAR_REDIRECTS = 3;
1718
+ /**
1719
+ * Return the URL when it is HTTPS on a photo host of the provider or a
1720
+ * subdomain. Junior fetches only these URLs.
1721
+ */
1722
+ function parseAvatarUrl(value, provider) {
1723
+ const hosts = AVATAR_HOSTS[provider];
1724
+ if (!hosts || !value || !URL.canParse(value)) return void 0;
1725
+ const url = new URL(value);
1726
+ if (url.protocol !== "https:" || url.username || url.password) return;
1727
+ const host = url.hostname.toLowerCase();
1728
+ return hosts.some((allowed) => host === allowed || host.endsWith(`.${allowed}`)) ? url.href : void 0;
1729
+ }
1730
+ function avatarPath(identityId) {
1731
+ return `/api/people/avatars/${encodeURIComponent(identityId)}`;
1732
+ }
1733
+ /**
1734
+ * Read the photo path of each user: the Slack photo, then the Google photo.
1735
+ * Users without a photo are not in the map.
1736
+ */
1737
+ async function readUserAvatarUrls(db, userIds) {
1738
+ if (userIds.length === 0) return /* @__PURE__ */ new Map();
1739
+ const rows = await db.select({
1740
+ identityId: juniorIdentities.id,
1741
+ provider: juniorIdentities.provider,
1742
+ updatedAt: juniorIdentities.updatedAt,
1743
+ userId: juniorIdentities.userId
1744
+ }).from(juniorIdentities).where(and(inArray(juniorIdentities.userId, [...new Set(userIds)]), eq(juniorIdentities.kind, "user"), inArray(juniorIdentities.provider, AVATAR_PROVIDERS), isNotNull(juniorIdentities.avatarUrl)));
1745
+ const rank = (row) => AVATAR_PROVIDERS.indexOf(row.provider);
1746
+ rows.sort((left, right) => rank(left) - rank(right) || right.updatedAt.getTime() - left.updatedAt.getTime());
1747
+ const avatars = /* @__PURE__ */ new Map();
1748
+ for (const row of rows) if (row.userId && !avatars.has(row.userId)) avatars.set(row.userId, avatarPath(row.identityId));
1749
+ return avatars;
1750
+ }
1751
+ /**
1752
+ * Return the photo path for one Identity row. A linked Identity uses the
1753
+ * photo of its user. An Identity without a user uses its own photo.
1754
+ */
1755
+ function identityAvatarUrl(row, userAvatarUrls) {
1756
+ if (row.userId) return userAvatarUrls.get(row.userId);
1757
+ return row.avatarUrl ? avatarPath(row.identityId) : void 0;
1758
+ }
1759
+ async function readBoundedBody(response, maxBytes) {
1760
+ if (!response.body) return /* @__PURE__ */ new Uint8Array();
1761
+ const reader = response.body.getReader();
1762
+ const chunks = [];
1763
+ let total = 0;
1764
+ while (true) {
1765
+ const { done, value } = await reader.read();
1766
+ if (done) return Buffer.concat(chunks);
1767
+ total += value.byteLength;
1768
+ if (total > maxBytes) {
1769
+ await reader.cancel();
1770
+ return;
1771
+ }
1772
+ chunks.push(value);
1773
+ }
1774
+ }
1775
+ function avatarFetchFailed(host, attributes) {
1776
+ logWarn("avatar.fetch.failed", {
1777
+ "server.address": host,
1778
+ ...attributes
1779
+ });
1780
+ return null;
1781
+ }
1782
+ async function fetchAvatarResponse(url, provider, signal) {
1783
+ let current = url;
1784
+ for (let redirects = 0;; redirects += 1) {
1785
+ const response = await fetch(current, {
1786
+ redirect: "manual",
1787
+ signal
1788
+ });
1789
+ const location = response.headers.get("location");
1790
+ if (response.status < 300 || response.status >= 400 || !location) return response;
1791
+ await response.body?.cancel();
1792
+ const next = URL.canParse(location, current) ? parseAvatarUrl(new URL(location, current).href, provider) : void 0;
1793
+ if (!next) return "redirect_off_host";
1794
+ if (redirects >= MAX_AVATAR_REDIRECTS) return "too_many_redirects";
1795
+ current = next;
1796
+ }
1797
+ }
1798
+ async function fetchAvatarImage(url, provider) {
1799
+ const host = new URL(url).hostname;
1800
+ let data;
1801
+ try {
1802
+ const response = await fetchAvatarResponse(url, provider, AbortSignal.timeout(AVATAR_FETCH_TIMEOUT_MS));
1803
+ if (typeof response === "string") return avatarFetchFailed(host, { "app.reason": response });
1804
+ if (!response.ok) {
1805
+ await response.body?.cancel();
1806
+ return avatarFetchFailed(host, { "http.response.status_code": response.status });
1807
+ }
1808
+ data = await readBoundedBody(response, MAX_AVATAR_BYTES);
1809
+ } catch (error) {
1810
+ return avatarFetchFailed(host, { "exception.message": error instanceof Error ? error.message : String(error) });
1811
+ }
1812
+ if (!data) return avatarFetchFailed(host, { "app.reason": "too_large" });
1813
+ const contentType = detectImageMediaType(data);
1814
+ if (!contentType) return avatarFetchFailed(host, { "app.reason": "not_an_image" });
1815
+ return {
1816
+ contentType,
1817
+ data: Buffer.from(data).toString("base64")
1818
+ };
1819
+ }
1820
+ /**
1821
+ * Read the saved photo of one Identity. Junior fetches it from the provider
1822
+ * once and keeps it in the state cache for a day. Return undefined when the
1823
+ * Identity has no photo or the provider does not return one.
1824
+ */
1825
+ async function readAvatarImage(db, state, identityId) {
1826
+ const [row] = await db.select({
1827
+ avatarUrl: juniorIdentities.avatarUrl,
1828
+ provider: juniorIdentities.provider
1829
+ }).from(juniorIdentities).where(and(eq(juniorIdentities.id, identityId), eq(juniorIdentities.kind, "user"))).limit(1);
1830
+ const url = row ? parseAvatarUrl(row.avatarUrl, row.provider) : void 0;
1831
+ if (!row || !url) return void 0;
1832
+ const key = `avatar:${createHash("sha256").update(url).digest("hex")}`;
1833
+ return await createCache(state, "core").getOrLoad(key, async () => {
1834
+ const loaded = await fetchAvatarImage(url, row.provider);
1835
+ return {
1836
+ value: loaded,
1837
+ ttlMs: loaded ? AVATAR_CACHE_TTL_MS : AVATAR_FAILURE_TTL_MS
1838
+ };
1839
+ }) ?? void 0;
1840
+ }
1656
1841
  // src/chat/plugins/viewer.ts
1657
1842
  function identityFromRow(row) {
1658
1843
  return identitySchema.parse({
@@ -1712,6 +1897,10 @@ async function updateViewerDisplayNameFromSql(db, userId, displayName) {
1712
1897
  }).where(eq(juniorUsers.id, userId)).returning())[0];
1713
1898
  return userRow ? await readUserById(db, userRow) : void 0;
1714
1899
  }
1900
+ /** Read the photo path of one canonical user: Slack, then Google. */
1901
+ async function readViewerAvatarUrl(userId) {
1902
+ return (await readUserAvatarUrls(getDb(), [userId])).get(userId);
1903
+ }
1715
1904
  /** Update one canonical user's display name. */
1716
1905
  async function updateViewerDisplayName(userId, displayName) {
1717
1906
  return await updateViewerDisplayNameFromSql(getDb(), userId, displayName);
@@ -1761,4 +1950,4 @@ async function readIdentityByIdFromSql(db, identityId) {
1761
1950
  async function readIdentityById(identityId) {
1762
1951
  return await readIdentityByIdFromSql(getDb(), identityId);
1763
1952
  }
1764
- export { upsertLinkedIdentity as A, parseDestination as B, resolveActorIdentityId as C, readUserTimezone as D, findUserByVerifiedEmails as E, agentTurnTotalTokens as F, sameDestination as H, agentTurnUsageSchema as I, hasAgentTurnUsage as L, parseSessionSource as M, addAgentTurnUsage as N, recordGoogleSignIn as O, agentTurnCostUsd as P, resolveRootConversationId as R, startLocationThread as S, deleteProviderIdentityForUser as T, requireSlackDestination as V, withConversationEventLock as _, readIdentityById as a, withConversationMutationLock as b, updateViewerDisplayName as c, getConversationEventStore as d, getConversationMessageSearchStore as f, createSqlConversationEventStore as g, getSqlExecutor as h, readActorIdentity as i, bindProviderConversation as j, saveUserTimezone as k, closeDb as l, getDb as m, findUserByEmailFromSql as n, resolveViewerUser as o, getConversationStore as p, listAdminUsersFromSql as r, setUserAdminFromSql as s, findUserByEmail as t, db_exports as u, loadConversationSlackTenantIds as v, deleteProviderIdentityForSlackUser as w, locationFromRow as x, createSqlStore as y, createSlackDestination as z };
1953
+ 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 };
@@ -1,15 +1,14 @@
1
- import { t as __exportAll } from "./rolldown-runtime-nwUcYXjP.js";
2
- import { Fn as isTurnExecutionLimitExceededError, Gt as saveTurnCheckpoint, It as AuthorizationFlowDisabledError, Jt as failTurnRecord, Kt as abandonTurnRecord, Mt as PluginCredentialFailureError, Yt as getTurnRecord, et as openConversationProjection, zn as getTerminalAssistantMessages } from "./tasks-SB9z-VTU.js";
3
- import { n as botConfig } from "./config-DrTeF80T.js";
4
- import { d as getConversationEventStore, p as getConversationStore } from "./viewer-CpXTGlep.js";
5
- import { m as sameActorIdentity, u as agentInstructionProvenance } from "./history-C84mlAwO.js";
6
- import { Dt as recordSubagentEnded, Ft as buildSteeringPiMessage, Ht as readBlockedReport, It as withoutSavedSteeringMessages, Ot as recordSubagentStarted, Tt as stopChildAgentsOf } from "./agent-runner-CAYfFmU7.js";
7
- import { E as markAgentInvocationSteered, H as TurnInputCommitLostError, K as isTurnInputCommitLostError, L as agentTypeInstructions, O as reportReplacedAgentInvocation, R as agentTypeStartsWithParentHistory, S as markAgentInvocationMailboxAppended, T as markAgentInvocationRunning, W as getConversationTurnBoundaryError, _ as getLatestAgentInvocationForConversation, c as getAgentInvocation, f as getAgentInvocationMessageId, m as getAgentInvocationTurnId, n as completeAgentInvocation, r as createAgentInvocation, s as getActiveAgentInvocationForConversation, w as markAgentInvocationPaused, y as isTerminalAgentInvocation } from "./store-C5PN02QO.js";
8
- import { r as getAssistantReplyText } from "./interruption-marker-CkncDT8d.js";
9
- import { C as maybePostAutomatedTurnLimitNotice, D as recordFinishedTurnForAutomatedLimit, O as resetAutomatedTurnLimit } from "./session-stop-Dam1EI7B.js";
10
- import { r as appendAndEnqueueInboundMessage } from "./store-zPEoJPBB.js";
11
- import { t as ConversationTurnLifecycleService } from "./turn-lifecycle-oP0kVeGz.js";
12
- import { a as persistThreadStateById, n as getPersistedSandboxState, r as getPersistedThreadState } from "./thread-state-BbbPh0Sn.js";
1
+ import { Dn as failTurnRecord, Nt as openConversationProjection, On as getTurnRecord, Tn as abandonTurnRecord, dn as PluginCredentialFailureError, hn as AuthorizationFlowDisabledError, mr as isTurnExecutionLimitExceededError, wn as saveTurnCheckpoint, yr as getTerminalAssistantMessages } from "./turn-session-routing-B9KLBhaT.js";
2
+ import { n as botConfig } from "./config-Dm3pWRqY.js";
3
+ import { j as SYSTEM_IDENTITY_ID, x as getConversationStore, y as getConversationEventStore } from "./viewer-BTgAfdUg.js";
4
+ import { _ as sameActorIdentity, p as agentInstructionProvenance } from "./media-U6Tuz6Mi.js";
5
+ import { Et as withoutSavedSteeringMessages, Mt as readBlockedReport, Tt as buildSteeringPiMessage, _t as recordSubagentEnded, ht as stopChildAgentsOf, vt as recordSubagentStarted } from "./agent-runner-MSQSMFkq.js";
6
+ import { E as markAgentInvocationSteered, H as TurnInputCommitLostError, K as isTurnInputCommitLostError, L as agentTypeInstructions, O as reportReplacedAgentInvocation, R as agentTypeStartsWithParentHistory, S as markAgentInvocationMailboxAppended, T as markAgentInvocationRunning, W as getConversationTurnBoundaryError, _ as getLatestAgentInvocationForConversation, c as getAgentInvocation, f as getAgentInvocationMessageId, m as getAgentInvocationTurnId, n as completeAgentInvocation, r as createAgentInvocation, s as getActiveAgentInvocationForConversation, w as markAgentInvocationPaused, y as isTerminalAgentInvocation } from "./store-Byarin7b.js";
7
+ import { r as getAssistantReplyText } from "./interruption-marker-CdblrIQK.js";
8
+ import { C as maybePostAutomatedTurnLimitNotice, D as recordFinishedTurnForAutomatedLimit, O as resetAutomatedTurnLimit } from "./session-stop-MZRgYqYH.js";
9
+ import { r as appendAndEnqueueInboundMessage } from "./store-BeWGgaMA.js";
10
+ import { t as ConversationTurnLifecycleService } from "./turn-lifecycle-AiXkVqCB.js";
11
+ import { a as persistThreadStateById, n as getPersistedSandboxState, r as getPersistedThreadState } from "./thread-state-BHBLMvgb.js";
13
12
  import { isDeepStrictEqual } from "node:util";
14
13
  import { z } from "zod";
15
14
  // src/chat/runtime/turn-execution.ts
@@ -86,16 +85,6 @@ async function loadForkHistory(invocation) {
86
85
  return forkHistoryFrom((await openConversationProjection({ conversationId: invocation.parentConversationId })).messages, invocation.parentToolCallId);
87
86
  }
88
87
  // src/chat/agent-invocations/work.ts
89
- var work_exports = /* @__PURE__ */ __exportAll({
90
- agentInvocationInputText: () => agentInvocationInputText,
91
- agentInvocationRunPolicy: () => agentInvocationRunPolicy,
92
- buildAgentInvocationInboundMessage: () => buildAgentInvocationInboundMessage,
93
- createAgentInvocationWorker: () => createAgentInvocationWorker,
94
- createAndEnqueueAgentInvocation: () => createAndEnqueueAgentInvocation,
95
- enqueueAgentInvocation: () => enqueueAgentInvocation,
96
- resolveAgentInvocationId: () => resolveAgentInvocationId,
97
- routeAgentInvocationWork: () => routeAgentInvocationWork
98
- });
99
88
  const agentInvocationMailboxMetadataSchema = z.object({
100
89
  agentInvocationId: z.string().min(1),
101
90
  kind: z.literal("agent_invocation")
@@ -121,6 +110,7 @@ function buildAgentInvocationInboundMessage(invocation, nowMs = Date.now()) {
121
110
  inboundMessageId: getAgentInvocationMessageId(invocation.invocationId),
122
111
  input: {
123
112
  authorId: invocation.invocationId,
113
+ authorIdentityId: SYSTEM_IDENTITY_ID,
124
114
  text: "[agent invocation]",
125
115
  metadata: {
126
116
  agentInvocationId: invocation.invocationId,
@@ -188,8 +178,7 @@ async function resolveAgentInvocationId(context) {
188
178
  if (context.attempt.messages.length > 0) return;
189
179
  const active = await getActiveAgentInvocationForConversation(context.conversationId);
190
180
  if (active) return active.invocationId;
191
- const latest = await getLatestAgentInvocationForConversation(context.conversationId);
192
- return latest?.status === "stopped" ? latest.invocationId : void 0;
181
+ return (await getLatestAgentInvocationForConversation(context.conversationId))?.invocationId;
193
182
  }
194
183
  /**
195
184
  * Give queued parent messages to the running agent invocation of a child as
@@ -267,9 +256,9 @@ function agentInvocationTurnEnd(terminal) {
267
256
  /** Project the terminal invocation into the idempotent turn lifecycle. */
268
257
  async function persistTerminalLifecycle(terminal) {
269
258
  const turnId = getAgentInvocationTurnId(terminal.invocationId);
270
- if (terminal.status === "stopped") await abandonTurnRecord({
259
+ if (terminal.status !== "completed") await abandonTurnRecord({
271
260
  conversationId: terminal.childConversationId,
272
- errorMessage: "Turn cancelled",
261
+ errorMessage: terminal.status === "stopped" ? "Turn cancelled" : terminal.errorMessage,
273
262
  turnId
274
263
  });
275
264
  await finishTurn(terminal.childConversationId, turnId, agentInvocationTurnEnd(terminal));
@@ -506,8 +495,7 @@ function createAgentInvocationWorker(agentRunner, options = {}) {
506
495
  turnId
507
496
  }));
508
497
  } catch (error) {
509
- if (!(error instanceof AgentRunError)) throw error;
510
- const runError = error.cause;
498
+ const runError = error instanceof AgentRunError ? error.cause : error;
511
499
  if (isInvocationInputCommitLost(runError)) return { status: "lost_lease" };
512
500
  if (stopSignal?.aborted) return await finishStopped();
513
501
  const blocking = blockingInvocationError(runError);
@@ -553,11 +541,4 @@ function createAgentInvocationWorker(agentRunner, options = {}) {
553
541
  return { status: "completed" };
554
542
  };
555
543
  }
556
- /** Route invocation-backed work before falling through to existing consumers. */
557
- function routeAgentInvocationWork(options) {
558
- return async (context) => {
559
- const invocationId = await resolveAgentInvocationId(context);
560
- return invocationId ? await options.invocationWorker(context, invocationId) : await options.fallbackWorker(context);
561
- };
562
- }
563
- export { work_exports as a, resolveAgentInvocationId as i, createAndEnqueueAgentInvocation as n, AgentRunError as o, enqueueAgentInvocation as r, executeTurn as s, createAgentInvocationWorker as t };
544
+ export { AgentRunError as a, resolveAgentInvocationId as i, createAndEnqueueAgentInvocation as n, executeTurn as o, enqueueAgentInvocation as r, createAgentInvocationWorker as t };
@@ -1,10 +1,10 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-nwUcYXjP.js";
2
- import { _n as isInvalidConversationRecordError, gn as isFinalAttempt, mn as hasConversationStop } from "./tasks-SB9z-VTU.js";
3
- import { y as isProviderRetryError } from "./client-C1yDYjoS.js";
4
- import { i as getChatConfig } from "./config-DrTeF80T.js";
5
- import { T as withLogContext, b as setSpanAttributes, h as logWarn, m as logInfo, p as logException } from "./logging-DLSpkSSr.js";
6
- import { f as getTurnRequestDeadline, m as ConversationQueueMessageRejectedError } from "./job-queue-In9QKz-I.js";
7
- import { C as requestAnotherSlice, T as startConversationWork, b as recordConversationRetry, d as countPendingConversationMessages, f as deadLetterAttempt, g as getConversationWorkState, i as beginConversationResume, l as completeConversationStop, m as ensureConversationWake, o as checkInConversationWork, p as drainConversationMailbox, s as clearConsumedConversationWake, t as ackMessages, u as completeConversationWork, x as releaseConversationWork, y as recordAttemptFailure } from "./store-zPEoJPBB.js";
2
+ import { Xn as isInvalidConversationRecordError, Yn as isFinalAttempt, qn as hasConversationStop } from "./turn-session-routing-B9KLBhaT.js";
3
+ import { y as isProviderRetryError } from "./client-B2MjENki.js";
4
+ import { i as getChatConfig } from "./config-Dm3pWRqY.js";
5
+ import { T as withLogContext, b as setSpanAttributes, h as logWarn, m as logInfo, p as logException } from "./logging-C6EdT6PP.js";
6
+ import { f as getTurnRequestDeadline, m as ConversationQueueMessageRejectedError } from "./job-queue-DiaXoSDg.js";
7
+ import { C as requestAnotherSlice, T as startConversationWork, b as recordConversationRetry, d as countPendingConversationMessages, f as deadLetterAttempt, g as getConversationWorkState, i as beginConversationResume, l as completeConversationStop, m as ensureConversationWake, o as checkInConversationWork, p as drainConversationMailbox, s as clearConsumedConversationWake, t as ackMessages, u as completeConversationWork, x as releaseConversationWork, y as recordAttemptFailure } from "./store-BeWGgaMA.js";
8
8
  // src/chat/task-execution/attempt-selection.ts
9
9
  function selectContiguousTurnBatch(messages) {
10
10
  const first = messages[0];
@@ -116,6 +116,14 @@ async function recordFailedDeliveryAttempt(args) {
116
116
  function isTerminalFailure(failure) {
117
117
  return failure.status === "recorded" && failure.deadLetteredMessages.length > 0 && failure.pendingCount === 0;
118
118
  }
119
+ /**
120
+ * Return whether a failure of this attempt can end the conversation work. The
121
+ * retry limit applies to every attempt, also one with no messages, such as a
122
+ * resume. A message also ends it at its own delivery limit.
123
+ */
124
+ function isLastConversationAttempt(work, messages) {
125
+ return (work.execution.retryCount ?? 0) + 1 >= 5 || messages.some((message) => isFinalAttempt(message));
126
+ }
119
127
  function logRetryExhausted(args) {
120
128
  logException(args.error, "conversation.work.retry.exhausted", {
121
129
  "app.run.id": args.work.execution.runId ?? "unknown",
@@ -403,7 +411,7 @@ async function processConversationWorkInContext(message, options) {
403
411
  conversationId,
404
412
  destination,
405
413
  drain,
406
- isFinalAttempt: attemptMessages.some((message) => isFinalAttempt(message)),
414
+ isFinalAttempt: isLastConversationAttempt(leasedWork, attemptMessages),
407
415
  messages: attemptMessages
408
416
  },
409
417
  conversationId,
@@ -0,0 +1,25 @@
1
+ -- Create the one system Identity with the fixed id `system`. Code writes this
2
+ -- id without a read, and Message and Conversation rows reference it through
3
+ -- foreign keys, so the row must exist. ON CONFLICT DO NOTHING makes a second
4
+ -- run do nothing.
5
+ INSERT INTO "junior_identities" (
6
+ "id",
7
+ "kind",
8
+ "provider",
9
+ "provider_tenant_id",
10
+ "provider_subject_id",
11
+ "display_name",
12
+ "created_at",
13
+ "updated_at"
14
+ )
15
+ VALUES (
16
+ 'system',
17
+ 'system',
18
+ 'junior',
19
+ '',
20
+ 'system',
21
+ 'System',
22
+ CURRENT_TIMESTAMP,
23
+ CURRENT_TIMESTAMP
24
+ )
25
+ ON CONFLICT DO NOTHING;
@@ -0,0 +1,5 @@
1
+ ALTER TABLE "junior_review_items" DROP CONSTRAINT "junior_review_items_closed_by_user_id_junior_users_id_fk";
2
+ --> statement-breakpoint
3
+ ALTER TABLE "junior_review_item_slack_cards" ADD CONSTRAINT "junior_review_item_slack_cards_conversation_id_junior_conversations_conversation_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."junior_conversations"("conversation_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
4
+ ALTER TABLE "junior_review_items" ADD CONSTRAINT "junior_review_items_added_by_conversation_id_junior_conversations_conversation_id_fk" FOREIGN KEY ("added_by_conversation_id") REFERENCES "public"."junior_conversations"("conversation_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
5
+ ALTER TABLE "junior_review_items" ADD CONSTRAINT "junior_review_items_closed_by_user_id_junior_users_id_fk" FOREIGN KEY ("closed_by_user_id") REFERENCES "public"."junior_users"("id") ON DELETE set null ON UPDATE no action;
@@ -0,0 +1,17 @@
1
+ # SQL Migrations
2
+
3
+ `src/db/schema.ts` is the Drizzle schema entry point. This directory is the
4
+ append-only history that brings an existing database to that schema.
5
+ `src/chat/conversations/sql/migrations.ts` owns loading, locking, and the
6
+ bridge-version guard.
7
+
8
+ ## Rules
9
+
10
+ - Never edit, rename, reorder, or delete an applied migration or its
11
+ metadata. Add a new migration to correct it.
12
+ - Change the table under `src/db/schema/`, then run
13
+ `pnpm --filter @sentry/junior db:generate --name <name>`. Commit the SQL,
14
+ `meta/_journal.json`, and the snapshot together.
15
+ - Put fixed row transformations in the migration that needs them.
16
+ - A schema without the core Drizzle journal must first upgrade through
17
+ `@sentry/junior@0.107.1`. Do not remove that guard.