@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,26 +1,27 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-nwUcYXjP.js";
2
- import { C as toOptionalTrimmed, i as completeObject, o as embedTexts } from "./client-C1yDYjoS.js";
3
- import { i as getChatConfig, n as botConfig, o as getSlackBotToken, v as normalizeSlackEmojiName } from "./config-DrTeF80T.js";
4
- import { B as isRecord$2, E as withSpan, a as extractGenAiUsageSummary, b as setSpanAttributes, c as getLogContextAttributes, d as logAudit, h as logWarn, m as logInfo, p as logException, y as setSentryUser } from "./logging-DLSpkSSr.js";
2
+ import { S as toOptionalTrimmed, i as completeObject, o as embedTexts, t as GEN_AI_PROVIDER_NAME } from "./client-B2MjENki.js";
3
+ import { i as getChatConfig, n as botConfig, o as getSlackBotToken, v as normalizeSlackEmojiName } from "./config-Dm3pWRqY.js";
4
+ import { B as isRecord$2, E as withSpan, a as extractGenAiUsageSummary, b as setSpanAttributes, c as getLogContextAttributes, d as logAudit, h as logWarn, m as logInfo, p as logException, y as setSentryUser } from "./logging-C6EdT6PP.js";
5
5
  import { a as getClient$1, n as captureMessage, r as continueTrace } from "./sentry-MDkYAVPV.js";
6
- import { t as canExposeConversationPayload } from "./conversation-privacy-CxlOCiUo.js";
7
- import { r as defaultModelId } from "./model-profile-Bg8EoCKD.js";
6
+ import { t as canExposeConversationPayload } from "./conversation-privacy-C1pJ89rs.js";
7
+ import { i as defaultModelId } from "./model-profile-B998sjVd.js";
8
8
  import { d as parseSlackChannelReferenceId, f as parseSlackTeamId, i as isActorUserId, m as parseSlackMessageTs, p as parseSlackUserId, s as parseActorUserId } from "./actor-CP9K2Idu.js";
9
- import { B as parseDestination, D as readUserTimezone, L as hasAgentTurnUsage, a as readIdentityById, d as getConversationEventStore, h as getSqlExecutor, i as readActorIdentity, k as saveUserTimezone, m as getDb, o as resolveViewerUser, y as createSqlStore } from "./viewer-CpXTGlep.js";
9
+ import { C as getSqlExecutor, D as createSqlStore, L as readUserTimezone, S as getDb, X as parseDestination, g as createCache, i as readActorIdentity, l as resolveViewerUser, n as findUserByEmailFromSql, o as readIdentityById, p as parseAvatarUrl, q as hasAgentTurnUsage, s as readIdentityByIdFromSql, y as getConversationEventStore, z as saveUserTimezone } from "./viewer-BTgAfdUg.js";
10
10
  import { c as juniorIdentities, d as juniorDestinations, l as juniorUsers, s as juniorConversations } from "./agent-invocations-C1RNOCs8.js";
11
- import { C as juniorCodeRepositories, S as juniorCodeChanges, g as juniorConversationBriefs, i as juniorWorkspaces, m as juniorConversationParticipants, n as juniorSnapshots, r as juniorWorkspaceRepos, w as juniorAttachments, x as juniorConversationAnnotations, y as juniorConversationEvents } from "./executor-4vhvsMZJ.js";
12
- import { o as decodeStoredConversationEvent } from "./history-C84mlAwO.js";
13
- import { _ as CredentialUnavailableError, a as resolveAuthTokenPlaceholder, h as credentialSubjectSchema, m as credentialLogContext, o as resolvePluginCommandEnv, p as credentialContextSchema } from "./registry-BgecKva0.js";
14
- import { n as pluginCatalogRuntime } from "./catalog-runtime-BD9GSpc6.js";
15
- import { c as withLock, i as getStateAdapter, o as acquireActiveLock, r as getDefaultRedisStateAdapterFor } from "./adapter-04AcD2SO.js";
11
+ import { C as juniorCodeRepositories, S as juniorCodeChanges, a as juniorReviewItemAssignees, c as juniorReviewItems, d as juniorAutomations, g as juniorConversationBriefs, i as juniorWorkspaces, m as juniorConversationParticipants, n as juniorSnapshots, o as juniorReviewItemConversations, r as juniorWorkspaceRepos, w as juniorAttachments, x as juniorConversationAnnotations, y as juniorConversationEvents } from "./executor-BB6eJkD4.js";
12
+ import { l as decodeStoredConversationEvent } from "./media-U6Tuz6Mi.js";
13
+ import { _ as CredentialUnavailableError, a as resolveAuthTokenPlaceholder, h as credentialSubjectSchema, m as credentialLogContext, o as resolvePluginCommandEnv, p as credentialContextSchema } from "./registry-CndmkP2P.js";
14
+ import { n as pluginCatalogRuntime } from "./catalog-runtime-Bdym_wKy.js";
15
+ import { r as acquireActiveLock } from "./locks-D-0h111u.js";
16
+ import { n as getDefaultRedisStateAdapterFor, r as getStateAdapter } from "./adapter-BQNTKVTF.js";
16
17
  import { i as isValidTimeZone } from "./cadence-R7Em4RjE.js";
17
- import { C as conversationReportEventSchema, Q as parseBriefInput, W as readMessageAttachments, Z as conversationBriefSchema, w as conversationReportMessageEventSchema, z as readMessageCardRefs } from "./conversation-DSo310Ps.js";
18
- import { n as briefEntriesFromReportEvents, s as generateBrief, t as BRIEF_PROMPT } from "./prompt-BW9jFsqZ.js";
18
+ import { C as conversationReportEventSchema, J as conversationBriefSchema, W as readMessageAttachments, Y as parseBriefInput, w as conversationReportMessageEventSchema, z as readMessageCardRefs } from "./conversation-AhOVzN_k.js";
19
+ import { n as briefEntriesFromReportEvents, s as generateBrief, t as BRIEF_PROMPT } from "./prompt-BPSdXYXV.js";
19
20
  import path from "node:path";
20
21
  import { AsyncLocalStorage } from "node:async_hooks";
21
22
  import { z } from "zod";
22
23
  import { createHash, createHmac, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, randomUUID, sign, timingSafeEqual } from "node:crypto";
23
- import { and, arrayContains, asc, desc, eq, exists, getTableColumns, gte, inArray, isNotNull, isNull, lt, lte, max, ne, notExists, or, sql } from "drizzle-orm";
24
+ import { and, arrayContains, asc, count, desc, eq, exists, getTableColumns, gte, ilike, inArray, isNotNull, isNull, lt, lte, max, ne, notExists, or, sql } from "drizzle-orm";
24
25
  import { EgressAuthRequired, EgressPolicyDenied, actorSchema, codeChangeInputSchema, conversationAnnotationInputSchema, conversationEventPresentationSchema, defineConversationEvent, eventDataSchema, eventInputSchema, eventMatchSchema, eventMatches, eventTypeSchema, missingToolAnnotationKeys, normalizeEventIdentifier, objectAnnotationSchema, objectIconPath, objectTypeSchema, pluginAuthorizationSchema, pluginCredentialHeaderTransformSchema, pluginCredentialResultSchema, pluginEventsSchema, pluginGrantSchema, pluginProviderAccountSchema, pluginStoredTokensSchema, promptContextSchema, promptMessageSchema, sourceSchema, stableEventMatchKey } from "@sentry/junior-plugin-api";
25
26
  import { alias, union } from "drizzle-orm/pg-core";
26
27
  import fs from "node:fs/promises";
@@ -166,39 +167,6 @@ function renderJuniorNativeConversationEvent(args) {
166
167
  const presentation = definition.renderEvent(definition.parse(args.content));
167
168
  return presentation === void 0 ? void 0 : conversationEventPresentationSchema.parse(presentation);
168
169
  }
169
- // src/chat/state/cache.ts
170
- const MAX_CACHE_KEY_LENGTH = 512;
171
- const LOAD_WAIT_MS = 1e4;
172
- /**
173
- * Create a cache in the state adapter under `junior:cache:{namespace}:`.
174
- * Entries are wrapped, so `null` and `false` values are cache hits.
175
- */
176
- function createCache(state, namespace) {
177
- return { async getOrLoad(key, load, options) {
178
- if (!key.trim()) throw new Error("Cache key is required");
179
- if (key.length > MAX_CACHE_KEY_LENGTH) throw new Error("Cache key exceeds the maximum length");
180
- const cacheKey = `junior:cache:${namespace}:${key}`;
181
- const cached = async () => options?.refresh ? void 0 : await state.get(cacheKey) ?? void 0;
182
- await state.connect();
183
- const hit = await cached();
184
- if (hit) return hit.value;
185
- const attempt = await withLock(state, `${cacheKey}:load`, async () => {
186
- const loadedMeanwhile = await cached();
187
- if (loadedMeanwhile) return loadedMeanwhile.value;
188
- const entry = await load();
189
- if (entry.ttlMs !== void 0) {
190
- if (!(entry.ttlMs > 0)) throw new Error("Cache ttlMs must be positive");
191
- await state.set(cacheKey, { value: entry.value }, entry.ttlMs);
192
- }
193
- return entry.value;
194
- }, {
195
- keepAlive: true,
196
- waitMs: LOAD_WAIT_MS
197
- });
198
- if (attempt.acquired) return attempt.value;
199
- return (await load()).value;
200
- } };
201
- }
202
170
  // src/chat/plugins/cache.ts
203
171
  /** Create the cache scoped to one plugin. */
204
172
  function createPluginCache(plugin) {
@@ -214,6 +182,8 @@ const slackUserInfoSchema = z.object({
214
182
  tz: z.string().nullish(),
215
183
  profile: z.object({
216
184
  email: z.string().nullish(),
185
+ image_512: z.string().nullish(),
186
+ is_custom_image: z.boolean().nullish(),
217
187
  real_name: z.string().nullish()
218
188
  }).optional()
219
189
  })
@@ -258,10 +228,14 @@ async function lookupSlackUser(teamId, userId) {
258
228
  const parsed = slackUserInfoSchema.safeParse(await response.json());
259
229
  if (!parsed.success) return null;
260
230
  const payload = parsed.data;
231
+ const userName = payload.user.name?.trim() || void 0;
232
+ const fullName = payload.user.profile?.real_name?.trim() || void 0;
233
+ const avatarUrl = payload.user.profile?.is_custom_image ? parseAvatarUrl(payload.user.profile.image_512, "slack") : void 0;
261
234
  const result = {
262
- userName: payload.user.name?.trim() || void 0,
263
- fullName: payload.user.profile?.real_name?.trim() || void 0,
235
+ userName,
236
+ fullName,
264
237
  email: payload.user.profile?.email?.trim() || void 0,
238
+ ...avatarUrl ? { avatarUrl } : void 0,
265
239
  timezone: payload.user.tz?.trim() || void 0
266
240
  };
267
241
  writeToCache(teamId, userId, result);
@@ -1784,17 +1758,22 @@ function createPluginModel(pluginName, options = {}, runtime = {}) {
1784
1758
  }
1785
1759
  /** Create the host-owned embedding capability exposed to prompt hooks. */
1786
1760
  function createPluginEmbedder(pluginName, runtime = {}) {
1787
- return { async embedTexts(input) {
1788
- return await embedTexts({
1789
- modelId: botConfig.embeddingModelId,
1790
- texts: input.texts,
1791
- signal: runtime.signal,
1792
- metadata: {
1793
- pluginName,
1794
- pluginModelRole: "embedding"
1795
- }
1796
- });
1797
- } };
1761
+ return {
1762
+ model: botConfig.embeddingModelId,
1763
+ provider: GEN_AI_PROVIDER_NAME,
1764
+ async embedTexts(input) {
1765
+ return await embedTexts({
1766
+ modelId: botConfig.embeddingModelId,
1767
+ texts: input.texts,
1768
+ ...input.inputType !== void 0 ? { inputType: input.inputType } : void 0,
1769
+ signal: runtime.signal,
1770
+ metadata: {
1771
+ pluginName,
1772
+ pluginModelRole: "embedding"
1773
+ }
1774
+ });
1775
+ }
1776
+ };
1798
1777
  }
1799
1778
  // src/chat/plugins/state.ts
1800
1779
  const MAX_PLUGIN_STATE_KEY_LENGTH = 512;
@@ -3085,19 +3064,6 @@ function getSlackToolContext(context) {
3085
3064
  threadTs: parseSlackMessageTs(location.threadTs)
3086
3065
  };
3087
3066
  }
3088
- /**
3089
- * Return whether a person started this Turn with a Slack message.
3090
- *
3091
- * Only these Turns act with that person's Slack authority. Event, automation,
3092
- * Agent invocation, and dashboard Turns in the same Conversation do not.
3093
- */
3094
- function isSlackMessageTurn(context) {
3095
- return context.source.kind === "slack" && context.destination.platform === "slack" && context.actor?.platform === "slack" && context.actor.teamId === context.source.teamId;
3096
- }
3097
- /** Disable reason for tools that need a Slack message Turn. */
3098
- function slackMessageTurnDisabledReason(context) {
3099
- return isSlackMessageTurn(context) ? void 0 : "It needs a Turn that a person started with a Slack message.";
3100
- }
3101
3067
  // src/chat/slack/tool-support/channel-capabilities.ts
3102
3068
  /** Resolve channel capabilities from a Slack channel ID. */
3103
3069
  function resolveChannelCapabilities(channelId) {
@@ -5565,6 +5531,24 @@ async function deleteWorkspaceSnapshotBuilders$1(names) {
5565
5531
  }
5566
5532
  if (failures.length > 0) throw failures[0];
5567
5533
  }
5534
+ const DOCKER_SNAPSHOT_PACKAGES = /* @__PURE__ */ new Set([
5535
+ "spal-release",
5536
+ "ripgrep",
5537
+ "gh",
5538
+ "jq"
5539
+ ]);
5540
+ /**
5541
+ * Keep only the small Docker base from the declared runtime dependencies.
5542
+ *
5543
+ * Postinstall commands are skipped, because they often download large tools.
5544
+ * A base package that no plugin declares is not installed.
5545
+ */
5546
+ function selectSnapshotRuntime(runtime) {
5547
+ return {
5548
+ dependencies: runtime.dependencies.filter((dependency) => "package" in dependency && DOCKER_SNAPSHOT_PACKAGES.has(dependency.package)),
5549
+ postinstall: []
5550
+ };
5551
+ }
5568
5552
  /** Local Docker implementation of the sandbox provider contract. */
5569
5553
  const dockerSandboxProvider = {
5570
5554
  errors: dockerSandboxErrors,
@@ -5572,7 +5556,8 @@ const dockerSandboxProvider = {
5572
5556
  getSession,
5573
5557
  createWorkspaceSnapshotBuilder: createWorkspaceSnapshotBuilder$1,
5574
5558
  getWorkspaceSnapshotBuilder: getWorkspaceSnapshotBuilder$1,
5575
- deleteWorkspaceSnapshotBuilders: deleteWorkspaceSnapshotBuilders$1
5559
+ deleteWorkspaceSnapshotBuilders: deleteWorkspaceSnapshotBuilders$1,
5560
+ selectSnapshotRuntime
5576
5561
  };
5577
5562
  // src/chat/sandbox/vercel/credentials.ts
5578
5563
  /**
@@ -7092,9 +7077,12 @@ function workspaceRecipe(workspace) {
7092
7077
  }
7093
7078
  /** Build the complete profile that selects one reusable sandbox snapshot. */
7094
7079
  function create(runtime, workspace) {
7095
- const dependencies = mergeDependencies([...GLOBAL_RUNTIME_DEPENDENCIES, ...pluginCatalogRuntime.getRuntimeDependencies()]);
7096
- const pluginPostinstall = pluginCatalogRuntime.getRuntimePostinstall();
7097
- const postinstall = [...GLOBAL_RUNTIME_POSTINSTALL, ...pluginPostinstall];
7080
+ const declared = {
7081
+ dependencies: mergeDependencies([...GLOBAL_RUNTIME_DEPENDENCIES, ...pluginCatalogRuntime.getRuntimeDependencies()]),
7082
+ postinstall: [...GLOBAL_RUNTIME_POSTINSTALL, ...pluginCatalogRuntime.getRuntimePostinstall()]
7083
+ };
7084
+ const { dependencies, postinstall } = getSandboxProvider().selectSnapshotRuntime?.(declared) ?? declared;
7085
+ const pluginPostinstall = postinstall.filter((command) => !GLOBAL_RUNTIME_POSTINSTALL.includes(command));
7098
7086
  if (dependencies.length === 0 && postinstall.length === 0 && !workspace) return null;
7099
7087
  const rebuildEpoch = process.env.SANDBOX_SNAPSHOT_REBUILD_EPOCH?.trim() ?? "";
7100
7088
  const floating = dependencies.some((dependency) => isFloating(dependency)) || pluginPostinstall.length > 0 || Boolean(workspace);
@@ -7552,6 +7540,488 @@ function createCodeChangePublisher(provider) {
7552
7540
  }
7553
7541
  };
7554
7542
  }
7543
+ // src/chat/review-items/assignees.ts
7544
+ /** Text that the agent writes to assign Everyone. */
7545
+ const EVERYONE_ASSIGNEE = "everyone";
7546
+ /**
7547
+ * Resolve one assignee: Everyone, an email, or a mention. A mention must be
7548
+ * in the tenant of the item.
7549
+ */
7550
+ async function resolveAssignee(db, value, tenant, findMentionedUserId) {
7551
+ const text = value.trim();
7552
+ if (text.toLowerCase() === "everyone") return { everyone: true };
7553
+ const mentionedUserId = await findMentionedUserId(text, tenant);
7554
+ if (mentionedUserId) return { userId: mentionedUserId };
7555
+ if (text.includes("@")) {
7556
+ const user = await findUserByEmailFromSql(db, text);
7557
+ if (!user) throw new ToolInputError(`No Junior user has the email ${text}.`);
7558
+ return { userId: user.id };
7559
+ }
7560
+ throw new ToolInputError(`Unknown assignee ${JSON.stringify(text)}. Use ${EVERYONE_ASSIGNEE}, an email, or a mention such as <@U123>.`);
7561
+ }
7562
+ /** Resolve the assignees of a Review item. One is required. */
7563
+ async function resolveAssignees(db, values, tenant, findMentionedUserId) {
7564
+ if (values.length === 0) throw new ToolInputError("A review item needs at least one assignee.");
7565
+ const resolved = await Promise.all(values.map((value) => resolveAssignee(db, value, tenant, findMentionedUserId)));
7566
+ return {
7567
+ everyone: resolved.some((assignee) => "everyone" in assignee),
7568
+ userIds: [...new Set(resolved.flatMap((assignee) => "userId" in assignee ? [assignee.userId] : []))]
7569
+ };
7570
+ }
7571
+ /** Return which of the Users take part in one root Conversation. */
7572
+ async function conversationParticipantUserIds(db, rootConversationId, userIds) {
7573
+ if (userIds.length === 0) return /* @__PURE__ */ new Set();
7574
+ const rows = await db.select({ userId: juniorConversationParticipants.userId }).from(juniorConversationParticipants).where(and(eq(juniorConversationParticipants.rootConversationId, rootConversationId), inArray(juniorConversationParticipants.userId, [...userIds])));
7575
+ return new Set(rows.map((row) => row.userId));
7576
+ }
7577
+ /** Return the User who created an Automation, if Junior knows them. */
7578
+ async function automationCreatorUserId(db, automationId) {
7579
+ const [row] = await db.select({ userId: juniorIdentities.userId }).from(juniorAutomations).innerJoin(juniorIdentities, eq(juniorIdentities.id, juniorAutomations.creatorIdentityId)).where(eq(juniorAutomations.id, automationId)).limit(1);
7580
+ return row?.userId ?? void 0;
7581
+ }
7582
+ // src/chat/review-items/access.ts
7583
+ /** Return whether two tenants are the same. Two missing tenants match. */
7584
+ function isSameTenant(a, b) {
7585
+ return a?.provider === b?.provider && a?.providerTenantId === b?.providerTenantId;
7586
+ }
7587
+ /**
7588
+ * The reader for one person: items assigned to them, and public items in the
7589
+ * provider tenants of their identities.
7590
+ */
7591
+ function reviewItemReaderForUser(user) {
7592
+ return {
7593
+ tenants: user.identities.flatMap((identity) => identity.providerTenantId ? [{
7594
+ provider: identity.provider,
7595
+ providerTenantId: identity.providerTenantId
7596
+ }] : []),
7597
+ userId: user.id
7598
+ };
7599
+ }
7600
+ /** SQL: the Review item has this assigned User. */
7601
+ function reviewItemAssignedTo(userId) {
7602
+ return sql`exists (select 1 from ${juniorReviewItemAssignees} where ${juniorReviewItemAssignees.itemId} = ${juniorReviewItems.id} and ${juniorReviewItemAssignees.userId} = ${userId})`;
7603
+ }
7604
+ /**
7605
+ * SQL: the Review item needs this User. Only an open `action` item that is
7606
+ * assigned to the User needs them. A notice, an item for Everyone, and an
7607
+ * item without assignees need no one. An assigned User can always see the
7608
+ * item, so this rule needs no visibility check. The nav count and the
7609
+ * "Waiting on you" marker use this rule.
7610
+ */
7611
+ function reviewItemNeedsUser(userId) {
7612
+ return and(eq(juniorReviewItems.status, "open"), eq(juniorReviewItems.kind, "action"), reviewItemAssignedTo(userId));
7613
+ }
7614
+ /** SQL: the Review item links this Conversation. */
7615
+ function reviewItemLinkedTo(conversationId) {
7616
+ return sql`exists (select 1 from ${juniorReviewItemConversations} where ${juniorReviewItemConversations.itemId} = ${juniorReviewItems.id} and ${juniorReviewItemConversations.conversationId} = ${conversationId})`;
7617
+ }
7618
+ function publicIn(tenants) {
7619
+ return tenants.map((tenant) => and(eq(juniorReviewItems.visibility, "public"), eq(juniorReviewItems.provider, tenant.provider), eq(juniorReviewItems.providerTenantId, tenant.providerTenantId)));
7620
+ }
7621
+ /**
7622
+ * SQL form of `canReadReviewItem`, for search. Keep the two the same; the
7623
+ * Review item access test checks that they agree.
7624
+ */
7625
+ function reviewItemVisibleTo(reader) {
7626
+ return or(sql`false`, ...publicIn(reader.tenants ?? []), ...reader.userId ? [reviewItemAssignedTo(reader.userId)] : [], ...reader.conversationId ? [reviewItemLinkedTo(reader.conversationId)] : [], ...reader.automationId ? [eq(juniorReviewItems.automationId, reader.automationId)] : []);
7627
+ }
7628
+ /** Return whether a reader can see one Review item. */
7629
+ function canReadReviewItem(item, reader) {
7630
+ return item.visibility === "public" && item.tenant !== void 0 && (reader.tenants ?? []).some((tenant) => isSameTenant(tenant, item.tenant)) || reader.userId !== void 0 && item.assignees.some((assignee) => assignee.userId === reader.userId) || reader.conversationId !== void 0 && item.conversationIds.includes(reader.conversationId) || reader.automationId !== void 0 && item.automationId === reader.automationId;
7631
+ }
7632
+ /**
7633
+ * Read the scope of an agent. The agent reads as its Conversation and
7634
+ * Automation, not as its Actor: an assignment lets a person see the item on
7635
+ * the review desk, not let an agent read it into another Conversation.
7636
+ */
7637
+ async function readReviewItemAgentScope(context) {
7638
+ const user = (await context.resolveActorIdentity?.())?.user;
7639
+ const automationId = context.source.kind === "scheduled_automation" || context.source.kind === "event_automation" ? context.source.automationId : void 0;
7640
+ const current = (await readConversationAccessFromSql(getDb(), [context.conversationId], user)).get(context.conversationId);
7641
+ const tenant = current?.tenant;
7642
+ return {
7643
+ inPrivateConversation: current?.visibility !== "public",
7644
+ reader: {
7645
+ conversationId: context.conversationId,
7646
+ ...automationId ? { automationId } : void 0,
7647
+ ...tenant ? { tenants: [tenant] } : void 0
7648
+ },
7649
+ ...tenant ? { tenant } : void 0,
7650
+ ...user ? { user } : void 0
7651
+ };
7652
+ }
7653
+ /**
7654
+ * Dedupe keys with this prefix belong to plugins. Only the plugin that added
7655
+ * the item, or a person, can change it. An agent cannot use the prefix.
7656
+ */
7657
+ const PLUGIN_REVIEW_ITEM_KEY_PREFIX = "plugin:";
7658
+ /**
7659
+ * Whether an agent can change an item. Only an agent in the tenant of the
7660
+ * item can change it. A public item changes only from a public
7661
+ * Conversation, so private content never reaches its readers. A private
7662
+ * item changes only from the Conversation or Automation that added it. An
7663
+ * agent never changes an item that a plugin added.
7664
+ */
7665
+ function canAgentChangeReviewItem(item, scope) {
7666
+ if (item.dedupeKey.startsWith("plugin:")) return false;
7667
+ if (!isSameTenant(item.tenant, scope.tenant)) return false;
7668
+ if (item.visibility === "public") return !scope.inPrivateConversation;
7669
+ return item.addedByConversationId === scope.reader.conversationId || scope.reader.automationId !== void 0 && item.automationId === scope.reader.automationId;
7670
+ }
7671
+ /**
7672
+ * Check the Conversations that an agent links. Every link must be in the
7673
+ * tenant of the item. `allowedIds` (the current Conversation and the links
7674
+ * that the item already has) always pass. A private item gets no other
7675
+ * links. A public item can get other links only to public Conversations.
7676
+ */
7677
+ async function requireReviewItemLinks(args) {
7678
+ const access = await readConversationAccessFromSql(getDb(), args.conversationIds, args.scope.user);
7679
+ for (const conversationId of args.conversationIds) {
7680
+ const conversation = access.get(conversationId);
7681
+ if (!conversation || !isSameTenant(conversation.tenant, args.tenant)) throw new ToolInputError(`Conversation ${conversationId} is not found.`);
7682
+ if (args.allowedIds.has(conversationId)) continue;
7683
+ if (args.visibility === "private") throw new ToolInputError("A private item links only to the Conversation that added it.");
7684
+ if (conversation.visibility !== "public") throw new ToolInputError(conversation.canViewPrivateContent ? `Conversation ${conversationId} is private. Only public Conversations can be linked.` : `Conversation ${conversationId} is not found.`);
7685
+ }
7686
+ }
7687
+ /**
7688
+ * Check who an agent assigns a private item to. It cannot go to Everyone.
7689
+ * Each User must be the current Actor, a person in one of the given
7690
+ * Conversations (the current one and, for an existing item, the one that
7691
+ * added it), or the creator of the Automation that added the item. An
7692
+ * Automation run often has no Actor and no people yet, and its creator wrote
7693
+ * the work. A public item can go to anyone.
7694
+ */
7695
+ async function requireReviewItemAssignees(args) {
7696
+ if (args.visibility === "public") return;
7697
+ if (args.assignees.everyone) throw new ToolInputError("A private item cannot be assigned to Everyone. Assign Users instead.");
7698
+ const creatorId = args.automationId ? await automationCreatorUserId(getDb(), args.automationId) : void 0;
7699
+ const others = args.assignees.userIds.filter((id) => id !== args.scope.user?.id && id !== creatorId);
7700
+ const inConversation = /* @__PURE__ */ new Set();
7701
+ for (const conversationId of new Set(args.conversationIds)) for (const id of await conversationParticipantUserIds(getDb(), conversationId, others)) inConversation.add(id);
7702
+ if (others.some((id) => !inConversation.has(id))) throw new ToolInputError("A private item can be assigned only to people in its Conversation.");
7703
+ }
7704
+ /**
7705
+ * Return whether a User can act on an open Review item: an assigned User,
7706
+ * anyone for an item assigned to Everyone, and anyone for an item with no
7707
+ * assignees. The caller checks that the User can see the item.
7708
+ */
7709
+ function canActOnReviewItem(item, userId) {
7710
+ if (item.status !== "open") return false;
7711
+ if (item.assignedToEveryone) return true;
7712
+ if (item.assignees.length === 0) return true;
7713
+ return item.assignees.some((assignee) => assignee.userId === userId);
7714
+ }
7715
+ /** Return whether a User is already the only assignee, so take does nothing. */
7716
+ function isOnlyAssignee(item, userId) {
7717
+ return !item.assignedToEveryone && item.assignees.length === 1 && item.assignees[0]?.userId === userId;
7718
+ }
7719
+ // src/chat/review-items/store.ts
7720
+ /**
7721
+ * Close reason of an action item that a person answered with a reply in
7722
+ * its thread instead of on the card or the review desk.
7723
+ */
7724
+ const REVIEW_ITEM_REPLIED_REASON = "Replied in thread";
7725
+ async function readUsers(db, userIds) {
7726
+ if (userIds.length === 0) return /* @__PURE__ */ new Map();
7727
+ const rows = await db.select({
7728
+ displayName: juniorUsers.displayName,
7729
+ email: juniorUsers.primaryEmail,
7730
+ userId: juniorUsers.id
7731
+ }).from(juniorUsers).where(inArray(juniorUsers.id, [...new Set(userIds)]));
7732
+ return new Map(rows.map((row) => [row.userId, {
7733
+ email: row.email,
7734
+ userId: row.userId,
7735
+ ...row.displayName ? { displayName: row.displayName } : void 0
7736
+ }]));
7737
+ }
7738
+ async function hydrate(db, rows) {
7739
+ if (rows.length === 0) return [];
7740
+ const ids = rows.map((row) => row.id);
7741
+ const [assigneeRows, conversationRows] = await Promise.all([db.select().from(juniorReviewItemAssignees).where(inArray(juniorReviewItemAssignees.itemId, ids)), db.select().from(juniorReviewItemConversations).where(inArray(juniorReviewItemConversations.itemId, ids))]);
7742
+ const users = await readUsers(db, [...assigneeRows.map((row) => row.userId), ...rows.flatMap((row) => row.closedByUserId ? [row.closedByUserId] : [])]);
7743
+ return rows.map((row) => {
7744
+ const closedBy = row.closedByUserId ? users.get(row.closedByUserId) : void 0;
7745
+ return {
7746
+ addedByConversationId: row.addedByConversationId,
7747
+ assignedToEveryone: row.assignedToEveryone,
7748
+ assignees: assigneeRows.filter((assignee) => assignee.itemId === row.id).flatMap((assignee) => users.get(assignee.userId) ?? []).sort((a, b) => a.email.localeCompare(b.email)),
7749
+ body: row.body,
7750
+ conversationIds: conversationRows.filter((link) => link.itemId === row.id).map((link) => link.conversationId).sort(),
7751
+ createdAt: row.createdAt,
7752
+ dedupeKey: row.dedupeKey,
7753
+ id: row.id,
7754
+ kind: row.kind,
7755
+ status: row.status,
7756
+ title: row.title,
7757
+ updatedAt: row.updatedAt,
7758
+ visibility: row.visibility,
7759
+ ...row.answer ? { answer: row.answer } : void 0,
7760
+ ...row.automationId ? { automationId: row.automationId } : void 0,
7761
+ ...row.closeReason ? { closeReason: row.closeReason } : void 0,
7762
+ ...row.closedAt ? { closedAt: row.closedAt } : void 0,
7763
+ ...closedBy ? { closedBy } : void 0,
7764
+ ...row.request ? { request: row.request } : void 0,
7765
+ ...row.provider && row.providerTenantId ? { tenant: {
7766
+ provider: row.provider,
7767
+ providerTenantId: row.providerTenantId
7768
+ } } : void 0,
7769
+ ...row.subjectId ? { subject: {
7770
+ id: row.subjectId,
7771
+ ...row.subjectTitle ? { title: row.subjectTitle } : void 0,
7772
+ ...row.subjectUrl ? { url: row.subjectUrl } : void 0
7773
+ } } : void 0
7774
+ };
7775
+ });
7776
+ }
7777
+ function contentColumns(content) {
7778
+ return {
7779
+ assignedToEveryone: content.assignedToEveryone,
7780
+ body: content.body,
7781
+ kind: content.kind,
7782
+ request: content.request ?? null,
7783
+ subjectId: content.subject?.id ?? null,
7784
+ subjectTitle: content.subject?.title ?? null,
7785
+ subjectUrl: content.subject?.url ?? null,
7786
+ title: content.title,
7787
+ visibility: content.visibility
7788
+ };
7789
+ }
7790
+ async function replaceLinks(db, itemId, content) {
7791
+ await db.delete(juniorReviewItemAssignees).where(eq(juniorReviewItemAssignees.itemId, itemId));
7792
+ await db.delete(juniorReviewItemConversations).where(eq(juniorReviewItemConversations.itemId, itemId));
7793
+ const userIds = [...new Set(content.assigneeUserIds)];
7794
+ if (userIds.length > 0) await db.insert(juniorReviewItemAssignees).values(userIds.map((userId) => ({
7795
+ itemId,
7796
+ userId
7797
+ })));
7798
+ const conversationIds = [...new Set(content.conversationIds)];
7799
+ if (conversationIds.length > 0) await db.insert(juniorReviewItemConversations).values(conversationIds.map((conversationId) => ({
7800
+ itemId,
7801
+ conversationId
7802
+ })));
7803
+ }
7804
+ /** Read one Review item by id. */
7805
+ async function getReviewItem(db, id) {
7806
+ return (await hydrate(db, await db.select().from(juniorReviewItems).where(eq(juniorReviewItems.id, id))))[0];
7807
+ }
7808
+ /**
7809
+ * Read the action items that the agent added in one Conversation and that a
7810
+ * reply can answer: the open items, and the items that a reply closed.
7811
+ */
7812
+ async function listReplyActionItems(db, conversationId) {
7813
+ return await hydrate(db, await db.select().from(juniorReviewItems).where(and(eq(juniorReviewItems.addedByConversationId, conversationId), eq(juniorReviewItems.kind, "action"), or(eq(juniorReviewItems.status, "open"), eq(juniorReviewItems.closeReason, REVIEW_ITEM_REPLIED_REASON)))));
7814
+ }
7815
+ /**
7816
+ * Read the open Review item with one dedupe key in one provider tenant, if
7817
+ * there is one. Dedupe keys are unique only inside a tenant.
7818
+ */
7819
+ async function getOpenReviewItemByDedupeKey(db, dedupeKey, tenant) {
7820
+ return (await hydrate(db, await db.select().from(juniorReviewItems).where(and(eq(juniorReviewItems.dedupeKey, dedupeKey), eq(juniorReviewItems.status, "open"), tenant ? and(eq(juniorReviewItems.provider, tenant.provider), eq(juniorReviewItems.providerTenantId, tenant.providerTenantId)) : isNull(juniorReviewItems.provider)))))[0];
7821
+ }
7822
+ /**
7823
+ * Add one open Review item. Return undefined when another open item in the
7824
+ * same tenant already uses its dedupe key.
7825
+ */
7826
+ async function insertReviewItem(db, input) {
7827
+ const now = new Date(input.nowMs);
7828
+ const id = await db.transaction(async (tx) => {
7829
+ const inserted = await tx.insert(juniorReviewItems).values({
7830
+ ...contentColumns(input),
7831
+ addedByConversationId: input.addedByConversationId,
7832
+ automationId: input.automationId ?? null,
7833
+ createdAt: now,
7834
+ dedupeKey: input.dedupeKey,
7835
+ id: randomUUID(),
7836
+ provider: input.tenant?.provider ?? null,
7837
+ providerTenantId: input.tenant?.providerTenantId ?? null,
7838
+ status: "open",
7839
+ updatedAt: now
7840
+ }).onConflictDoNothing().returning({ id: juniorReviewItems.id });
7841
+ if (!inserted[0]) return void 0;
7842
+ await replaceLinks(tx, inserted[0].id, input);
7843
+ return inserted[0].id;
7844
+ });
7845
+ if (!id) return void 0;
7846
+ const item = await getReviewItem(db, id);
7847
+ if (!item) throw new Error(`Review item ${id} is missing after insert`);
7848
+ return item;
7849
+ }
7850
+ /** Replace the content of one open Review item. Return false when closed. */
7851
+ async function updateReviewItemContent(db, input) {
7852
+ return await db.transaction(async (tx) => {
7853
+ if (!(await tx.update(juniorReviewItems).set({
7854
+ ...contentColumns(input),
7855
+ updatedAt: new Date(input.nowMs)
7856
+ }).where(and(eq(juniorReviewItems.id, input.id), eq(juniorReviewItems.status, "open"))).returning({ id: juniorReviewItems.id }))[0]) return false;
7857
+ await replaceLinks(tx, input.id, input);
7858
+ return true;
7859
+ });
7860
+ }
7861
+ /**
7862
+ * Close one open Review item. `closedByUserId` is the person who closed it,
7863
+ * and it is unset when an agent closes it. Return false when the item was
7864
+ * already closed, so only the first close wins.
7865
+ */
7866
+ async function closeReviewItem(db, input) {
7867
+ const now = new Date(input.nowMs);
7868
+ return (await db.update(juniorReviewItems).set({
7869
+ answer: input.answer ?? null,
7870
+ closeReason: input.reason ?? null,
7871
+ closedAt: now,
7872
+ closedByUserId: input.closedByUserId ?? null,
7873
+ status: input.status,
7874
+ updatedAt: now
7875
+ }).where(and(eq(juniorReviewItems.id, input.id), eq(juniorReviewItems.status, "open"))).returning({ id: juniorReviewItems.id })).length > 0;
7876
+ }
7877
+ /**
7878
+ * Dismiss the open Review items that an Automation or a set of
7879
+ * Conversations added, when no agent can act on them any more. No person
7880
+ * closes them. It sends no message and does not change the Slack cards. A
7881
+ * close by Conversations skips the items of an Automation, because a later
7882
+ * run of the Automation can still change them. An Automation delete closes
7883
+ * those.
7884
+ */
7885
+ async function dismissOpenReviewItems(db, input) {
7886
+ const now = new Date(input.nowMs);
7887
+ await db.update(juniorReviewItems).set({
7888
+ closeReason: input.reason,
7889
+ closedAt: now,
7890
+ status: "dismissed",
7891
+ updatedAt: now
7892
+ }).where(and(eq(juniorReviewItems.status, "open"), "automationId" in input ? eq(juniorReviewItems.automationId, input.automationId) : and(inArray(juniorReviewItems.addedByConversationId, [...input.addedByConversationIds]), isNull(juniorReviewItems.automationId))));
7893
+ }
7894
+ /**
7895
+ * Make one User the only assignee of an open Review item. Return false when
7896
+ * the item is closed.
7897
+ */
7898
+ async function takeReviewItem(db, input) {
7899
+ return await db.transaction(async (tx) => {
7900
+ if (!(await tx.update(juniorReviewItems).set({
7901
+ assignedToEveryone: false,
7902
+ updatedAt: new Date(input.nowMs)
7903
+ }).where(and(eq(juniorReviewItems.id, input.id), eq(juniorReviewItems.status, "open"))).returning({ id: juniorReviewItems.id }))[0]) return false;
7904
+ await tx.delete(juniorReviewItemAssignees).where(eq(juniorReviewItemAssignees.itemId, input.id));
7905
+ await tx.insert(juniorReviewItemAssignees).values({
7906
+ itemId: input.id,
7907
+ userId: input.userId
7908
+ });
7909
+ return true;
7910
+ });
7911
+ }
7912
+ function searchConditions(reader, filters) {
7913
+ const conditions = [reviewItemVisibleTo(reader), inArray(juniorReviewItems.status, [...filters.statuses])];
7914
+ if (filters.after) conditions.push(or(lt(juniorReviewItems.updatedAt, filters.after.updatedAt), and(eq(juniorReviewItems.updatedAt, filters.after.updatedAt), lt(juniorReviewItems.id, filters.after.id))));
7915
+ if (filters.subjectId) conditions.push(eq(juniorReviewItems.subjectId, filters.subjectId));
7916
+ if (filters.automationId) conditions.push(eq(juniorReviewItems.automationId, filters.automationId));
7917
+ if (filters.conversationId) conditions.push(reviewItemLinkedTo(filters.conversationId));
7918
+ if (filters.assignee) conditions.push("everyone" in filters.assignee ? eq(juniorReviewItems.assignedToEveryone, true) : reviewItemAssignedTo(filters.assignee.userId));
7919
+ if (filters.query) {
7920
+ const pattern = `%${filters.query.replace(/[\\%_]/g, "\\$&")}%`;
7921
+ conditions.push(or(ilike(juniorReviewItems.title, pattern), ilike(juniorReviewItems.body, pattern)));
7922
+ }
7923
+ return conditions;
7924
+ }
7925
+ /** Count the open Review items that need a User (`reviewItemNeedsUser`). */
7926
+ async function countReviewItemsNeedingUser(db, userId) {
7927
+ return (await db.select({ count: count() }).from(juniorReviewItems).where(reviewItemNeedsUser(userId)))[0]?.count ?? 0;
7928
+ }
7929
+ /**
7930
+ * Find the Review items that a reader can see. Open items come first, so
7931
+ * the limit never drops an open item for a closed one. Then the newest
7932
+ * change comes first: `updated_at`, then `id`, both descending. `after`
7933
+ * pages through that order.
7934
+ */
7935
+ async function searchReviewItems(db, reader, filters) {
7936
+ return await hydrate(db, await db.select().from(juniorReviewItems).where(and(...searchConditions(reader, filters))).orderBy(desc(eq(juniorReviewItems.status, "open")), desc(juniorReviewItems.updatedAt), desc(juniorReviewItems.id)).limit(filters.limit));
7937
+ }
7938
+ /**
7939
+ * List the Conversations that wait on a User: each is linked to an open item
7940
+ * that needs the User (`reviewItemNeedsUser`).
7941
+ */
7942
+ async function listWaitingConversationIds(db, userId) {
7943
+ return (await db.selectDistinct({ conversationId: juniorReviewItemConversations.conversationId }).from(juniorReviewItemConversations).innerJoin(juniorReviewItems, eq(juniorReviewItems.id, juniorReviewItemConversations.itemId)).where(reviewItemNeedsUser(userId))).map((row) => row.conversationId).sort();
7944
+ }
7945
+ // src/chat/review-items/plugin.ts
7946
+ /**
7947
+ * Resolve the Users to assign. An Identity without a User has no one to
7948
+ * assign. A private item goes only to people in its Conversation, the same
7949
+ * rule that `requireReviewItemAssignees` applies to agents.
7950
+ */
7951
+ async function assigneeUserIds(db, identityIds, conversationId, isPrivate) {
7952
+ const userIds = [];
7953
+ for (const identityId of new Set(identityIds)) {
7954
+ const user = (await readIdentityByIdFromSql(db, identityId))?.user;
7955
+ if (!user) continue;
7956
+ if (isPrivate && !(await readConversationAccessFromSql(db, [conversationId], user)).get(conversationId)?.isParticipant) throw new Error("A private review item can be assigned only to people in its Conversation");
7957
+ userIds.push(user.id);
7958
+ }
7959
+ return userIds;
7960
+ }
7961
+ /**
7962
+ * Connect a plugin to the review desk for one Conversation. Dedupe keys are
7963
+ * `plugin:<name>:<key>`, so a plugin changes only its own items, and agents
7964
+ * cannot change them. A change sends no message to the Conversation. It
7965
+ * only shows the new state through `show`. An item is private when the
7966
+ * Conversation is not public.
7967
+ */
7968
+ function createPluginReviewItems(args) {
7969
+ const { conversationId, show } = args;
7970
+ const scopedKey = (dedupeKey) => `${PLUGIN_REVIEW_ITEM_KEY_PREFIX}${args.plugin}:${dedupeKey}`;
7971
+ const readAccess = async (db) => {
7972
+ const access = (await readConversationAccessFromSql(db, [conversationId])).get(conversationId);
7973
+ if (!access) throw new Error(`Conversation ${conversationId} is not found`);
7974
+ return access;
7975
+ };
7976
+ return {
7977
+ async upsert(notice) {
7978
+ const db = getDb();
7979
+ const access = await readAccess(db);
7980
+ const existing = await getOpenReviewItemByDedupeKey(db, scopedKey(notice.dedupeKey), access.tenant);
7981
+ const isPrivate = access.visibility !== "public" || existing?.visibility === "private";
7982
+ if (existing && isPrivate && existing.addedByConversationId !== conversationId) throw new Error("A private review item links only to the Conversation that added it");
7983
+ const content = {
7984
+ assignedToEveryone: false,
7985
+ assigneeUserIds: await assigneeUserIds(db, notice.assigneeIdentityIds, conversationId, isPrivate),
7986
+ body: notice.body,
7987
+ conversationIds: [.../* @__PURE__ */ new Set([conversationId, ...existing?.conversationIds ?? []])],
7988
+ kind: "notice",
7989
+ title: notice.title,
7990
+ visibility: isPrivate ? "private" : "public",
7991
+ ...notice.subject ? { subject: notice.subject } : void 0
7992
+ };
7993
+ if (existing) {
7994
+ await updateReviewItemContent(db, {
7995
+ ...content,
7996
+ id: existing.id,
7997
+ nowMs: Date.now()
7998
+ });
7999
+ await show(existing.id);
8000
+ return;
8001
+ }
8002
+ const item = await insertReviewItem(db, {
8003
+ ...content,
8004
+ addedByConversationId: conversationId,
8005
+ dedupeKey: scopedKey(notice.dedupeKey),
8006
+ nowMs: Date.now(),
8007
+ ...access.tenant ? { tenant: access.tenant } : void 0
8008
+ });
8009
+ if (item) await show(item.id);
8010
+ },
8011
+ async close(input) {
8012
+ const db = getDb();
8013
+ const access = await readAccess(db);
8014
+ const item = await getOpenReviewItemByDedupeKey(db, scopedKey(input.dedupeKey), access.tenant);
8015
+ if (!item) return;
8016
+ if (await closeReviewItem(db, {
8017
+ id: item.id,
8018
+ nowMs: Date.now(),
8019
+ reason: input.reason,
8020
+ status: input.status
8021
+ })) await show(item.id);
8022
+ }
8023
+ };
8024
+ }
7555
8025
  // src/chat/briefs/registration.ts
7556
8026
  var registration_exports = /* @__PURE__ */ __exportAll({
7557
8027
  coreTaskRegistrations: () => coreTaskRegistrations,
@@ -7712,6 +8182,13 @@ function pluginMcpContext(plugin, context) {
7712
8182
  }
7713
8183
  };
7714
8184
  }
8185
+ /** Time one plugin hook call so slow plugins show up in turn traces. */
8186
+ async function withPluginHookSpan(pluginName, hookName, callback) {
8187
+ return await withSpan(`plugin.hook ${hookName}`, "plugin.hook", {}, callback, {
8188
+ "app.plugin.name": pluginName,
8189
+ "app.plugin.hook": hookName
8190
+ });
8191
+ }
7715
8192
  function safeErrorMessage(error) {
7716
8193
  return error instanceof Error ? error.message : String(error);
7717
8194
  }
@@ -7859,7 +8336,7 @@ async function getPluginUserPromptContributions(args) {
7859
8336
  const hook = plugin.hooks?.userPrompt;
7860
8337
  if (!hook) continue;
7861
8338
  try {
7862
- const rawResult = await hook({ ...invocationPluginContext(plugin, args.context, args.turnId) });
8339
+ const rawResult = await withPluginHookSpan(pluginName, "userPrompt", async () => await hook({ ...invocationPluginContext(plugin, args.context, args.turnId) }));
7863
8340
  if (rawResult === void 0) continue;
7864
8341
  if (!Array.isArray(rawResult)) {
7865
8342
  logInvalidPromptContributions({
@@ -8096,6 +8573,11 @@ function getPluginRoutes(options) {
8096
8573
  plugin: pluginName
8097
8574
  }) },
8098
8575
  codeChanges: createCodeChangePublisher(pluginName),
8576
+ reviewItems: { forConversation: (conversationId) => createPluginReviewItems({
8577
+ conversationId,
8578
+ plugin: pluginName,
8579
+ show: options.showReviewItem ?? (async () => {})
8580
+ }) },
8099
8581
  users: { readIdentity: readIdentityById },
8100
8582
  events: {
8101
8583
  async hasMatch(event) {
@@ -8502,11 +8984,11 @@ function createPluginHookRunner(input = {}) {
8502
8984
  const hook = plugin.hooks?.sandboxPrepare;
8503
8985
  if (!hook) continue;
8504
8986
  logInfo("agent.plugin.sandbox_preparation.started", { "app.plugin.name": pluginName });
8505
- await hook({
8987
+ await withPluginHookSpan(pluginName, "sandboxPrepare", async () => await hook({
8506
8988
  ...basePluginContext$1(plugin),
8507
8989
  actor: input.actor,
8508
8990
  sandbox: sandboxCapability
8509
- });
8991
+ }));
8510
8992
  }
8511
8993
  },
8512
8994
  async beforeToolExecute(tool) {
@@ -8519,7 +9001,7 @@ function createPluginHookRunner(input = {}) {
8519
9001
  if (!hook) continue;
8520
9002
  let replacement;
8521
9003
  let blocked;
8522
- await hook({
9004
+ await withPluginHookSpan(pluginName, "beforeToolExecute", async () => await hook({
8523
9005
  ...basePluginContext$1(plugin),
8524
9006
  actor: input.actor,
8525
9007
  actors,
@@ -8548,7 +9030,7 @@ function createPluginHookRunner(input = {}) {
8548
9030
  replacement = input;
8549
9031
  }
8550
9032
  }
8551
- });
9033
+ }));
8552
9034
  if (blocked) throw blocked;
8553
9035
  if (replacement !== void 0) {
8554
9036
  if (!isRecord(replacement)) throw new Error(`Plugin "${pluginName}" replaced tool input with a non-object value`);
@@ -9483,4 +9965,4 @@ async function executeCredentialedEgressRequest(input) {
9483
9965
  });
9484
9966
  }
9485
9967
  }
9486
- export { openSlackModal as $, recordSlackAttachment as $n, verifyTimerWatchCredentialSubject as $t, setWorkspaceSnapshotBuild as A, sandboxSkillDir as An, getSlackToolContext as At, parseOAuthStatePayload as B, conversationArchivedForUser as Bn, createTimerWatch as Bt, listWorkspaces as C, runNonInteractiveCommand as Cn, resolveSandboxCommandEnvironment as Ct, invalidateReadySnapshot as D, SANDBOX_REPOS_ROOT as Dn, tryWorkspaceRepoCheckoutPath as Dt, WorkspaceValidationError as E, SANDBOX_JUNIOR_ROOT as En, SANDBOX_RUNTIME as Et, getSandboxProvider as F, createPluginModel as Fn, RESOURCE_WATCH_TOOL_SOURCE as Ft, normalizeSlackReplyMarkdown as G, viewerConversationMembership as Gn, listWatches as Gt, startOAuthFlow as H, conversationIsMemberRoot as Hn, deliverWatch as Ht, isVercelSandboxEgressRequest as I, createPluginLogger as In, STOP_WATCHING_TOOL_NAME as It, truncateStatusText as J, readConversationEventPage as Jn, bindScheduledAutomationCredentialSubject as Jt, normalizeSlackStatusText as K, createPluginConversationEvents as Kn, takeDueTimerWatches as Kt, proxyVercelSandboxEgressRequest as L, readConversationAccessFromSql as Ln, cancelSubscriptions as Lt, hash as M, sandboxSkillPathResolution as Mn, slackMessageTurnDisabledReason as Mt, isStale as N, createPluginState as Nn, RESOURCE_SUBSCRIPTION_DEFAULT_TTL_MS as Nt, loadSnapshotsForProfile as O, SANDBOX_SKILLS_ROOT as On, workspaceRepoCheckoutPath as Ot, ensureDockerDaemon as P, createPluginEmbedder as Pn, RESOURCE_SUBSCRIPTION_MAX_TTL_MS as Pt, openSlackDirectMessage as Q, readAttachmentVisionSummary as Qn, verifySlackDirectCredentialSubject as Qt, deliverOAuthAuthorization as R, conversationArchiveTimes as Rn, cancelWatch as Rt, getWorkspaceByName as S, buildNonInteractiveShellScript as Sn, buildSandboxEgressNetworkPolicy as St, updateWorkspace as T, SANDBOX_DATA_ROOT as Tn, normalizeSandboxEgressTracePropagationDomains as Tt, escapeSlackMrkdwnText as U, conversationNotArchivedForEmail as Un, findMatchingWatches as Ut, resolveBaseUrl as V, conversationHasParticipantUser as Vn, createWatch as Vt, formatSlackLink as W, conversationNotArchivedForUser as Wn, getWatch as Wt, deleteSlackMessage as X, getSlackFileId as Xn, verifyEventAutomationCredentialSubject as Xt, addReactionToMessage as Y, buildSentryConversationUrl as Yn, bindSlackDirectCredentialSubject as Yt, getSlackMessagePermalink as Z, matchSlackAttachments as Zn, verifyScheduledAutomationCredentialSubject as Zt, registration_exports as _, getDashboardTaskLink as _n, consumeSandboxEgressPermissionDeniedSignal as _t, agent_hooks_exports as a, isConversationScopedChannel as an, defaultTimezone as ar, slackEntitySchema as at, deleteWorkspace as b, PluginToolDeniedError as bn, parseSandboxEgressAuthRequiredSignal as bt, getPluginRoutes as c, runWithSlackInstallationToken as cn, createPluginCache as cr, isAlreadyExistsError as ct, getPluginTools as d, getWorkspaceTeamId as dn, agentsInstructionsUpdatedEvent as dr, WorkspaceSnapshotNotReadyError as dt, SlackActionError as en, resolveRootVisibility as er, postSlackEphemeralMessage as et, getPluginUserPromptContributions as f, runWithWorkspaceTeamId as fn, authenticationLinkedEvent as fr, getWorkspaceSnapshotNotReadyError as ft, coreTaskRegistrations as g, getDashboardReviewItemLink as gn, consumeSandboxEgressAuthRequiredSignal as gt, validatePlugins as h, getDashboardObjectIconLink as hn, clearSandboxEgressSignals as ht, resolvePluginOAuthAccount as i, getSlackClient as in, listConversationAnnotationsById as ir, uploadFilesToConversation as it, create as j, sandboxSkillFile as jn, isSlackMessageTurn as jt, setWorkspaceSnapshot as k, SANDBOX_WORKSPACE_ROOT as kn, resolveChannelCapabilities as kt, getPluginSlackConversationLink as l, withSlackRetries as ln, createCache as lr, throwSandboxOperationError as lt, setPlugins as m, getDashboardConversationLink as mn, conversationNoteEvent as mr, workspaceSnapshotNotReadyUserMessage as mt, createUserTokenStore as n, getFilePermalink as nn, createPluginAnnotations as nr, removeReactionFromMessage as nt, createPluginHookRunner as o, isDmChannel as on, resolveUserTimezone as or, slackExternalRefIdSchema as ot, getPlugins as p, JUNIOR_THREAD_STATE_TTL_MS as pn, authenticationUnlinkedEvent as pr, isWorkspaceSnapshotNotReadyError as pt, slackMention as q, readLatestConversationBrief as qn, bindEventAutomationCredentialSubject as qt, getOAuthAccountProviders as r, getHeaderString as rn, listConversationAnnotations as rr, updateSlackMessage as rt, getPluginApiRoutes as s, normalizeSlackConversationId as sn, lookupSlackUser as sr, isAbortError as st, executeCredentialedEgressRequest as t, downloadPrivateSlackFile as tn, saveObjectAnnotations as tr, postSlackMessage as tt, getPluginSystemPromptContributions as u, sleep$1 as un, JUNIOR_NATIVE_EVENT_NAMESPACE as ur, wrapSandboxSetupError as ut, setBriefsConfig as v, setDashboardConversationLinkOptions as vn, createSandboxEgressCredentialToken as vt, listWorkspacesByRepository as w, SANDBOX_COMMAND_PATH as wn, resolveSandboxEgressProviderForHost as wt, getWorkspace as x, buildCommandScript as xn, sandboxEgressSignalsResponseSchema as xt, createWorkspace as y, ToolInputError as yn, parseSandboxEgressCredentialToken as yt, formatProviderLabel as z, conversationArchivedForEmail as zn, collectEventMatchKeys as zt };
9968
+ export { loadSnapshotsForProfile as $, sandboxSkillDir as $n, workspaceRepoCheckoutPath as $t, updateReviewItemContent as A, normalizeSlackConversationId as An, lookupSlackUser as Ar, slackEntitySchema as At, EVERYONE_ASSIGNEE as B, getDashboardTaskLink as Bn, clearSandboxEgressSignals as Bt, getOpenReviewItemByDedupeKey as C, SlackActionError as Cn, resolveRootVisibility as Cr, openSlackDirectMessage as Ct, listWaitingConversationIds as D, getSlackClient as Dn, listConversationAnnotationsById as Dr, removeReactionFromMessage as Dt, listReplyActionItems as E, getHeaderString as En, listConversationAnnotations as Er, postSlackMessage as Et, isOnlyAssignee as F, runWithWorkspaceTeamId as Fn, authenticationUnlinkedEvent as Fr, wrapSandboxSetupError as Ft, deleteWorkspace as G, buildNonInteractiveShellScript as Gn, parseSandboxEgressAuthRequiredSignal as Gt, resolveAssignee as H, ToolInputError as Hn, consumeSandboxEgressPermissionDeniedSignal as Ht, readReviewItemAgentScope as I, JUNIOR_THREAD_STATE_TTL_MS as In, conversationNoteEvent as Ir, WorkspaceSnapshotNotReadyError as It, listWorkspaces as J, SANDBOX_DATA_ROOT as Jn, resolveSandboxCommandEnvironment as Jt, getWorkspace as K, runNonInteractiveCommand as Kn, sandboxEgressSignalsResponseSchema as Kt, requireReviewItemAssignees as L, getDashboardConversationLink as Ln, getWorkspaceSnapshotNotReadyError as Lt, canActOnReviewItem as M, withSlackRetries as Mn, JUNIOR_NATIVE_EVENT_NAMESPACE as Mr, isAbortError as Mt, canAgentChangeReviewItem as N, sleep$1 as Nn, agentsInstructionsUpdatedEvent as Nr, isAlreadyExistsError as Nt, searchReviewItems as O, isConversationScopedChannel as On, defaultTimezone as Or, updateSlackMessage as Ot, canReadReviewItem as P, getWorkspaceTeamId as Pn, authenticationLinkedEvent as Pr, throwSandboxOperationError as Pt, invalidateReadySnapshot as Q, SANDBOX_WORKSPACE_ROOT as Qn, tryWorkspaceRepoCheckoutPath as Qt, requireReviewItemLinks as R, getDashboardObjectIconLink as Rn, isWorkspaceSnapshotNotReadyError as Rt, dismissOpenReviewItems as S, verifyTimerWatchCredentialSubject as Sn, recordSlackAttachment as Sr, getSlackMessagePermalink as St, insertReviewItem as T, getFilePermalink as Tn, createPluginAnnotations as Tr, postSlackEphemeralMessage as Tt, resolveAssignees as U, PluginToolDeniedError as Un, createSandboxEgressCredentialToken as Ut, automationCreatorUserId as V, setDashboardConversationLinkOptions as Vn, consumeSandboxEgressAuthRequiredSignal as Vt, createWorkspace as W, buildCommandScript as Wn, parseSandboxEgressCredentialToken as Wt, updateWorkspace as X, SANDBOX_REPOS_ROOT as Xn, normalizeSandboxEgressTracePropagationDomains as Xt, listWorkspacesByRepository as Y, SANDBOX_JUNIOR_ROOT as Yn, resolveSandboxEgressProviderForHost as Yt, WorkspaceValidationError as Z, SANDBOX_SKILLS_ROOT as Zn, SANDBOX_RUNTIME as Zt, registration_exports as _, bindScheduledAutomationCredentialSubject as _n, readConversationEventPage as _r, normalizeSlackStatusText as _t, agent_hooks_exports as a, STOP_WATCHING_TOOL_NAME as an, createPluginLogger as ar, ensureDockerDaemon as at, closeReviewItem as b, verifyScheduledAutomationCredentialSubject as bn, matchSlackAttachments as br, addReactionToMessage as bt, getPluginRoutes as c, collectEventMatchKeys as cn, conversationArchivedForEmail as cr, proxyVercelSandboxEgressRequest as ct, getPluginTools as d, deliverWatch as dn, conversationIsMemberRoot as dr, parseOAuthStatePayload as dt, resolveChannelCapabilities as en, sandboxSkillFile as er, setWorkspaceSnapshot as et, getPluginUserPromptContributions as f, findMatchingWatches as fn, conversationNotArchivedForEmail as fr, resolveBaseUrl as ft, coreTaskRegistrations as g, bindEventAutomationCredentialSubject as gn, readLatestConversationBrief as gr, normalizeSlackReplyMarkdown as gt, validatePlugins as h, takeDueTimerWatches as hn, createPluginConversationEvents as hr, formatSlackLink as ht, resolvePluginOAuthAccount as i, RESOURCE_WATCH_TOOL_SOURCE as in, createPluginModel as ir, isStale as it, PLUGIN_REVIEW_ITEM_KEY_PREFIX as j, runWithSlackInstallationToken as jn, createPluginCache as jr, slackExternalRefIdSchema as jt, takeReviewItem as k, isDmChannel as kn, resolveUserTimezone as kr, uploadFilesToConversation as kt, getPluginSlackConversationLink as l, createTimerWatch as ln, conversationArchivedForUser as lr, deliverOAuthAuthorization as lt, setPlugins as m, listWatches as mn, viewerConversationMembership as mr, escapeSlackMrkdwnText as mt, createUserTokenStore as n, RESOURCE_SUBSCRIPTION_DEFAULT_TTL_MS as nn, createPluginState as nr, create as nt, createPluginHookRunner as o, cancelSubscriptions as on, readConversationAccessFromSql as or, getSandboxProvider as ot, getPlugins as p, getWatch as pn, conversationNotArchivedForUser as pr, startOAuthFlow as pt, getWorkspaceByName as q, SANDBOX_COMMAND_PATH as qn, buildSandboxEgressNetworkPolicy as qt, getOAuthAccountProviders as r, RESOURCE_SUBSCRIPTION_MAX_TTL_MS as rn, createPluginEmbedder as rr, hash as rt, getPluginApiRoutes as s, cancelWatch as sn, conversationArchiveTimes as sr, isVercelSandboxEgressRequest as st, executeCredentialedEgressRequest as t, getSlackToolContext as tn, sandboxSkillPathResolution as tr, setWorkspaceSnapshotBuild as tt, getPluginSystemPromptContributions as u, createWatch as un, conversationHasParticipantUser as ur, formatProviderLabel as ut, setBriefsConfig as v, bindSlackDirectCredentialSubject as vn, buildSentryConversationUrl as vr, slackMention as vt, getReviewItem as w, downloadPrivateSlackFile as wn, saveObjectAnnotations as wr, openSlackModal as wt, countReviewItemsNeedingUser as x, verifySlackDirectCredentialSubject as xn, readAttachmentVisionSummary as xr, deleteSlackMessage as xt, REVIEW_ITEM_REPLIED_REASON as y, verifyEventAutomationCredentialSubject as yn, getSlackFileId as yr, truncateStatusText as yt, reviewItemReaderForUser as z, getDashboardReviewItemLink as zn, workspaceSnapshotNotReadyUserMessage as zt };