@sentry/junior 0.266.0 → 0.268.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 (87) hide show
  1. package/dist/{actor-tlxzqaRe.js → actor-CP9K2Idu.js} +9 -1
  2. package/dist/{adapter-eRFA_naf.js → adapter-04AcD2SO.js} +5 -7
  3. package/dist/{admin-tILpP51p.js → admin-DIU1QyG3.js} +1 -1
  4. package/dist/{agent-invocations-CxKldAeB.js → agent-invocations-C1RNOCs8.js} +2 -0
  5. package/dist/{agent-runner-BtYK7bht.js → agent-runner-CAYfFmU7.js} +2636 -601
  6. package/dist/api/schema.d.ts +2 -5454
  7. package/dist/api/schema.js +6 -5
  8. package/dist/api.d.ts +6 -1
  9. package/dist/api.js +255 -1208
  10. package/dist/{app-Csbi9dW7.d.ts → app-z6Uuyq3j.d.ts} +23 -4
  11. package/dist/app.d.ts +2 -2
  12. package/dist/app.js +1869 -378
  13. package/dist/{automation-BKpY9LPa.js → automation-CmeFBSEh.js} +42 -14
  14. package/dist/{briefs-DgfWMU7e.js → briefs-DQbV2jNr.js} +4 -4
  15. package/dist/{catalog-runtime-DAJjAbSx.js → catalog-runtime-BD9GSpc6.js} +1 -1
  16. package/dist/cli/chat.js +15 -15
  17. package/dist/cli/check.js +3 -3
  18. package/dist/cli/init.js +0 -1
  19. package/dist/cli/main.js +3 -3
  20. package/dist/cli/plugins.js +4 -4
  21. package/dist/cli/snapshot-create.js +4 -4
  22. package/dist/cli/upgrade.d.ts +1 -1
  23. package/dist/cli/upgrade.js +3 -3
  24. package/dist/{client-B0YZfxNy.js → client-C1yDYjoS.js} +2 -2
  25. package/dist/{config-C0UwYqDK.js → config-DrTeF80T.js} +32 -10
  26. package/dist/{connections-Bwu3VORi.js → connections-D46f8vvi.js} +12 -12
  27. package/dist/{conversation-DF4rUcjb.js → conversation-DSo310Ps.js} +8 -1
  28. package/dist/{conversation-privacy-CY7JVjxn.js → conversation-privacy-CxlOCiUo.js} +1 -1
  29. package/dist/{credential-sync-VGAMUTGp.js → credential-sync-sm9fegLY.js} +29 -34
  30. package/dist/{credentialed-enF5Bf3y.js → credentialed-lukuD-cc.js} +2933 -1026
  31. package/dist/db-BU4LQD8s.d.ts +6 -0
  32. package/dist/{executor-TnWbvL09.js → executor-4vhvsMZJ.js} +83 -6
  33. package/dist/instrumentation.js +6 -2
  34. package/dist/{assistant-reply-UyzAfZQo.js → interruption-marker-CkncDT8d.js} +8 -2
  35. package/dist/{job-queue-DBpHmKnd.js → job-queue-In9QKz-I.js} +128 -3
  36. package/dist/{web-input-B-bMjD1V.js → list-BkgnXEEB.js} +1611 -817
  37. package/dist/{logging-Ce0mXZ8J.js → logging-DLSpkSSr.js} +11 -3
  38. package/dist/{message-VZ3DFpGb.js → message-BObyg59s.js} +4 -4
  39. package/dist/nitro.d.ts +1 -1
  40. package/dist/nitro.js +3 -3
  41. package/dist/{oauth-callback-server-CpqWLjVY.js → oauth-callback-server-B-WbSdoH.js} +2 -2
  42. package/dist/{prompt-BcCGlmg2.js → prompt-BW9jFsqZ.js} +1 -1
  43. package/dist/{registry-1BEIPdbT.js → registry-BgecKva0.js} +2 -2
  44. package/dist/{web-message-id-CJSUiksE.js → reporting-window-DVCD_10C.js} +1 -10
  45. package/dist/{resolve-BSHHkEGE.js → resolve-Cpa4Bu1O.js} +3 -3
  46. package/dist/{result-CwOpzqdh.js → result-D3TSmZZ8.js} +9 -9
  47. package/dist/{runner-CEaX04kz.js → runner-DS5OKhEp.js} +11 -11
  48. package/dist/{sandbox-egress-signals-BwtGHwqa.js → sandbox-egress-signals-CnDjY5rx.js} +1 -1
  49. package/dist/{schema-RsaBHxRH.js → schema-0050HFnB.js} +82 -5
  50. package/dist/schema-ZngqcOWM.d.ts +5813 -0
  51. package/dist/{service-account-6wC76eBZ.js → service-account-CKV2WTY7.js} +3 -3
  52. package/dist/{service-account-Dg4lIbHm.js → service-account-DGjnDw9y.js} +3 -3
  53. package/dist/{session-stop-tZOw9AJD.js → session-stop-Dam1EI7B.js} +527 -134
  54. package/dist/{skills-DzyGN7wd.js → skills-CfAnfImD.js} +2 -2
  55. package/dist/{spawn-6rokq7l_.js → spawn-cmq9p4Ly.js} +2 -2
  56. package/dist/{store-DcKkzrdt.js → store-C5PN02QO.js} +4 -4
  57. package/dist/{store-CG0VJyye.d.ts → store-xMuuXRE0.d.ts} +23 -9
  58. package/dist/store-zPEoJPBB.js +295 -0
  59. package/dist/{task-queue-Bg4RS2Gy.js → task-queue-_mf28dSu.js} +10 -5
  60. package/dist/{task-runner-8N5LIkSg.js → task-runner-DMI2prDO.js} +25 -14
  61. package/dist/{tasks-DDT-J3Rf.js → tasks-SB9z-VTU.js} +1551 -54
  62. package/dist/{thread-state-cprEaxgl.js → thread-state-BbbPh0Sn.js} +8 -18
  63. package/dist/{turn-failure-response-C8sMdsdX.js → turn-failure-response-BKKmnMB5.js} +11 -16
  64. package/dist/{turn-lifecycle-DaBVoz7h.js → turn-lifecycle-oP0kVeGz.js} +1 -1
  65. package/dist/{validation-C2lllinq.js → validation--JsAGLwc.js} +1 -1
  66. package/dist/{version-oaLfxxnf.js → version-jHwJp4hC.js} +1 -1
  67. package/dist/version.js +1 -1
  68. package/dist/{viewer-BpE6Z3qf.js → viewer-CpXTGlep.js} +329 -230
  69. package/dist/web-message-id-DlHNANtd.js +132 -0
  70. package/dist/{work-VX1SHWha.js → work-3N-GHY9m.js} +10 -10
  71. package/dist/{worker-hook-y_Y.js → worker-CCdbcItd.js} +30 -19
  72. package/migrations/0064_any_resource_event_triggers.sql +2 -0
  73. package/migrations/0065_user_customization.sql +1 -0
  74. package/migrations/0066_review_items.sql +53 -0
  75. package/migrations/0067_review_item_dedupe_tenant.sql +2 -0
  76. package/migrations/0068_review_item_slack_cards.sql +10 -0
  77. package/migrations/meta/0064_snapshot.json +3505 -0
  78. package/migrations/meta/0065_snapshot.json +3511 -0
  79. package/migrations/meta/0066_snapshot.json +4099 -0
  80. package/migrations/meta/0067_snapshot.json +3921 -0
  81. package/migrations/meta/0068_snapshot.json +4176 -0
  82. package/migrations/meta/_journal.json +36 -1
  83. package/package.json +19 -4
  84. package/dist/db-BnRqQ7G5.d.ts +0 -5
  85. package/dist/images-BKQqSi5W.js +0 -64
  86. package/dist/store-FALy-JQb.js +0 -1424
  87. package/dist/turn-session-routing-a9CHX-w6.js +0 -76
@@ -1,22 +1,22 @@
1
- import { a as completeText, f as unwrapCurrentInstruction, p as escapeXml, s as resolveGatewayModel, y as isProviderRetryError } from "./client-B0YZfxNy.js";
2
- import { n as botConfig } from "./config-C0UwYqDK.js";
3
- import { V as toOptionalString, f as logException, m as logWarn, o as getActiveTraceId, t as buildTurnFailureResponse, y as setSpanAttributes, z as isRecord } from "./logging-Ce0mXZ8J.js";
4
- import { c as toStoredSlackActor, g as parseSlackMessageTs } from "./actor-tlxzqaRe.js";
5
- import { S as bindProviderConversation, b as startLocationThread, d as getConversationStore, f as getDb, h as withConversationEventLock, l as getConversationEventStore, m as createSqlConversationEventStore, p as getSqlExecutor, w as addAgentTurnUsage } from "./viewer-BpE6Z3qf.js";
6
- import { d as juniorDestinations } from "./agent-invocations-CxKldAeB.js";
7
- import { a as juniorAutomationRuns, l as juniorLocationConfigurations, o as juniorAutomationVersions, s as juniorAutomations } from "./executor-TnWbvL09.js";
1
+ import { a as completeText, f as unwrapCurrentInstruction, p as escapeXml, s as resolveGatewayModel, y as isProviderRetryError } from "./client-C1yDYjoS.js";
2
+ import { i as getChatConfig, n as botConfig } from "./config-DrTeF80T.js";
3
+ import { B as isRecord, H as toOptionalString, V as toOptionalNumber, b as setSpanAttributes, h as logWarn, o as getActiveTraceContext, p as logException, t as buildTurnFailureResponse } from "./logging-DLSpkSSr.js";
4
+ import { c as parseStoredSlackActor, l as toStoredSlackActor, m as parseSlackMessageTs } from "./actor-CP9K2Idu.js";
5
+ import { B as parseDestination, H as sameDestination, N as addAgentTurnUsage, S as startLocationThread, _ as withConversationEventLock, d as getConversationEventStore, g as createSqlConversationEventStore, h as getSqlExecutor, j as bindProviderConversation, m as getDb, p as getConversationStore } from "./viewer-CpXTGlep.js";
6
+ import { d as juniorDestinations } from "./agent-invocations-C1RNOCs8.js";
7
+ import { d as juniorAutomations, l as juniorAutomationRuns, p as juniorLocationConfigurations, u as juniorAutomationVersions } from "./executor-4vhvsMZJ.js";
8
8
  import { d as contextProvenance, f as instructionActors, n as agentHistoryItemSchema, p as instructionProvenanceFor } from "./history-C84mlAwO.js";
9
- import { f as credentialContextForActor, h as credentialSubjectSchema } from "./registry-1BEIPdbT.js";
10
- import { n as pluginCatalogRuntime } from "./catalog-runtime-DAJjAbSx.js";
11
- import { An as createPluginLogger, Q as parseSandboxEgressAuthRequiredSignal, ar as agentsInstructionsUpdatedEvent, at as formatProviderLabel, ct as startOAuthFlow, ir as JUNIOR_NATIVE_EVENT_NAMESPACE, mn as ToolInputError, nr as createPluginCache, nt as resolveSandboxEgressProviderForHost, on as sleep, or as authenticationLinkedEvent, p as getPlugins, sr as authenticationUnlinkedEvent, t as executeCredentialedEgressRequest } from "./credentialed-enF5Bf3y.js";
12
- import { i as MUTATION_LOCK_TTL_MS, o as fenceLock, r as getStateAdapter, s as withLock } from "./adapter-eRFA_naf.js";
13
- import { N as messageCardRef } from "./conversation-DF4rUcjb.js";
14
- import { A as eventAutomationSchema, E as scheduledAutomationSchema, S as automationVersionSchema } from "./automation-BKpY9LPa.js";
9
+ import { f as credentialContextForActor, h as credentialSubjectSchema } from "./registry-BgecKva0.js";
10
+ import { n as pluginCatalogRuntime } from "./catalog-runtime-BD9GSpc6.js";
11
+ import { H as startOAuthFlow, In as createPluginLogger, bt as parseSandboxEgressAuthRequiredSignal, cr as createPluginCache, dr as agentsInstructionsUpdatedEvent, fr as authenticationLinkedEvent, p as getPlugins, pn as JUNIOR_THREAD_STATE_TTL_MS, pr as authenticationUnlinkedEvent, t as executeCredentialedEgressRequest, un as sleep$1, ur as JUNIOR_NATIVE_EVENT_NAMESPACE, wt as resolveSandboxEgressProviderForHost, yn as ToolInputError, z as formatProviderLabel } from "./credentialed-lukuD-cc.js";
12
+ import { a as MUTATION_LOCK_TTL_MS, c as withLock, i as getStateAdapter, r as getDefaultRedisStateAdapterFor, s as fenceLock } from "./adapter-04AcD2SO.js";
13
+ import { L as messageCardRef } from "./conversation-DSo310Ps.js";
14
+ import { A as eventAutomationSchema, E as scheduledAutomationSchema, S as automationVersionSchema } from "./automation-CmeFBSEh.js";
15
15
  import { isDeepStrictEqual } from "node:util";
16
16
  import { z } from "zod";
17
17
  import { createHash, randomUUID } from "node:crypto";
18
- import { and, asc, desc, eq, inArray, ne, or, sql } from "drizzle-orm";
19
- import { actorSchema, eventMatches, sourceSchema } from "@sentry/junior-plugin-api";
18
+ import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
19
+ import { actorSchema, anyResourceMatchFields, describeEventTrigger, destinationSchema, eventMatchSchema, eventMatches, eventTriggerChoices, eventTriggerProblems, normalizeEventIdentifier, normalizeEventMatch, sourceSchema } from "@sentry/junior-plugin-api";
20
20
  // src/chat/configuration/defaults.ts
21
21
  let installDefaults = {};
22
22
  function cloneDefaults(defaults) {
@@ -401,7 +401,11 @@ function createDurableLocationConfigurationService(args) {
401
401
  * Add new keys here as features graduate from private experiments; remove them
402
402
  * once they become stable defaults.
403
403
  */
404
- const EXPERIMENTAL_FEATURES = ["operator-tools", "passive-routing"];
404
+ const EXPERIMENTAL_FEATURES = [
405
+ "operator-tools",
406
+ "passive-routing",
407
+ "user-customization"
408
+ ];
405
409
  const EXPERIMENTAL_FEATURE_SET = new Set(EXPERIMENTAL_FEATURES);
406
410
  let configuredExperimental = {};
407
411
  function isExperimentalFeature(value) {
@@ -887,7 +891,8 @@ const SESSION_CONTEXT_SECTION_TAG = "runtime";
887
891
  const REQUEST_CONTEXT_SECTION_TAG = {
888
892
  pluginContext: "plugin-context",
889
893
  conversationNotes: "conversation-notes",
890
- disabledTools: "disabled-tools"
894
+ disabledTools: "disabled-tools",
895
+ userCustomization: "user-customization"
891
896
  };
892
897
  // src/chat/pi/transcript.ts
893
898
  const RUNTIME_TURN_CONTEXT_START = `<${TURN_CONTEXT_TAG}>`;
@@ -1038,7 +1043,7 @@ async function persistWithRetry(persist) {
1038
1043
  return;
1039
1044
  } catch (error) {
1040
1045
  lastError = error;
1041
- if (attempt < PERSIST_ATTEMPTS) await sleep(attempt * 100);
1046
+ if (attempt < PERSIST_ATTEMPTS) await sleep$1(attempt * 100);
1042
1047
  }
1043
1048
  throw lastError;
1044
1049
  }
@@ -1105,6 +1110,1168 @@ function buildTurnLimitResponse(eventId) {
1105
1110
  function buildTurnErrorResponse(error, eventId) {
1106
1111
  return isTurnExecutionLimitExceededError(error) ? buildTurnLimitResponse(eventId) : buildTurnFailureResponse(eventId);
1107
1112
  }
1113
+ // src/chat/task-execution/state.ts
1114
+ /**
1115
+ * Expiring conversation mailbox and execution index store.
1116
+ *
1117
+ * The conversation record owns pending inbound work, execution status, and the
1118
+ * lease. `conversation:active` feeds heartbeat recovery and contains every
1119
+ * non-idle conversation until the lease/status path makes it idle.
1120
+ * `conversation:by-activity` supports legacy SQL import only; SQL owns durable
1121
+ * conversation metadata in configured runtime.
1122
+ */
1123
+ const CONVERSATION_PREFIX = "junior:conversation:v2";
1124
+ const CONVERSATION_SCHEMA_VERSION = 2;
1125
+ const CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH = 1e4;
1126
+ const CONVERSATION_INDEX_LOCK_TTL_MS = 1e4;
1127
+ const CONVERSATION_INDEX_LOCK_WAIT_MS = 2e3;
1128
+ const CONVERSATION_INDEX_LOCK_RETRY_MS = 25;
1129
+ const CONVERSATION_MUTATION_LOCK_TTL_MS = 1e4;
1130
+ const CONVERSATION_MUTATION_WAIT_MS = 1e4;
1131
+ const CONVERSATION_MUTATION_RETRY_MS = 25;
1132
+ var InvalidConversationRecordError = class extends Error {
1133
+ constructor(conversationId) {
1134
+ super(`Conversation record is invalid for ${conversationId}`);
1135
+ this.name = "InvalidConversationRecordError";
1136
+ }
1137
+ };
1138
+ /** Return whether an error means a stored conversation record failed validation. */
1139
+ function isInvalidConversationRecordError(error) {
1140
+ return error instanceof InvalidConversationRecordError;
1141
+ }
1142
+ var ConversationMutationFencedError = class extends Error {
1143
+ constructor(conversationId) {
1144
+ super(`Conversation mutation lock was lost before write for ${conversationId}`);
1145
+ this.name = "ConversationMutationFencedError";
1146
+ }
1147
+ };
1148
+ const CONVERSATION_BY_ACTIVITY_INDEX_KEY = `${CONVERSATION_PREFIX}:by-activity`;
1149
+ const CONVERSATION_ACTIVE_INDEX_KEY = `${CONVERSATION_PREFIX}:active`;
1150
+ const CONVERSATION_WORK_LEASE_TTL_MS = 9e4;
1151
+ const CONVERSATION_WORK_CHECK_IN_INTERVAL_MS = 15e3;
1152
+ const CONVERSATION_WORK_STALE_ENQUEUE_MS = 6e4;
1153
+ const inboundMessageSourceSchema = z.enum([
1154
+ "api",
1155
+ "internal",
1156
+ "local",
1157
+ "plugin",
1158
+ "event",
1159
+ "scheduler",
1160
+ "slack",
1161
+ "web"
1162
+ ]);
1163
+ const agentInputSchema = z.object({
1164
+ attachments: z.array(z.unknown()).optional(),
1165
+ authorId: z.string().min(1).optional(),
1166
+ /**
1167
+ * Identity of the input author, resolved at ingress.
1168
+ *
1169
+ * TODO(dcramer): Set this at every ingress after every deployed worker
1170
+ * reads it. This schema is strict, so an older worker rejects the entry.
1171
+ */
1172
+ authorIdentityId: z.string().min(1).optional(),
1173
+ metadata: z.record(z.string(), z.unknown()).optional(),
1174
+ text: z.string()
1175
+ }).strict().refine((input) => input.text.trim() || input.attachments?.length, { message: "agent input requires text or attachments" });
1176
+ /** Durable delivery modes for pending inbound mailbox work. */
1177
+ const inboundMessageDeliverySchema = z.enum(["defer", "interrupt"]);
1178
+ /** Sentry trace propagation headers. A malformed value is dropped, not rejected. */
1179
+ const traceContextSchema = z.object({
1180
+ baggage: z.string().optional(),
1181
+ sentryTrace: z.string().min(1)
1182
+ }).optional().catch(void 0);
1183
+ /** Redis mailbox shape kept for deployed workers that still require this field. */
1184
+ const storedInboundMessageSchema = z.object({
1185
+ attemptCount: z.number().finite().optional(),
1186
+ conversationId: z.string().refine((value) => value.trim().length > 0),
1187
+ createdAtMs: z.number().finite(),
1188
+ delivery: inboundMessageDeliverySchema,
1189
+ destination: destinationSchema.optional(),
1190
+ inboundMessageId: z.string().refine((value) => value.trim().length > 0),
1191
+ injectedAtMs: z.number().finite().optional(),
1192
+ input: agentInputSchema,
1193
+ receivedAtMs: z.number().finite(),
1194
+ source: inboundMessageSourceSchema,
1195
+ traceContext: traceContextSchema
1196
+ }).strict().extend({ publishExternally: z.boolean() });
1197
+ function conversationKey(conversationId) {
1198
+ return `${CONVERSATION_PREFIX}:${conversationId}`;
1199
+ }
1200
+ function conversationStopKey(conversationId) {
1201
+ return `${CONVERSATION_PREFIX}:stop:${conversationId}`;
1202
+ }
1203
+ function indexLockKey(indexKey) {
1204
+ return `${indexKey}:lock`;
1205
+ }
1206
+ function mutationLockKey(conversationId) {
1207
+ return `${CONVERSATION_PREFIX}:mutation:${conversationId}`;
1208
+ }
1209
+ function now() {
1210
+ return Date.now();
1211
+ }
1212
+ function compareMessages(left, right) {
1213
+ return left.createdAtMs - right.createdAtMs || left.receivedAtMs - right.receivedAtMs || left.inboundMessageId.localeCompare(right.inboundMessageId);
1214
+ }
1215
+ function inputHasAttachments(input) {
1216
+ return Array.isArray(input.attachments) && input.attachments.length > 0;
1217
+ }
1218
+ /**
1219
+ * Upgrade a still-pending payload when a duplicate source event carries file
1220
+ * attachments the stored copy lacks (Slack `app_mention`/`message` twins),
1221
+ * keeping the stored ordering timestamps and delivery attempts.
1222
+ */
1223
+ function upgradedPendingMessage(stored, duplicate) {
1224
+ const promotesInterrupt = stored.delivery === "defer" && duplicate.delivery === "interrupt";
1225
+ const delivery = promotesInterrupt ? "interrupt" : stored.delivery;
1226
+ const input = promotesInterrupt ? {
1227
+ ...duplicate.input,
1228
+ ...inputHasAttachments(duplicate.input) || !inputHasAttachments(stored.input) ? void 0 : { attachments: stored.input.attachments }
1229
+ } : inputHasAttachments(duplicate.input) && !inputHasAttachments(stored.input) ? duplicate.input : stored.input;
1230
+ if (input === stored.input && delivery === stored.delivery) return stored;
1231
+ return {
1232
+ ...stored,
1233
+ delivery,
1234
+ input
1235
+ };
1236
+ }
1237
+ function compareIndexDescending(left, right) {
1238
+ return right.score - left.score || right.conversationId.localeCompare(left.conversationId);
1239
+ }
1240
+ function compareIndexAscending(left, right) {
1241
+ return left.score - right.score || left.conversationId.localeCompare(right.conversationId);
1242
+ }
1243
+ function uniqueStrings(values) {
1244
+ return [...new Set(values)];
1245
+ }
1246
+ function normalizeSource(value) {
1247
+ const parsed = inboundMessageSourceSchema.safeParse(value);
1248
+ return parsed.success ? parsed.data : void 0;
1249
+ }
1250
+ function normalizeExecutionStatus(value) {
1251
+ if (value === "paused") return value;
1252
+ if (value === "failed" || value === "idle" || value === "pending" || value === "running") return value;
1253
+ }
1254
+ function normalizeMessage(value) {
1255
+ const parsed = storedInboundMessageSchema.safeParse(value);
1256
+ if (!parsed.success) return;
1257
+ const { publishExternally: _publishExternally, traceContext, ...message } = parsed.data;
1258
+ return traceContext ? {
1259
+ ...message,
1260
+ traceContext
1261
+ } : message;
1262
+ }
1263
+ function storeMessage(message, publishExternally) {
1264
+ return {
1265
+ ...message,
1266
+ publishExternally
1267
+ };
1268
+ }
1269
+ /** Whether this is the final attempt before an unacked message is dead-lettered. */
1270
+ function isFinalAttempt(message) {
1271
+ return (message.attemptCount ?? 0) >= 4;
1272
+ }
1273
+ function normalizeActor(value) {
1274
+ return parseStoredSlackActor(value);
1275
+ }
1276
+ function normalizeLease(value) {
1277
+ if (!isRecord(value)) return;
1278
+ const token = toOptionalString(value.token);
1279
+ const acquiredAtMs = toOptionalNumber(value.acquiredAtMs);
1280
+ const lastCheckInAtMs = toOptionalNumber(value.lastCheckInAtMs);
1281
+ const expiresAtMs = toOptionalNumber(value.expiresAtMs);
1282
+ if (!token || typeof acquiredAtMs !== "number" || typeof lastCheckInAtMs !== "number" || typeof expiresAtMs !== "number") return;
1283
+ return {
1284
+ token,
1285
+ acquiredAtMs,
1286
+ lastCheckInAtMs,
1287
+ expiresAtMs
1288
+ };
1289
+ }
1290
+ function normalizeStop(value) {
1291
+ if (!isRecord(value)) return;
1292
+ const inboundMessageIds = Array.isArray(value.inboundMessageIds) ? uniqueStrings(value.inboundMessageIds.filter((id) => typeof id === "string")) : [];
1293
+ const runId = toOptionalString(value.runId);
1294
+ return runId ? {
1295
+ inboundMessageIds,
1296
+ runId
1297
+ } : void 0;
1298
+ }
1299
+ /** Decode execution state and repair idle records that still own work. */
1300
+ function normalizeExecution(conversationId, value) {
1301
+ if (!isRecord(value)) return;
1302
+ const status = normalizeExecutionStatus(value.status);
1303
+ if (!status) return;
1304
+ const pendingMessages = [];
1305
+ if (Array.isArray(value.pendingMessages)) {
1306
+ for (const rawMessage of value.pendingMessages) {
1307
+ const message = normalizeMessage(rawMessage);
1308
+ if (!message || message.conversationId !== conversationId) return;
1309
+ if (message.injectedAtMs !== void 0) continue;
1310
+ pendingMessages.push(message);
1311
+ }
1312
+ pendingMessages.sort(compareMessages);
1313
+ }
1314
+ const inboundMessageIds = Array.isArray(value.inboundMessageIds) ? uniqueStrings(value.inboundMessageIds.map((id) => typeof id === "string" ? id : void 0).filter((id) => Boolean(id))) : [];
1315
+ const lease = normalizeLease(value.lease);
1316
+ const runId = toOptionalString(value.runId);
1317
+ return {
1318
+ status: status === "idle" && lease ? "running" : status === "idle" && pendingMessages.length > 0 ? "pending" : status,
1319
+ inboundMessageIds: uniqueStrings([...inboundMessageIds, ...pendingMessages.map((message) => message.inboundMessageId)]),
1320
+ pendingCount: pendingMessages.length,
1321
+ pendingMessages,
1322
+ lease,
1323
+ lastCheckpointAtMs: toOptionalNumber(value.lastCheckpointAtMs),
1324
+ lastEnqueuedAtMs: toOptionalNumber(value.lastEnqueuedAtMs),
1325
+ lastProgressAtMs: toOptionalNumber(value.lastProgressAtMs),
1326
+ retryCount: toOptionalNumber(value.retryCount),
1327
+ runId,
1328
+ updatedAtMs: toOptionalNumber(value.updatedAtMs)
1329
+ };
1330
+ }
1331
+ /** Decode current conversation records. */
1332
+ function normalizeConversation(conversationId, value) {
1333
+ if (!isRecord(value) || value.schemaVersion !== CONVERSATION_SCHEMA_VERSION) return;
1334
+ const storedConversationId = toOptionalString(value.conversationId);
1335
+ const createdAtMs = toOptionalNumber(value.createdAtMs);
1336
+ const lastActivityAtMs = toOptionalNumber(value.lastActivityAtMs);
1337
+ const updatedAtMs = toOptionalNumber(value.updatedAtMs);
1338
+ const execution = normalizeExecution(conversationId, value.execution);
1339
+ const destination = value.destination === void 0 ? void 0 : parseDestination(value.destination);
1340
+ if (storedConversationId !== conversationId || typeof createdAtMs !== "number" || typeof lastActivityAtMs !== "number" || typeof updatedAtMs !== "number" || !execution || value.destination !== void 0 && !destination) return;
1341
+ if (execution.pendingMessages.length > 0 && execution.pendingMessages.some((message) => {
1342
+ if (!message.destination) return false;
1343
+ return !destination || !sameDestination(message.destination, destination);
1344
+ })) return;
1345
+ return {
1346
+ schemaVersion: CONVERSATION_SCHEMA_VERSION,
1347
+ conversationId,
1348
+ createdAtMs,
1349
+ lastActivityAtMs,
1350
+ updatedAtMs,
1351
+ execution,
1352
+ ...destination ? { destination } : void 0,
1353
+ ...toOptionalString(value.title) ? { title: toOptionalString(value.title) } : void 0,
1354
+ ...toOptionalString(value.channelName) ? { channelName: toOptionalString(value.channelName) } : void 0,
1355
+ ...normalizeActor(value.actor) ? { actor: normalizeActor(value.actor) } : void 0,
1356
+ ...normalizeSource(value.source) ? { source: normalizeSource(value.source) } : void 0
1357
+ };
1358
+ }
1359
+ function emptyConversation(args) {
1360
+ return {
1361
+ schemaVersion: CONVERSATION_SCHEMA_VERSION,
1362
+ conversationId: args.conversationId,
1363
+ createdAtMs: args.nowMs,
1364
+ lastActivityAtMs: args.nowMs,
1365
+ updatedAtMs: args.nowMs,
1366
+ ...args.destination ? { destination: args.destination } : void 0,
1367
+ ...args.source ? { source: args.source } : void 0,
1368
+ execution: {
1369
+ status: "idle",
1370
+ inboundMessageIds: [],
1371
+ pendingCount: 0,
1372
+ pendingMessages: [],
1373
+ updatedAtMs: args.nowMs
1374
+ }
1375
+ };
1376
+ }
1377
+ function isLeaseActive(lease, nowMs) {
1378
+ return Boolean(lease && lease.expiresAtMs > nowMs);
1379
+ }
1380
+ function pendingMessages(conversation) {
1381
+ return [...conversation.execution.pendingMessages].sort(compareMessages);
1382
+ }
1383
+ function isRunnableStatus(status) {
1384
+ return status !== "failed" && status !== "idle";
1385
+ }
1386
+ function hasRunnableWork(conversation) {
1387
+ return isRunnableStatus(conversation.execution.status) || pendingMessages(conversation).length > 0;
1388
+ }
1389
+ function executionWithPendingMessages(execution, pending) {
1390
+ const pendingMessages = [...pending].sort(compareMessages);
1391
+ const status = execution.status === "idle" && execution.lease ? "running" : execution.status === "idle" && pendingMessages.length > 0 ? "pending" : execution.status;
1392
+ return {
1393
+ ...execution,
1394
+ status,
1395
+ inboundMessageIds: uniqueStrings([...execution.inboundMessageIds, ...pendingMessages.map((message) => message.inboundMessageId)]),
1396
+ pendingMessages,
1397
+ pendingCount: pendingMessages.length
1398
+ };
1399
+ }
1400
+ function withExecutionUpdate(conversation, execution, nowMs) {
1401
+ return {
1402
+ ...conversation,
1403
+ updatedAtMs: nowMs,
1404
+ execution: {
1405
+ ...executionWithPendingMessages(execution, execution.pendingMessages),
1406
+ updatedAtMs: nowMs
1407
+ }
1408
+ };
1409
+ }
1410
+ async function getConnectedState(stateAdapter) {
1411
+ const state = stateAdapter ?? getStateAdapter();
1412
+ await state.connect();
1413
+ return state;
1414
+ }
1415
+ async function sleep(ms) {
1416
+ await new Promise((resolve) => {
1417
+ setTimeout(resolve, ms).unref?.();
1418
+ });
1419
+ }
1420
+ async function withIndexLock(state, indexKey, callback) {
1421
+ const startedAtMs = now();
1422
+ let lock;
1423
+ while (true) {
1424
+ lock = await state.acquireLock(indexLockKey(indexKey), CONVERSATION_INDEX_LOCK_TTL_MS);
1425
+ if (lock) break;
1426
+ if (now() - startedAtMs >= CONVERSATION_INDEX_LOCK_WAIT_MS) throw new Error(`Could not acquire conversation index lock for ${indexKey}`);
1427
+ await sleep(CONVERSATION_INDEX_LOCK_RETRY_MS);
1428
+ }
1429
+ try {
1430
+ return await callback();
1431
+ } finally {
1432
+ await state.releaseLock(lock);
1433
+ }
1434
+ }
1435
+ function normalizeIndexEntry(value) {
1436
+ if (!isRecord(value)) return;
1437
+ const conversationId = toOptionalString(value.conversationId);
1438
+ const score = toOptionalNumber(value.score);
1439
+ if (!conversationId || typeof score !== "number") return;
1440
+ return {
1441
+ conversationId,
1442
+ score
1443
+ };
1444
+ }
1445
+ function uniqueIndexEntries(value) {
1446
+ if (!Array.isArray(value)) return [];
1447
+ const entries = /* @__PURE__ */ new Map();
1448
+ for (const item of value) {
1449
+ const entry = normalizeIndexEntry(item);
1450
+ if (!entry) continue;
1451
+ const existing = entries.get(entry.conversationId);
1452
+ if (!existing || entry.score > existing.score) entries.set(entry.conversationId, entry);
1453
+ }
1454
+ return [...entries.values()];
1455
+ }
1456
+ function retainedIndexEntries(indexKey, entries) {
1457
+ if (indexKey === "junior:conversation:v2:by-activity") return entries.sort(compareIndexDescending).slice(0, CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH);
1458
+ if (indexKey === "junior:conversation:v2:active") return entries.sort(compareIndexAscending);
1459
+ throw new Error(`Unknown conversation index ${indexKey}`);
1460
+ }
1461
+ function redisIndexKey(indexKey) {
1462
+ const prefix = getChatConfig().state.keyPrefix;
1463
+ return [...prefix ? [prefix] : [], indexKey].join(":");
1464
+ }
1465
+ function parseRedisIndexEntries(values) {
1466
+ if (!Array.isArray(values)) return [];
1467
+ const entries = [];
1468
+ for (let index = 0; index < values.length; index += 2) {
1469
+ const conversationId = toOptionalString(values[index]);
1470
+ const score = typeof values[index + 1] === "number" ? values[index + 1] : Number(values[index + 1]);
1471
+ if (!conversationId || !Number.isFinite(score)) continue;
1472
+ entries.push({
1473
+ conversationId,
1474
+ score
1475
+ });
1476
+ }
1477
+ return entries;
1478
+ }
1479
+ function redisConversationIndexStore(client) {
1480
+ const upsertBoundedActivityScript = `
1481
+ redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2])
1482
+ redis.call("PEXPIRE", KEYS[1], ARGV[3])
1483
+ local extra = redis.call("ZCARD", KEYS[1]) - tonumber(ARGV[4])
1484
+ if extra > 0 then
1485
+ redis.call("ZREMRANGEBYRANK", KEYS[1], 0, extra - 1)
1486
+ end
1487
+ return 1
1488
+ `;
1489
+ return {
1490
+ async list(args) {
1491
+ const key = redisIndexKey(args.indexKey);
1492
+ const limit = args.limit;
1493
+ const offset = Math.max(0, args.offset ?? 0);
1494
+ if (limit === 0) return [];
1495
+ return parseRedisIndexEntries(args.scoreMax !== void 0 ? await client.sendCommand([
1496
+ "ZRANGEBYSCORE",
1497
+ key,
1498
+ "-inf",
1499
+ String(args.scoreMax),
1500
+ "WITHSCORES",
1501
+ ...limit !== void 0 || offset > 0 ? [
1502
+ "LIMIT",
1503
+ String(offset),
1504
+ String(limit ?? 1e9)
1505
+ ] : []
1506
+ ]) : await client.sendCommand([
1507
+ args.order === "asc" ? "ZRANGE" : "ZREVRANGE",
1508
+ key,
1509
+ String(offset),
1510
+ String(limit === void 0 ? -1 : offset + Math.max(0, limit - 1)),
1511
+ "WITHSCORES"
1512
+ ]));
1513
+ },
1514
+ async remove(args) {
1515
+ await client.sendCommand([
1516
+ "ZREM",
1517
+ redisIndexKey(args.indexKey),
1518
+ args.conversationId
1519
+ ]);
1520
+ },
1521
+ async upsert(args) {
1522
+ const key = redisIndexKey(args.indexKey);
1523
+ if (args.indexKey === "junior:conversation:v2:by-activity") {
1524
+ await client.sendCommand([
1525
+ "EVAL",
1526
+ upsertBoundedActivityScript,
1527
+ "1",
1528
+ key,
1529
+ String(args.score),
1530
+ args.conversationId,
1531
+ String(JUNIOR_THREAD_STATE_TTL_MS),
1532
+ String(CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH)
1533
+ ]);
1534
+ return;
1535
+ }
1536
+ if (args.indexKey === "junior:conversation:v2:active") {
1537
+ await client.sendCommand([
1538
+ "ZADD",
1539
+ key,
1540
+ String(args.score),
1541
+ args.conversationId
1542
+ ]);
1543
+ await client.sendCommand([
1544
+ "PEXPIRE",
1545
+ key,
1546
+ String(JUNIOR_THREAD_STATE_TTL_MS)
1547
+ ]);
1548
+ return;
1549
+ }
1550
+ throw new Error(`Unknown conversation index ${args.indexKey}`);
1551
+ }
1552
+ };
1553
+ }
1554
+ function emulatedConversationIndexStore(state) {
1555
+ const readIndex = async (indexKey) => uniqueIndexEntries(await state.get(indexKey));
1556
+ const writeIndex = async (indexKey, entries) => {
1557
+ await state.set(indexKey, entries, JUNIOR_THREAD_STATE_TTL_MS);
1558
+ };
1559
+ return {
1560
+ async list(args) {
1561
+ const entries = (await readIndex(args.indexKey)).filter((entry) => args.scoreMax === void 0 ? true : entry.score <= args.scoreMax).sort(args.order === "asc" ? compareIndexAscending : compareIndexDescending);
1562
+ const offset = Math.max(0, args.offset ?? 0);
1563
+ return entries.slice(offset, args.limit === void 0 ? entries.length : offset + args.limit);
1564
+ },
1565
+ async remove(args) {
1566
+ await withIndexLock(state, args.indexKey, async () => {
1567
+ const entries = await readIndex(args.indexKey);
1568
+ const next = entries.filter((entry) => entry.conversationId !== args.conversationId);
1569
+ if (next.length === entries.length) return;
1570
+ await writeIndex(args.indexKey, next);
1571
+ });
1572
+ },
1573
+ async upsert(args) {
1574
+ await withIndexLock(state, args.indexKey, async () => {
1575
+ const withoutCurrent = (await readIndex(args.indexKey)).filter((entry) => entry.conversationId !== args.conversationId);
1576
+ const next = retainedIndexEntries(args.indexKey, [...withoutCurrent, {
1577
+ conversationId: args.conversationId,
1578
+ score: args.score
1579
+ }]);
1580
+ await writeIndex(args.indexKey, next);
1581
+ });
1582
+ }
1583
+ };
1584
+ }
1585
+ async function getConversationIndexStore(state) {
1586
+ const redisStateAdapter = await getDefaultRedisStateAdapterFor(state);
1587
+ if (redisStateAdapter) return redisConversationIndexStore(redisStateAdapter.getClient());
1588
+ return emulatedConversationIndexStore(state);
1589
+ }
1590
+ async function upsertIndexEntry(args) {
1591
+ await (await getConversationIndexStore(args.state)).upsert({
1592
+ conversationId: args.conversationId,
1593
+ indexKey: args.indexKey,
1594
+ score: args.score
1595
+ });
1596
+ }
1597
+ async function removeIndexEntry(args) {
1598
+ await (await getConversationIndexStore(args.state)).remove({
1599
+ conversationId: args.conversationId,
1600
+ indexKey: args.indexKey
1601
+ });
1602
+ }
1603
+ async function acquireMutationLock(state, conversationId) {
1604
+ const startedAtMs = now();
1605
+ while (true) {
1606
+ const lock = await state.acquireLock(mutationLockKey(conversationId), CONVERSATION_MUTATION_LOCK_TTL_MS);
1607
+ if (lock) return lock;
1608
+ if (now() - startedAtMs >= CONVERSATION_MUTATION_WAIT_MS) throw new Error(`Could not acquire conversation mutation lock for ${conversationId}`);
1609
+ await sleep(CONVERSATION_MUTATION_RETRY_MS);
1610
+ }
1611
+ }
1612
+ async function withConversationMutation(args, callback) {
1613
+ const state = await getConnectedState(args.state);
1614
+ const lock = await acquireMutationLock(state, args.conversationId);
1615
+ try {
1616
+ return await callback(state, lock);
1617
+ } finally {
1618
+ await state.releaseLock(lock);
1619
+ }
1620
+ }
1621
+ async function readConversation(state, conversationId) {
1622
+ const raw = await state.get(conversationKey(conversationId));
1623
+ if (raw == null) return;
1624
+ const conversation = normalizeConversation(conversationId, raw);
1625
+ if (!conversation) throw new InvalidConversationRecordError(conversationId);
1626
+ return conversation;
1627
+ }
1628
+ /**
1629
+ * Persist a conversation and refresh its reporting and active-recovery indexes.
1630
+ *
1631
+ * Conversation writes are lock-serialized blind whole-record writes, so the
1632
+ * mutation lock is re-validated (and extended) immediately before the write:
1633
+ * a stalled mutator that lost its lock must fail loudly instead of clobbering
1634
+ * a newer record with a stale copy.
1635
+ */
1636
+ async function writeConversation(state, lock, conversation) {
1637
+ const execution = executionWithPendingMessages(conversation.execution, conversation.execution.pendingMessages);
1638
+ const next = {
1639
+ ...conversation,
1640
+ execution
1641
+ };
1642
+ await fenceConversationMutation(state, lock, next.conversationId);
1643
+ const hasSlackDestination = next.destination?.platform === "slack";
1644
+ const stored = {
1645
+ ...next,
1646
+ execution: {
1647
+ ...next.execution,
1648
+ pendingMessages: next.execution.pendingMessages.map((message) => storeMessage(message, hasSlackDestination && (message.source === "slack" || message.source === "event" || message.source === "plugin" || message.source === "scheduler")))
1649
+ }
1650
+ };
1651
+ await state.set(conversationKey(next.conversationId), stored, JUNIOR_THREAD_STATE_TTL_MS);
1652
+ await upsertIndexEntry({
1653
+ state,
1654
+ indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY,
1655
+ conversationId: next.conversationId,
1656
+ score: next.lastActivityAtMs
1657
+ });
1658
+ if (!hasRunnableWork(next)) {
1659
+ await removeIndexEntry({
1660
+ state,
1661
+ indexKey: CONVERSATION_ACTIVE_INDEX_KEY,
1662
+ conversationId: next.conversationId
1663
+ });
1664
+ return;
1665
+ }
1666
+ await upsertIndexEntry({
1667
+ state,
1668
+ indexKey: CONVERSATION_ACTIVE_INDEX_KEY,
1669
+ conversationId: next.conversationId,
1670
+ score: next.execution.updatedAtMs ?? next.updatedAtMs
1671
+ });
1672
+ }
1673
+ function assertSameOptionalConversationDestination(args) {
1674
+ if (!args.next || !args.current) return;
1675
+ if (sameDestination(args.current, args.next)) return;
1676
+ throw new Error(`Conversation destination changed for ${args.conversationId}`);
1677
+ }
1678
+ function conversationWorkState(conversation) {
1679
+ const lease = conversation.execution.lease;
1680
+ return {
1681
+ ...conversation,
1682
+ lastEnqueuedAtMs: conversation.execution.lastEnqueuedAtMs,
1683
+ ...lease ? { lease: {
1684
+ acquiredAtMs: lease.acquiredAtMs,
1685
+ lastCheckInAtMs: lease.lastCheckInAtMs,
1686
+ leaseExpiresAtMs: lease.expiresAtMs,
1687
+ leaseToken: lease.token
1688
+ } } : void 0,
1689
+ messages: pendingMessages(conversation),
1690
+ needsRun: hasRunnableWork(conversation)
1691
+ };
1692
+ }
1693
+ /** Return a persisted conversation record, if one exists. */
1694
+ async function getConversation(args) {
1695
+ return await readConversation(await getConnectedState(args.state), args.conversationId);
1696
+ }
1697
+ /** Return a persisted conversation work record, if one exists. */
1698
+ async function getConversationWorkState(args) {
1699
+ const conversation = await getConversation(args);
1700
+ return conversation ? conversationWorkState(conversation) : void 0;
1701
+ }
1702
+ /** Return whether a conversation has pending or resumable execution work. */
1703
+ function hasRunnableConversationWork(conversation) {
1704
+ return hasRunnableWork(conversation);
1705
+ }
1706
+ async function appendInboundMessageWithAdmission(args) {
1707
+ const nowMs = args.nowMs ?? now();
1708
+ return await withConversationMutation({
1709
+ conversationId: args.message.conversationId,
1710
+ state: args.state
1711
+ }, async (state, lock) => {
1712
+ const existing = await readConversation(state, args.message.conversationId);
1713
+ const current = existing ?? emptyConversation({
1714
+ conversationId: args.message.conversationId,
1715
+ destination: args.message.destination,
1716
+ nowMs,
1717
+ source: args.message.source
1718
+ });
1719
+ if (existing) assertSameOptionalConversationDestination({
1720
+ conversationId: args.message.conversationId,
1721
+ current: current.destination,
1722
+ next: args.message.destination
1723
+ });
1724
+ const existingPending = current.execution.pendingMessages.some((message) => message.inboundMessageId === args.message.inboundMessageId);
1725
+ if (current.execution.inboundMessageIds.includes(args.message.inboundMessageId)) {
1726
+ if (!existingPending) return { status: "duplicate" };
1727
+ const nextStatus = current.execution.status === "idle" ? "pending" : current.execution.status;
1728
+ await writeConversation(state, lock, withExecutionUpdate(current, {
1729
+ ...current.execution,
1730
+ status: nextStatus,
1731
+ pendingMessages: current.execution.pendingMessages.map((message) => message.inboundMessageId === args.message.inboundMessageId ? upgradedPendingMessage(message, args.message) : message)
1732
+ }, nowMs));
1733
+ return { status: "duplicate" };
1734
+ }
1735
+ if (args.exclusive && hasRunnableWork(current)) return { status: "active" };
1736
+ let status;
1737
+ if (current.execution.lease) status = current.execution.status === "running" ? "running" : "paused";
1738
+ else if (current.execution.status === "paused" && args.message.delivery === "defer") status = "paused";
1739
+ else status = "pending";
1740
+ await writeConversation(state, lock, withExecutionUpdate({
1741
+ ...current,
1742
+ destination: current.destination ?? args.message.destination,
1743
+ source: current.source ?? args.message.source,
1744
+ lastActivityAtMs: nowMs
1745
+ }, {
1746
+ ...current.execution,
1747
+ status,
1748
+ inboundMessageIds: [...current.execution.inboundMessageIds, args.message.inboundMessageId],
1749
+ pendingMessages: [...current.execution.pendingMessages, args.message].sort(compareMessages)
1750
+ }, nowMs));
1751
+ return { status: "appended" };
1752
+ });
1753
+ }
1754
+ /** Persist one inbound message idempotently in its conversation mailbox. */
1755
+ async function appendInboundMessage(args) {
1756
+ const result = await appendInboundMessageWithAdmission({
1757
+ ...args,
1758
+ exclusive: false
1759
+ });
1760
+ if (result.status === "active") throw new Error("Non-exclusive mailbox append returned active");
1761
+ return result;
1762
+ }
1763
+ /** Append only when the Conversation has no other runnable work. */
1764
+ async function appendExclusiveInboundMessage(args) {
1765
+ return await appendInboundMessageWithAdmission({
1766
+ ...args,
1767
+ exclusive: true
1768
+ });
1769
+ }
1770
+ /** Mark a conversation runnable when there is no new mailbox message. */
1771
+ async function requestConversationWork(args) {
1772
+ const nowMs = args.nowMs ?? now();
1773
+ return await withConversationMutation(args, async (state, lock) => {
1774
+ const existing = await readConversation(state, args.conversationId);
1775
+ if (existing) assertSameOptionalConversationDestination({
1776
+ conversationId: args.conversationId,
1777
+ current: existing.destination,
1778
+ next: args.destination
1779
+ });
1780
+ const current = existing ?? emptyConversation({
1781
+ conversationId: args.conversationId,
1782
+ destination: args.destination,
1783
+ nowMs
1784
+ });
1785
+ const status = current.execution.lease ? "paused" : "pending";
1786
+ await writeConversation(state, lock, withExecutionUpdate({
1787
+ ...current,
1788
+ destination: current.destination ?? args.destination
1789
+ }, {
1790
+ ...current.execution,
1791
+ status
1792
+ }, nowMs));
1793
+ return { status: existing === void 0 ? "created" : "updated" };
1794
+ });
1795
+ }
1796
+ /** Record that a wake-up nudge was accepted for the conversation. */
1797
+ async function markConversationWorkEnqueued(args) {
1798
+ const nowMs = args.nowMs ?? now();
1799
+ await withConversationMutation(args, async (state, lock) => {
1800
+ const current = await readConversation(state, args.conversationId);
1801
+ if (!current) return;
1802
+ await writeConversation(state, lock, withExecutionUpdate(current, {
1803
+ ...current.execution,
1804
+ lastEnqueuedAtMs: nowMs
1805
+ }, nowMs));
1806
+ });
1807
+ }
1808
+ /** Clear a wake marker after its queue delivery finds no runnable work. */
1809
+ async function clearConsumedConversationWake(args) {
1810
+ const nowMs = args.nowMs ?? now();
1811
+ return await withConversationMutation(args, async (state, lock) => {
1812
+ const current = await readConversation(state, args.conversationId);
1813
+ if (!current || hasRunnableWork(current) || current.execution.lastEnqueuedAtMs === void 0) return false;
1814
+ await writeConversation(state, lock, withExecutionUpdate(current, {
1815
+ ...current.execution,
1816
+ lastEnqueuedAtMs: void 0
1817
+ }, nowMs));
1818
+ return true;
1819
+ });
1820
+ }
1821
+ /** Try to acquire the durable execution lease for one conversation. */
1822
+ async function startConversationWork(args) {
1823
+ const nowMs = args.nowMs ?? now();
1824
+ return await withConversationMutation(args, async (state, lock) => {
1825
+ const current = await readConversation(state, args.conversationId);
1826
+ if (!current) return { status: "no_work" };
1827
+ if (isLeaseActive(current.execution.lease, nowMs)) return {
1828
+ status: "active",
1829
+ leaseExpiresAtMs: current.execution.lease.expiresAtMs
1830
+ };
1831
+ if (!hasRunnableWork(current)) return { status: "no_work" };
1832
+ const lease = {
1833
+ token: randomUUID(),
1834
+ acquiredAtMs: nowMs,
1835
+ lastCheckInAtMs: nowMs,
1836
+ expiresAtMs: nowMs + CONVERSATION_WORK_LEASE_TTL_MS
1837
+ };
1838
+ const startsNewRun = current.execution.runId === void 0;
1839
+ await writeConversation(state, lock, withExecutionUpdate(current, {
1840
+ ...current.execution,
1841
+ lease,
1842
+ status: current.execution.status === "paused" ? "paused" : "running",
1843
+ runId: current.execution.runId ?? randomUUID(),
1844
+ lastEnqueuedAtMs: void 0,
1845
+ retryCount: startsNewRun ? 0 : current.execution.retryCount
1846
+ }, nowMs));
1847
+ return {
1848
+ status: "acquired",
1849
+ leaseToken: lease.token,
1850
+ leaseExpiresAtMs: lease.expiresAtMs
1851
+ };
1852
+ });
1853
+ }
1854
+ /** Extend the durable execution lease when the worker checks in. */
1855
+ async function checkInConversationWork(args) {
1856
+ const nowMs = args.nowMs ?? now();
1857
+ return await withConversationMutation(args, async (state, lock) => {
1858
+ const current = await readConversation(state, args.conversationId);
1859
+ if (!current || current.execution.lease?.token !== args.leaseToken) return false;
1860
+ await writeConversation(state, lock, withExecutionUpdate(current, {
1861
+ ...current.execution,
1862
+ lease: {
1863
+ ...current.execution.lease,
1864
+ lastCheckInAtMs: nowMs,
1865
+ expiresAtMs: nowMs + CONVERSATION_WORK_LEASE_TTL_MS
1866
+ }
1867
+ }, nowMs));
1868
+ return true;
1869
+ });
1870
+ }
1871
+ /**
1872
+ * Resolve pending mailbox entries after the caller accepts responsibility.
1873
+ *
1874
+ * Returning ids acknowledges only that subset; returning nothing acknowledges
1875
+ * every pending entry passed to the handler.
1876
+ */
1877
+ async function drainConversationMailbox(args) {
1878
+ const nowMs = args.nowMs ?? now();
1879
+ const pending = await withConversationMutation(args, async (state) => {
1880
+ const current = await readConversation(state, args.conversationId);
1881
+ if (!current || current.execution.lease?.token !== args.leaseToken) throw new Error(`Conversation lease is not held for ${args.conversationId}`);
1882
+ return pendingMessages(current);
1883
+ });
1884
+ if (pending.length === 0) return {
1885
+ changed: false,
1886
+ messages: []
1887
+ };
1888
+ const result = await args.handle(pending);
1889
+ const pendingIds = new Set(pending.map((message) => message.inboundMessageId));
1890
+ const acknowledgedIds = new Set(result ?? pending.map((message) => message.inboundMessageId));
1891
+ for (const inboundMessageId of acknowledgedIds) if (!pendingIds.has(inboundMessageId)) throw new Error(`Conversation mailbox drain result is not pending for ${args.conversationId}`);
1892
+ if (acknowledgedIds.size === 0) return {
1893
+ changed: false,
1894
+ messages: []
1895
+ };
1896
+ await withConversationMutation(args, async (state, lock) => {
1897
+ const current = await readConversation(state, args.conversationId);
1898
+ if (!current || current.execution.lease?.token !== args.leaseToken) throw new Error(`Conversation lease is not held for ${args.conversationId}`);
1899
+ const pendingMessages = current.execution.pendingMessages.filter((message) => !acknowledgedIds.has(message.inboundMessageId));
1900
+ await writeConversation(state, lock, withExecutionUpdate(current, {
1901
+ ...current.execution,
1902
+ status: current.execution.status === "pending" && pendingMessages.length === 0 ? "running" : current.execution.status,
1903
+ lastProgressAtMs: nowMs,
1904
+ retryCount: 0,
1905
+ pendingMessages
1906
+ }, nowMs));
1907
+ });
1908
+ return {
1909
+ changed: true,
1910
+ messages: pending.filter((message) => acknowledgedIds.has(message.inboundMessageId))
1911
+ };
1912
+ }
1913
+ /** Acknowledge leased mailbox entries after the handler accepts responsibility. */
1914
+ async function ackMessages(args) {
1915
+ const nowMs = args.nowMs ?? now();
1916
+ const inboundMessageIds = new Set(args.inboundMessageIds);
1917
+ return await withConversationMutation(args, async (state, lock) => {
1918
+ const current = await readConversation(state, args.conversationId);
1919
+ if (!current || current.execution.lease?.token !== args.leaseToken) return false;
1920
+ if (inboundMessageIds.size === 0) return true;
1921
+ const pendingMessages = current.execution.pendingMessages.filter((message) => !inboundMessageIds.has(message.inboundMessageId));
1922
+ if (pendingMessages.length === current.execution.pendingMessages.length) return true;
1923
+ await writeConversation(state, lock, withExecutionUpdate(current, {
1924
+ ...current.execution,
1925
+ lastProgressAtMs: nowMs,
1926
+ retryCount: 0,
1927
+ pendingMessages
1928
+ }, nowMs));
1929
+ return true;
1930
+ });
1931
+ }
1932
+ function isHumanFacingMessage(message) {
1933
+ return message.source === "web" || message.source === "slack";
1934
+ }
1935
+ async function readConversationStop(state, conversationId) {
1936
+ return normalizeStop(await state.get(conversationStopKey(conversationId)));
1937
+ }
1938
+ async function fenceConversationMutation(state, lock, conversationId) {
1939
+ if (!await state.extendLock(lock, CONVERSATION_MUTATION_LOCK_TTL_MS)) throw new ConversationMutationFencedError(conversationId);
1940
+ }
1941
+ /** Return whether one Conversation run has a durable stop request. */
1942
+ async function hasConversationStop(args) {
1943
+ return (await readConversationStop(await getConnectedState(args.state), args.conversationId))?.runId === args.runId;
1944
+ }
1945
+ /** Persist a stop request for the current run without process affinity. */
1946
+ async function stopConversationWork(args) {
1947
+ const nowMs = args.nowMs ?? now();
1948
+ return await withConversationMutation(args, async (state, lock) => {
1949
+ const current = await readConversation(state, args.conversationId);
1950
+ if (!current || !hasRunnableWork(current)) return { status: "no_work" };
1951
+ const runId = current.execution.runId ?? randomUUID();
1952
+ const pendingMessages = current.execution.pendingMessages.filter(isHumanFacingMessage);
1953
+ const inboundMessageIds = pendingMessages.map((message) => message.inboundMessageId);
1954
+ if (current.execution.runId === void 0) await writeConversation(state, lock, withExecutionUpdate(current, {
1955
+ ...current.execution,
1956
+ runId
1957
+ }, nowMs));
1958
+ await fenceConversationMutation(state, lock, args.conversationId);
1959
+ await state.set(conversationStopKey(args.conversationId), {
1960
+ inboundMessageIds,
1961
+ runId
1962
+ }, JUNIOR_THREAD_STATE_TTL_MS);
1963
+ return {
1964
+ runId,
1965
+ status: "requested",
1966
+ pendingMessages
1967
+ };
1968
+ });
1969
+ }
1970
+ /** Clear one observed stop request and discard older human-facing mailbox work. */
1971
+ async function completeConversationStop(args) {
1972
+ const nowMs = args.nowMs ?? now();
1973
+ return await withConversationMutation(args, async (state, lock) => {
1974
+ const current = await readConversation(state, args.conversationId);
1975
+ if (!current || current.execution.lease?.token !== args.leaseToken) return {
1976
+ status: "lost_lease",
1977
+ removedInboundMessageIds: []
1978
+ };
1979
+ const stop = await readConversationStop(state, args.conversationId);
1980
+ if (!stop || stop.runId !== args.runId) return {
1981
+ status: "none",
1982
+ removedInboundMessageIds: []
1983
+ };
1984
+ const stoppedInboundMessageIds = new Set(stop.inboundMessageIds);
1985
+ const removedInboundMessageIds = [];
1986
+ const pendingMessages = current.execution.pendingMessages.filter((message) => {
1987
+ const shouldStop = stoppedInboundMessageIds.has(message.inboundMessageId);
1988
+ if (shouldStop) removedInboundMessageIds.push(message.inboundMessageId);
1989
+ return !shouldStop;
1990
+ });
1991
+ await writeConversation(state, lock, withExecutionUpdate(current, {
1992
+ ...current.execution,
1993
+ lastEnqueuedAtMs: pendingMessages.length === 0 ? void 0 : current.execution.lastEnqueuedAtMs,
1994
+ pendingMessages
1995
+ }, nowMs));
1996
+ await fenceConversationMutation(state, lock, args.conversationId);
1997
+ await state.delete(conversationStopKey(args.conversationId));
1998
+ return {
1999
+ status: "cleared",
2000
+ removedInboundMessageIds
2001
+ };
2002
+ });
2003
+ }
2004
+ /** Cancel human-facing pending mailbox rows without requiring a worker lease. */
2005
+ async function cancelHumanFacingPendingMessages(args) {
2006
+ const nowMs = args.nowMs ?? now();
2007
+ const requestedIds = args.inboundMessageIds === void 0 ? void 0 : new Set(args.inboundMessageIds);
2008
+ return await withConversationMutation(args, async (state, lock) => {
2009
+ const current = await readConversation(state, args.conversationId);
2010
+ if (!current) return {
2011
+ cancelledInboundMessageIds: [],
2012
+ cancelledMessages: []
2013
+ };
2014
+ const cancelledInboundMessageIds = [];
2015
+ const cancelledMessages = [];
2016
+ const pendingMessages = [];
2017
+ for (const message of current.execution.pendingMessages) {
2018
+ const isRequested = requestedIds === void 0 || requestedIds.has(message.inboundMessageId);
2019
+ const isInSnapshot = args.receivedBeforeMs === void 0 || message.receivedAtMs <= args.receivedBeforeMs;
2020
+ if (isHumanFacingMessage(message) && isRequested && isInSnapshot) {
2021
+ cancelledInboundMessageIds.push(message.inboundMessageId);
2022
+ cancelledMessages.push(message);
2023
+ continue;
2024
+ }
2025
+ pendingMessages.push(message);
2026
+ }
2027
+ if (cancelledInboundMessageIds.length === 0) return {
2028
+ cancelledInboundMessageIds,
2029
+ cancelledMessages
2030
+ };
2031
+ const becomesIdle = current.execution.status === "pending" && pendingMessages.length === 0;
2032
+ await writeConversation(state, lock, withExecutionUpdate(current, {
2033
+ ...current.execution,
2034
+ lastEnqueuedAtMs: pendingMessages.length === 0 ? void 0 : current.execution.lastEnqueuedAtMs,
2035
+ pendingMessages,
2036
+ retryCount: becomesIdle ? 0 : current.execution.retryCount,
2037
+ runId: becomesIdle ? void 0 : current.execution.runId,
2038
+ status: becomesIdle ? "idle" : current.execution.status
2039
+ }, nowMs));
2040
+ return {
2041
+ cancelledInboundMessageIds,
2042
+ cancelledMessages
2043
+ };
2044
+ });
2045
+ }
2046
+ /**
2047
+ * Mark one author's queued web messages as interrupts without a worker lease.
2048
+ *
2049
+ * The active Turn drains them at its next model boundary when it belongs to the
2050
+ * same author. Otherwise they start the next Turn ahead of deferred input.
2051
+ */
2052
+ async function steerWebPendingMessages(args) {
2053
+ const nowMs = args.nowMs ?? now();
2054
+ const requestedIds = new Set(args.inboundMessageIds);
2055
+ return await withConversationMutation(args, async (state, lock) => {
2056
+ const current = await readConversation(state, args.conversationId);
2057
+ if (!current) return { steeredInboundMessageIds: [] };
2058
+ const steeredInboundMessageIds = [];
2059
+ const pendingMessages = current.execution.pendingMessages.map((message) => {
2060
+ if (message.source !== "web" || message.delivery !== "defer" || message.input.authorId !== args.authorId || !requestedIds.has(message.inboundMessageId)) return message;
2061
+ steeredInboundMessageIds.push(message.inboundMessageId);
2062
+ return {
2063
+ ...message,
2064
+ delivery: "interrupt"
2065
+ };
2066
+ });
2067
+ if (steeredInboundMessageIds.length === 0) return { steeredInboundMessageIds };
2068
+ await writeConversation(state, lock, withExecutionUpdate(current, {
2069
+ ...current.execution,
2070
+ pendingMessages
2071
+ }, nowMs));
2072
+ return { steeredInboundMessageIds };
2073
+ });
2074
+ }
2075
+ /** Mark the leased conversation as needing another queue-delivered slice. */
2076
+ async function requestAnotherSlice(args) {
2077
+ const nowMs = args.nowMs ?? now();
2078
+ return await withConversationMutation(args, async (state, lock) => {
2079
+ const current = await readConversation(state, args.conversationId);
2080
+ if (!current || current.execution.lease?.token !== args.leaseToken) return false;
2081
+ assertSameOptionalConversationDestination({
2082
+ conversationId: args.conversationId,
2083
+ current: current.destination,
2084
+ next: args.destination
2085
+ });
2086
+ await writeConversation(state, lock, withExecutionUpdate(current, {
2087
+ ...current.execution,
2088
+ status: "paused"
2089
+ }, nowMs));
2090
+ return true;
2091
+ });
2092
+ }
2093
+ /** Begin a requested turn resume under the worker's existing lease. */
2094
+ async function beginConversationResume(args) {
2095
+ const nowMs = args.nowMs ?? now();
2096
+ return await withConversationMutation(args, async (state, lock) => {
2097
+ const current = await readConversation(state, args.conversationId);
2098
+ if (!current || current.execution.lease?.token !== args.leaseToken || current.execution.status !== "paused") return false;
2099
+ await writeConversation(state, lock, withExecutionUpdate(current, {
2100
+ ...current.execution,
2101
+ status: "running"
2102
+ }, nowMs));
2103
+ return true;
2104
+ });
2105
+ }
2106
+ /** Release the durable execution lease without changing completion state. */
2107
+ async function releaseConversationWork(args) {
2108
+ const nowMs = args.nowMs ?? now();
2109
+ return await withConversationMutation(args, async (state, lock) => {
2110
+ const current = await readConversation(state, args.conversationId);
2111
+ if (!current || current.execution.lease?.token !== args.leaseToken) return false;
2112
+ await writeConversation(state, lock, withExecutionUpdate(current, {
2113
+ ...current.execution,
2114
+ lease: void 0,
2115
+ status: current.execution.status === "running" ? "pending" : current.execution.status
2116
+ }, nowMs));
2117
+ return true;
2118
+ });
2119
+ }
2120
+ /** Count one failed execution attempt and stop the conversation when retries are exhausted. */
2121
+ async function recordConversationRetry(args) {
2122
+ const nowMs = args.nowMs ?? now();
2123
+ return await withConversationMutation(args, async (state, lock) => {
2124
+ const current = await readConversation(state, args.conversationId);
2125
+ if (!current || current.execution.lease?.token !== args.leaseToken) return "lost_lease";
2126
+ const count = (current.execution.retryCount ?? 0) + 1;
2127
+ const stopped = count >= 5;
2128
+ await writeConversation(state, lock, withExecutionUpdate(current, {
2129
+ ...current.execution,
2130
+ lastEnqueuedAtMs: void 0,
2131
+ lease: stopped ? void 0 : current.execution.lease,
2132
+ retryCount: count,
2133
+ pendingMessages: stopped ? [] : current.execution.pendingMessages,
2134
+ runId: stopped ? void 0 : current.execution.runId,
2135
+ status: stopped ? "failed" : "paused"
2136
+ }, nowMs));
2137
+ return stopped ? "stopped" : "recorded";
2138
+ });
2139
+ }
2140
+ /** Finish a leased conversation and report whether runnable work remains. */
2141
+ async function completeConversationWork(args) {
2142
+ const nowMs = args.nowMs ?? now();
2143
+ return await withConversationMutation(args, async (state, lock) => {
2144
+ const current = await readConversation(state, args.conversationId);
2145
+ if (!current || current.execution.lease?.token !== args.leaseToken) return "lost_lease";
2146
+ const hasPending = pendingMessages(current).length > 0;
2147
+ const stopped = args.resumeIfStopped === true && (await readConversationStop(state, args.conversationId))?.runId === current.execution.runId;
2148
+ const needsRun = current.execution.status === "paused" || stopped;
2149
+ const runnable = needsRun || hasPending;
2150
+ await writeConversation(state, lock, withExecutionUpdate(current, {
2151
+ ...current.execution,
2152
+ lease: void 0,
2153
+ status: needsRun ? "paused" : hasPending ? "pending" : "idle",
2154
+ lastProgressAtMs: args.madeProgress === false ? current.execution.lastProgressAtMs : nowMs,
2155
+ retryCount: runnable || args.madeProgress === false ? current.execution.retryCount : 0,
2156
+ runId: runnable ? current.execution.runId : void 0
2157
+ }, nowMs));
2158
+ return runnable ? "pending" : "completed";
2159
+ });
2160
+ }
2161
+ /**
2162
+ * Record one failed execution attempt for pending messages, dead-lettering
2163
+ * the conversation when its retry budget or the legacy message delivery limit
2164
+ * is exhausted.
2165
+ *
2166
+ * Retries are counted only when the run made no durable progress: if any
2167
+ * attempted message left the mailbox, the remaining pending entries may be
2168
+ * deliberate deferrals and are left untouched. Consumed message ids stay in
2169
+ * `inboundMessageIds` so source retries remain duplicates.
2170
+ */
2171
+ async function recordAttemptFailure(args) {
2172
+ const nowMs = args.nowMs ?? now();
2173
+ return await withConversationMutation(args, async (state, lock) => {
2174
+ const current = await readConversation(state, args.conversationId);
2175
+ if (!current || current.execution.lease?.token !== args.leaseToken) return {
2176
+ status: "lost_lease",
2177
+ pendingCount: 0,
2178
+ deadLetteredMessages: [],
2179
+ retryCount: 0
2180
+ };
2181
+ const pendingIds = new Set(current.execution.pendingMessages.map((message) => message.inboundMessageId));
2182
+ if (args.inboundMessageIds.length === 0 || args.inboundMessageIds.some((id) => !pendingIds.has(id))) return {
2183
+ status: "skipped",
2184
+ pendingCount: current.execution.pendingMessages.length,
2185
+ deadLetteredMessages: [],
2186
+ retryCount: current.execution.retryCount ?? 0
2187
+ };
2188
+ const retryCount = (current.execution.retryCount ?? 0) + 1;
2189
+ const retriesExhausted = retryCount >= 5;
2190
+ const attemptedIds = new Set(args.inboundMessageIds);
2191
+ const deadLetteredMessages = [];
2192
+ const pendingMessages = [];
2193
+ for (const message of current.execution.pendingMessages) {
2194
+ if (!attemptedIds.has(message.inboundMessageId)) {
2195
+ if (retriesExhausted) deadLetteredMessages.push(message);
2196
+ else pendingMessages.push(message);
2197
+ continue;
2198
+ }
2199
+ const attempted = {
2200
+ ...message,
2201
+ attemptCount: (message.attemptCount ?? 0) + 1
2202
+ };
2203
+ if (retriesExhausted || attempted.attemptCount >= 5) {
2204
+ deadLetteredMessages.push(attempted);
2205
+ continue;
2206
+ }
2207
+ pendingMessages.push(attempted);
2208
+ }
2209
+ await writeConversation(state, lock, withExecutionUpdate(current, {
2210
+ ...current.execution,
2211
+ retryCount,
2212
+ pendingMessages
2213
+ }, nowMs));
2214
+ return {
2215
+ status: "recorded",
2216
+ pendingCount: pendingMessages.length,
2217
+ deadLetteredMessages,
2218
+ retryCount
2219
+ };
2220
+ });
2221
+ }
2222
+ /** Record a terminal failure completion for a leased conversation. */
2223
+ async function deadLetterAttempt(args) {
2224
+ const nowMs = args.nowMs ?? now();
2225
+ return await withConversationMutation(args, async (state, lock) => {
2226
+ const current = await readConversation(state, args.conversationId);
2227
+ if (!current || current.execution.lease?.token !== args.leaseToken) return "lost_lease";
2228
+ const runnable = pendingMessages(current).length > 0;
2229
+ await writeConversation(state, lock, withExecutionUpdate(current, {
2230
+ ...current.execution,
2231
+ lease: void 0,
2232
+ status: runnable ? "pending" : "failed",
2233
+ runId: runnable ? current.execution.runId : void 0
2234
+ }, nowMs));
2235
+ return runnable ? "pending" : "failed";
2236
+ });
2237
+ }
2238
+ /** Recover an expired lease, consuming the retry budget under the mutation lock. */
2239
+ async function clearExpiredConversationLease(args) {
2240
+ const nowMs = args.nowMs ?? now();
2241
+ return await withConversationMutation(args, async (state, lock) => {
2242
+ const current = await readConversation(state, args.conversationId);
2243
+ if (!current?.execution.lease || current.execution.lease.expiresAtMs > nowMs) return "not_expired";
2244
+ const retryCount = (current.execution.retryCount ?? 0) + 1;
2245
+ const stopped = retryCount >= 5;
2246
+ await writeConversation(state, lock, withExecutionUpdate(current, {
2247
+ ...current.execution,
2248
+ lastEnqueuedAtMs: void 0,
2249
+ lease: void 0,
2250
+ retryCount,
2251
+ pendingMessages: stopped ? [] : current.execution.pendingMessages,
2252
+ runId: stopped ? void 0 : current.execution.runId,
2253
+ status: stopped ? "failed" : "paused"
2254
+ }, nowMs));
2255
+ return stopped ? "stopped" : "requeued";
2256
+ });
2257
+ }
2258
+ /** Remove one conversation from the active index after it is missing or idle. */
2259
+ async function removeActiveConversation(args) {
2260
+ await removeIndexEntry({
2261
+ state: await getConnectedState(args.state),
2262
+ indexKey: CONVERSATION_ACTIVE_INDEX_KEY,
2263
+ conversationId: args.conversationId
2264
+ });
2265
+ }
2266
+ /** List active conversation ids by oldest execution update first. */
2267
+ async function listActiveConversationIds(args = {}) {
2268
+ return (await (await getConversationIndexStore(await getConnectedState(args.state))).list({
2269
+ indexKey: CONVERSATION_ACTIVE_INDEX_KEY,
2270
+ limit: args.limit,
2271
+ order: "asc",
2272
+ scoreMax: args.staleBeforeMs
2273
+ })).map((entry) => entry.conversationId);
2274
+ }
1108
2275
  // src/chat/task-execution/turn-cursor-keys.ts
1109
2276
  /** Redis namespace for the current turn cursor format. */
1110
2277
  const TURN_CURSOR_PREFIX = "junior:turn_cursor:v2";
@@ -1145,8 +2312,17 @@ async function withTurnCursorMutation(conversationId, turnId, run) {
1145
2312
  if (!result.acquired) throw new Error(`Could not acquire turn cursor lock for ${turnId}`);
1146
2313
  return result.value;
1147
2314
  }
1148
- function executionMetricsForTurn(conversation, turnId) {
1149
- return conversation?.executionMetrics?.runId === turnId ? conversation.executionMetrics : void 0;
2315
+ /**
2316
+ * Read this Turn's own totals. A newer Turn can own the latest Conversation
2317
+ * metrics while this Turn is paused, so fall back to this Turn's metric rows.
2318
+ */
2319
+ async function executionMetricsForTurn(conversationStore, conversation, turnId) {
2320
+ if (!conversation) return void 0;
2321
+ if (conversation.executionMetrics?.runId === turnId) return conversation.executionMetrics;
2322
+ return await conversationStore.getRunMetrics({
2323
+ conversationId: conversation.conversationId,
2324
+ runId: turnId
2325
+ });
1150
2326
  }
1151
2327
  /** Keep only keys whose value is defined. */
1152
2328
  function definedProps$1(values) {
@@ -1195,6 +2371,7 @@ const storedTurnSummarySchema = z.object({
1195
2371
  /** Full resume cursor stored for one turn. */
1196
2372
  const storedTurnRecordSchema = z.object({
1197
2373
  actor: actorSchema.optional(),
2374
+ actorIdentityId: z.string().min(1).optional(),
1198
2375
  schemaVersion: z.literal(TURN_CURSOR_SCHEMA_VERSION),
1199
2376
  version: z.number().int().nonnegative(),
1200
2377
  conversationId: z.string().min(1),
@@ -1216,7 +2393,7 @@ const storedTurnRecordSchema = z.object({
1216
2393
  startedAtMs: nonNegativeNumberSchema,
1217
2394
  state: turnStatusSchema,
1218
2395
  surface: turnSurfaceSchema.optional(),
1219
- traceId: z.string().optional(),
2396
+ traceContext: traceContextSchema,
1220
2397
  updatedAtMs: nonNegativeNumberSchema,
1221
2398
  committedSeq: seqCursorSchema,
1222
2399
  historyVersion: z.number().int().nonnegative().optional(),
@@ -1317,6 +2494,7 @@ function materializeTurnRecord(stored, piProjection, turnStartMessageIndex, runt
1317
2494
  actors: instructionActors(piProjection.provenance),
1318
2495
  cumulativeDurationMs: runtimeMetrics?.cumulativeDurationMs ?? 0,
1319
2496
  ...definedProps$1({
2497
+ actorIdentityId: stored.actorIdentityId,
1320
2498
  channelName: runtimeMetrics?.channelName,
1321
2499
  credentialSubject: stored.credentialSubject,
1322
2500
  cumulativeToolCallCount: stored.cumulativeToolCallCount,
@@ -1331,7 +2509,7 @@ function materializeTurnRecord(stored, piProjection, turnStartMessageIndex, runt
1331
2509
  resultMessageId: stored.resultMessageId,
1332
2510
  resumedFromSliceId: stored.resumedFromSliceId,
1333
2511
  surface: stored.surface,
1334
- traceId: stored.traceId,
2512
+ traceContext: stored.traceContext,
1335
2513
  turnStartMessageIndex
1336
2514
  })
1337
2515
  };
@@ -1358,8 +2536,9 @@ async function materializeStoredTurnRecord(conversationId, turnId, followCurrent
1358
2536
  const followsReplacement = followCurrentReplacement && (parsed.state === "running" || parsed.state === "paused") && parsed.historyVersion !== void 0 && parsed.historyVersion !== currentHistoryVersion;
1359
2537
  const piProjection = followsReplacement ? projectConversationEvents(await eventStore.loadCurrentHistory(conversationId), { defaultProfile: botConfig.defaultProfile }) : pinnedProjection;
1360
2538
  const turnStartMessageIndex = followsReplacement ? 0 : parsed.turnStartSeq === void 0 ? void 0 : piProjection.seqs.filter((seq) => seq <= parsed.turnStartSeq).length;
1361
- const conversation = await getConversationStore().get({ conversationId });
1362
- const executionMetrics = executionMetricsForTurn(conversation, turnId);
2539
+ const conversationStore = getConversationStore();
2540
+ const conversation = await conversationStore.get({ conversationId });
2541
+ const executionMetrics = await executionMetricsForTurn(conversationStore, conversation, turnId);
1363
2542
  return materializeTurnRecord(parsed, piProjection, turnStartMessageIndex, {
1364
2543
  channelName: conversation?.channelName,
1365
2544
  cumulativeDurationMs: executionMetrics?.durationMs,
@@ -1390,6 +2569,7 @@ function buildStoredRecord(args) {
1390
2569
  committedSeq: args.committedSeq,
1391
2570
  ...definedProps$1({
1392
2571
  actor: args.actor,
2572
+ actorIdentityId: args.actorIdentityId,
1393
2573
  credentialSubject: args.credentialSubject,
1394
2574
  cumulativeToolCallCount: args.cumulativeToolCallCount,
1395
2575
  turnLimitWrappedUp: args.turnLimitWrappedUp,
@@ -1404,7 +2584,7 @@ function buildStoredRecord(args) {
1404
2584
  resultMessageId: args.resultMessageId,
1405
2585
  resumedFromSliceId: args.resumedFromSliceId,
1406
2586
  surface: args.surface,
1407
- traceId: args.traceId,
2587
+ traceContext: args.traceContext,
1408
2588
  turnStartSeq: args.turnStartSeq
1409
2589
  })
1410
2590
  };
@@ -1468,6 +2648,7 @@ async function updateTurnState(args) {
1468
2648
  lastProgressAtMs: parsed.lastProgressAtMs,
1469
2649
  previousVersion: parsed.version,
1470
2650
  ...definedProps$1({
2651
+ actorIdentityId: args.existing.actorIdentityId,
1471
2652
  credentialSubject: args.existing.credentialSubject,
1472
2653
  cumulativeToolCallCount: args.existing.cumulativeToolCallCount,
1473
2654
  turnLimitWrappedUp: args.existing.turnLimitWrappedUp,
@@ -1482,7 +2663,7 @@ async function updateTurnState(args) {
1482
2663
  resultMessageId: args.resultMessageId ?? args.existing.resultMessageId,
1483
2664
  resumedFromSliceId: args.existing.resumedFromSliceId,
1484
2665
  surface: args.existing.surface,
1485
- traceId: args.existing.traceId,
2666
+ traceContext: args.existing.traceContext,
1486
2667
  turnStartSeq: parsed.turnStartSeq
1487
2668
  })
1488
2669
  })
@@ -1494,8 +2675,9 @@ async function upsertTurnRecord(args) {
1494
2675
  }
1495
2676
  async function upsertTurnRecordLocked(args, fence) {
1496
2677
  const existingRecord = await getStoredTurnRecord(args.conversationId, args.turnId);
1497
- const conversation = await (args.conversationStore ?? getConversationStore()).get({ conversationId: args.conversationId });
1498
- const executionMetrics = executionMetricsForTurn(conversation, args.turnId);
2678
+ const conversationStore = args.conversationStore ?? getConversationStore();
2679
+ const conversation = await conversationStore.get({ conversationId: args.conversationId });
2680
+ const executionMetrics = await executionMetricsForTurn(conversationStore, conversation, args.turnId);
1499
2681
  const existingDispatchId = existingRecord?.dispatchId ?? (await listTurnSummaries(args.conversationId)).find((summary) => summary.turnId === args.turnId)?.dispatchId;
1500
2682
  if (existingDispatchId && args.dispatchId && existingDispatchId !== args.dispatchId) throw new Error(`Turn ${args.turnId} dispatchId cannot be changed`);
1501
2683
  const ttlMs = turnCursorTtlMs(args.state, args.ttlMs);
@@ -1544,6 +2726,7 @@ async function upsertTurnRecordLocked(args, fence) {
1544
2726
  historyVersion: commit.historyVersion,
1545
2727
  previousVersion: existingRecord?.version,
1546
2728
  ...definedProps$1({
2729
+ actorIdentityId: existingRecord?.actorIdentityId,
1547
2730
  credentialSubject: existingRecord?.credentialSubject ?? args.credentialSubject,
1548
2731
  cumulativeToolCallCount: args.cumulativeToolCallCount ?? existingRecord?.cumulativeToolCallCount,
1549
2732
  turnLimitWrappedUp: args.turnLimitWrappedUp ?? existingRecord?.turnLimitWrappedUp,
@@ -1559,7 +2742,7 @@ async function upsertTurnRecordLocked(args, fence) {
1559
2742
  resumedFromSliceId: args.resumedFromSliceId,
1560
2743
  startedAtMs: existingRecord?.startedAtMs,
1561
2744
  surface: args.surface ?? existingRecord?.surface,
1562
- traceId: args.traceId ?? existingRecord?.traceId,
2745
+ traceContext: args.traceContext ?? existingRecord?.traceContext,
1563
2746
  turnStartSeq
1564
2747
  })
1565
2748
  })
@@ -1573,7 +2756,7 @@ async function recordTurnSummaryOwned(args, fence) {
1573
2756
  const stored = await getStoredTurnRecord(args.conversationId, args.turnId);
1574
2757
  const conversationStore = args.conversationStore ?? getConversationStore();
1575
2758
  const conversation = await conversationStore.get({ conversationId: args.conversationId });
1576
- const executionMetrics = executionMetricsForTurn(conversation, args.turnId);
2759
+ const executionMetrics = await executionMetricsForTurn(conversationStore, conversation, args.turnId);
1577
2760
  const priorSummary = (await listTurnSummaries(args.conversationId)).find((summary) => summary.turnId === args.turnId);
1578
2761
  const terminalSummary = priorSummary && (priorSummary.state === "completed" || priorSummary.state === "failed" || priorSummary.state === "abandoned") ? priorSummary : void 0;
1579
2762
  const terminalRecord = stored && (stored.state === "completed" || stored.state === "failed" || stored.state === "abandoned") ? stored : void 0;
@@ -1728,7 +2911,7 @@ function sharedWrite(args, latest) {
1728
2911
  dispatchId: args.dispatchId ?? latest?.dispatchId,
1729
2912
  source: args.source,
1730
2913
  surface: args.surface ?? latest?.surface,
1731
- traceId: getActiveTraceId() ?? latest?.traceId,
2914
+ traceContext: latest?.traceContext ?? getActiveTraceContext(),
1732
2915
  turnStartMessageIndex: args.turnStartMessageIndex ?? latest?.turnStartMessageIndex,
1733
2916
  cumulativeToolCallCount: args.cumulativeToolCallCount ?? latest?.cumulativeToolCallCount,
1734
2917
  turnLimitWrappedUp: args.turnLimitWrappedUp ?? latest?.turnLimitWrappedUp,
@@ -3265,6 +4448,213 @@ async function recordGuardianActionReviewed(args) {
3265
4448
  createdAtMs: Date.now()
3266
4449
  }]);
3267
4450
  }
4451
+ // src/chat/events/catalog.ts
4452
+ /** A source with nothing enabled, for a namespace that the catalog lacks. */
4453
+ const NO_SOURCE = {
4454
+ resourceTypes: [],
4455
+ events: {},
4456
+ anyResourceFields: {}
4457
+ };
4458
+ /**
4459
+ * Build one catalog entry. It merges the filters of every resource type here,
4460
+ * once, so trigger checks for each event copy and each row do not merge them
4461
+ * again.
4462
+ */
4463
+ function eventCatalogEntry(registration) {
4464
+ return {
4465
+ ...registration,
4466
+ anyResourceFields: anyResourceMatchFields(registration.resourceTypes)
4467
+ };
4468
+ }
4469
+ /** Core-owned event namespace. Not a plugin publisher. */
4470
+ const CORE_EVENT_NAMESPACE = "junior";
4471
+ /** True when the catalog has any enabled registration. */
4472
+ function hasEventCatalogEntries(catalog) {
4473
+ return Object.keys(catalog).length > 0;
4474
+ }
4475
+ /**
4476
+ * Catalog entries that durable event automations may select.
4477
+ *
4478
+ * Resource types with `automation: false` stay out of create/update schemas
4479
+ * and trigger availability. A namespace with no such resource type left is
4480
+ * removed.
4481
+ */
4482
+ function automationEventCatalog(catalog) {
4483
+ return Object.fromEntries(Object.entries(catalog).flatMap(([namespace, registration]) => {
4484
+ const resourceTypes = registration.resourceTypes.filter((resourceType) => registration.limits?.[resourceType.type]?.automation !== false);
4485
+ if (resourceTypes.length === 0) return [];
4486
+ if (resourceTypes.length === registration.resourceTypes.length) return [[namespace, registration]];
4487
+ const supported = new Set(resourceTypes.flatMap((resourceType) => resourceType.supportedEvents));
4488
+ return [[namespace, eventCatalogEntry({
4489
+ ...registration,
4490
+ resourceTypes,
4491
+ events: Object.fromEntries(Object.entries(registration.events).filter(([eventType]) => supported.has(eventType)))
4492
+ })]];
4493
+ }));
4494
+ }
4495
+ /** Return why watchEvents cannot watch one resource type, if it cannot. */
4496
+ function eventWatchUnavailable(catalog, namespace, resourceType) {
4497
+ const limits = catalog[namespace]?.limits?.[resourceType];
4498
+ return limits?.watch === false ? limits.watchUnavailableMessage : void 0;
4499
+ }
4500
+ function enumSchema(values, unavailableMessage) {
4501
+ if (values.length === 0) return z.string().refine(() => false, unavailableMessage);
4502
+ return z.enum(values);
4503
+ }
4504
+ /** Build the model-facing namespace enum from enabled event plugins. */
4505
+ function eventNamespaceSchema(catalog) {
4506
+ return enumSchema(Object.keys(catalog).sort(), "No event namespaces are enabled.");
4507
+ }
4508
+ /** Build the model-facing event enum from enabled plugin registrations. */
4509
+ function registeredEventTypeSchema(catalog) {
4510
+ return enumSchema([...new Set(Object.values(catalog).flatMap((registration) => registration.resourceTypes.flatMap((resourceType) => resourceType.supportedEvents)))].sort(), "No event types are enabled.");
4511
+ }
4512
+ /** Build the model-facing resource type enum from enabled plugin registrations. */
4513
+ function registeredResourceTypeSchema(catalog) {
4514
+ return enumSchema([...new Set(Object.values(catalog).flatMap((registration) => registration.resourceTypes.map((resourceType) => resourceType.type)))].sort(), "No resource types are enabled.");
4515
+ }
4516
+ /**
4517
+ * Return app guidance for one event of a namespace. The source declares it
4518
+ * once for the event, so every resource type and a trigger with no resource
4519
+ * type get the same guidance.
4520
+ */
4521
+ function eventGuidance(catalog, namespace, eventType) {
4522
+ return catalog[namespace]?.events[eventType]?.guidance;
4523
+ }
4524
+ /** Build the match object the tools accept for one resource type. */
4525
+ function registeredEventMatchSchema() {
4526
+ return eventMatchSchema.optional().describe("Optional filters: exact values for the resource type matchFields. A single value must equal the event data. A list means the event value may be any one entry. For a field with list: true, the event value is a list, and it matches when it includes the value or any entry. A field with events is set only on those events, so every selected event must be in that list. Omit a key to ignore it. Unmatched events are dropped with no agent turn.");
4527
+ }
4528
+ /**
4529
+ * List why a selection cannot work with the enabled plugins. Use it for new
4530
+ * selections and for saved triggers, which can go stale after a plugin renames
4531
+ * or removes a resource type, event, or filter.
4532
+ */
4533
+ function registeredEventTriggerProblems(catalog, selection, options) {
4534
+ return eventTriggerProblems(catalog[selection.namespace] ?? NO_SOURCE, selection, options);
4535
+ }
4536
+ /**
4537
+ * Describe a selection with the enabled plugins, for the dashboard and Slack.
4538
+ * Its problems are the same as `registeredEventTriggerProblems`.
4539
+ */
4540
+ function describeRegisteredEventTrigger(catalog, selection) {
4541
+ return describeEventTrigger(catalog[selection.namespace] ?? NO_SOURCE, selection);
4542
+ }
4543
+ function uniqueMatchValues(match) {
4544
+ if (!match || Object.keys(match).length === 0) return void 0;
4545
+ return Object.fromEntries(Object.entries(match).map(([key, value]) => [key, Array.isArray(value) ? [...new Set(value)] : value]));
4546
+ }
4547
+ /**
4548
+ * Reject an event automation trigger or a Watch that cannot work, with every
4549
+ * problem in one message for the agent. Return its match as it is saved:
4550
+ * each text value in the case that its plugin declares, with no repeated
4551
+ * list values.
4552
+ */
4553
+ function requireValidEventTrigger(catalog, selection, target) {
4554
+ const fields = eventTriggerChoices(catalog[selection.namespace] ?? NO_SOURCE, selection)?.fields ?? {};
4555
+ const normalized = {
4556
+ ...selection,
4557
+ match: normalizeEventMatch(fields, selection.match)
4558
+ };
4559
+ const problems = registeredEventTriggerProblems(catalog, normalized, { inputKeys: true });
4560
+ if (problems.length > 0) {
4561
+ const source = catalog[selection.namespace]?.displayName ?? selection.namespace;
4562
+ const resource = selection.resourceType ? `${source} ${selection.resourceType.replaceAll("_", " ")}` : source;
4563
+ throw new ToolInputError([target === "watch" ? `This ${resource} watch cannot be created.` : `This ${resource} trigger cannot be saved.`, ...problems.map((problem) => problem.message)].join(" "));
4564
+ }
4565
+ return uniqueMatchValues(normalized.match);
4566
+ }
4567
+ /**
4568
+ * Normalize one selector with the convention declared by its plugin.
4569
+ *
4570
+ * Reject a selector that does not fit its resource type. Resource types can
4571
+ * share event types, so only the identifier shows which one a selector names.
4572
+ */
4573
+ function requireEventIdentifier(catalog, input) {
4574
+ const registration = catalog[input.namespace];
4575
+ const identifier = normalizeEventIdentifier(registration, input.identifier);
4576
+ const resourceTypes = registration?.resourceTypes ?? [];
4577
+ const fits = (resourceType) => resourceType.identifier !== void 0 && new RegExp(resourceType.identifier.pattern).test(identifier);
4578
+ const resourceType = resourceTypes.find((candidate) => candidate.type === input.resourceType);
4579
+ if (!resourceType?.identifier || fits(resourceType)) return identifier;
4580
+ const fitting = resourceTypes.filter(fits).map((candidate) => candidate.type);
4581
+ throw new ToolInputError([`Identifier "${identifier}" does not fit resourceType "${input.resourceType}" in namespace "${input.namespace}". Expected format: "${resourceType.identifier.format}".`, ...fitting.length > 0 ? [`If the identifier is correct, use one of these resourceType values: ${fitting.map((type) => `"${type}"`).join(", ")}.`] : []].join(" "));
4582
+ }
4583
+ // src/chat/services/turn-session-routing.ts
4584
+ async function loadConversationChain(conversationId, conversationStore) {
4585
+ const chain = [];
4586
+ const seen = /* @__PURE__ */ new Set();
4587
+ let cursor = conversationId;
4588
+ while (cursor && !seen.has(cursor)) {
4589
+ seen.add(cursor);
4590
+ const conversation = await conversationStore.get({ conversationId: cursor });
4591
+ if (!conversation) break;
4592
+ chain.push(conversation);
4593
+ cursor = conversation.parentConversationId;
4594
+ }
4595
+ return chain;
4596
+ }
4597
+ /**
4598
+ * Normalize a stored Slack session source against the conversation destination.
4599
+ *
4600
+ * Does not create channel or thread values from the conversation id.
4601
+ */
4602
+ function slackSourceForDestination(args) {
4603
+ if (args.source?.kind !== "slack" || args.source.channelId !== args.destination.channelId) return;
4604
+ const threadTs = args.source.threadTs?.trim();
4605
+ return {
4606
+ kind: "slack",
4607
+ visibility: args.source.visibility ?? "public",
4608
+ teamId: args.source.teamId?.trim() || args.destination.teamId,
4609
+ channelId: args.source.channelId,
4610
+ ...threadTs ? { threadTs } : void 0
4611
+ };
4612
+ }
4613
+ /**
4614
+ * Resolve the Conversation Location, Destination, and session Source.
4615
+ *
4616
+ * Reads parent Conversations when this Conversation has no Destination.
4617
+ */
4618
+ async function resolveConversationRouting(args) {
4619
+ const conversationStore = args.conversationStore ?? (await import("./viewer-CpXTGlep.js").then((n) => n.u)).getConversationStore();
4620
+ const chain = await loadConversationChain(args.conversationId, conversationStore);
4621
+ let destination;
4622
+ let source;
4623
+ let location;
4624
+ for (const conversation of chain) {
4625
+ destination ??= conversation.destination;
4626
+ source ??= conversation.sessionSource;
4627
+ location ??= conversation.location;
4628
+ if (destination && source) break;
4629
+ }
4630
+ if (!destination) return;
4631
+ if (destination.platform === "slack") {
4632
+ const slackSource = slackSourceForDestination({
4633
+ destination,
4634
+ source
4635
+ });
4636
+ return {
4637
+ destination,
4638
+ ...location ? { location } : void 0,
4639
+ ...slackSource ? { source: slackSource } : void 0
4640
+ };
4641
+ }
4642
+ return {
4643
+ destination,
4644
+ ...location ? { location } : void 0,
4645
+ ...source ? { source } : void 0
4646
+ };
4647
+ }
4648
+ /** Require the Conversation Destination and Source from SQL. */
4649
+ async function resolveTurnSessionRouting(args) {
4650
+ const routing = await resolveConversationRouting(args);
4651
+ if (!routing?.destination || !routing.source) throw new Error(`Conversation ${args.conversationId} is missing durable routing metadata`);
4652
+ return {
4653
+ destination: routing.destination,
4654
+ ...routing.location ? { location: routing.location } : void 0,
4655
+ source: routing.source
4656
+ };
4657
+ }
3268
4658
  // src/chat/automations/revision.ts
3269
4659
  /** Storage revisions prevent stale edits from replacing newer values. */
3270
4660
  var AutomationConflictError = class extends ToolInputError {
@@ -3407,6 +4797,14 @@ function eventAutomationRunLimitReason() {
3407
4797
  const window = describeRunWindow(botConfig.eventAutomationRunWindowMs);
3408
4798
  return `This automation reached its limit of ${botConfig.maxEventAutomationRuns} runs per ${window}. Check that its trigger is not too broad, then resume it.`;
3409
4799
  }
4800
+ function hasRunKey(entries, runKey) {
4801
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
4802
+ const entry = entries[index];
4803
+ if ("reset" in entry) return false;
4804
+ if (entry.id === runKey) return true;
4805
+ }
4806
+ return false;
4807
+ }
3410
4808
  /**
3411
4809
  * Count one run of an Event automation, or refuse when its window is full.
3412
4810
  *
@@ -3415,22 +4813,29 @@ function eventAutomationRunLimitReason() {
3415
4813
  */
3416
4814
  async function admitEventAutomationRun(args) {
3417
4815
  const state = args.state ?? getStateAdapter();
3418
- await appendRunEntry(state, args.automationId, {
4816
+ await state.connect();
4817
+ const readEntries = async () => {
4818
+ const values = await state.getList(runListKey(args.automationId));
4819
+ return {
4820
+ entries: values.flatMap((value) => {
4821
+ const entry = runEntrySchema.safeParse(value);
4822
+ return entry.success ? [entry.data] : [];
4823
+ }),
4824
+ length: values.length
4825
+ };
4826
+ };
4827
+ if (!hasRunKey((await readEntries()).entries, args.runKey)) await appendRunEntry(state, args.automationId, {
3419
4828
  atMs: args.nowMs,
3420
4829
  id: args.runKey
3421
4830
  });
3422
4831
  const windowStartMs = args.nowMs - botConfig.eventAutomationRunWindowMs;
3423
- const values = await state.getList(runListKey(args.automationId));
3424
- const entries = values.flatMap((value) => {
3425
- const entry = runEntrySchema.safeParse(value);
3426
- return entry.success ? [entry.data] : [];
3427
- });
4832
+ const { entries, length } = await readEntries();
3428
4833
  let lastReset = -1;
3429
4834
  entries.forEach((entry, index) => {
3430
4835
  if ("reset" in entry) lastReset = index;
3431
4836
  });
3432
4837
  const oldest = entries[0];
3433
- const full = lastReset === -1 && values.length >= runListMaxLength() && oldest !== void 0 && "atMs" in oldest && oldest.atMs > windowStartMs;
4838
+ const full = lastReset === -1 && length >= runListMaxLength() && oldest !== void 0 && "atMs" in oldest && oldest.atMs > windowStartMs;
3434
4839
  const runKeys = /* @__PURE__ */ new Set();
3435
4840
  for (const entry of entries.slice(lastReset + 1)) {
3436
4841
  if ("reset" in entry) continue;
@@ -3461,7 +4866,7 @@ function eventAutomationColumns(task) {
3461
4866
  channelId: record.destination.channelId,
3462
4867
  creatorIdentityId,
3463
4868
  creatorSlackUserId: record.createdBy.slackUserId,
3464
- identifier: record.trigger.identifier,
4869
+ identifier: record.trigger.identifier ?? null,
3465
4870
  instruction: record.task.text,
3466
4871
  namespace: record.trigger.namespace,
3467
4872
  record,
@@ -3469,14 +4874,32 @@ function eventAutomationColumns(task) {
3469
4874
  title: title?.trim() || null
3470
4875
  };
3471
4876
  }
3472
- /** Decode an event Automation row with its SQL-backed fields. */
3473
- function parseEventAutomationRow(row) {
3474
- const task = eventAutomationSchema.parse({
4877
+ function eventAutomationRowInput(row) {
4878
+ return {
3475
4879
  ...row.record,
3476
4880
  createdAtMs: row.createdAtMs,
3477
4881
  creatorIdentityId: row.creatorIdentityId,
3478
4882
  id: row.id
4883
+ };
4884
+ }
4885
+ /** Decode an event Automation row with its SQL-backed fields. */
4886
+ function parseEventAutomationRow(row) {
4887
+ return withRowFields(row, eventAutomationSchema.parse(eventAutomationRowInput(row)));
4888
+ }
4889
+ /**
4890
+ * Decode a row for Event matching. Log and skip a row that does not parse,
4891
+ * so one bad row cannot stop matching for its whole namespace.
4892
+ */
4893
+ function readMatchableEventAutomationRow(row) {
4894
+ const parsed = eventAutomationSchema.safeParse(eventAutomationRowInput(row));
4895
+ if (parsed.success) return withRowFields(row, parsed.data);
4896
+ logWarn("event_automation.row.invalid", {
4897
+ "app.task.id": row.id,
4898
+ "app.task.type": "event",
4899
+ "error.message": parsed.error.message
3479
4900
  });
4901
+ }
4902
+ function withRowFields(row, task) {
3480
4903
  const title = row.title?.trim();
3481
4904
  return {
3482
4905
  ...task,
@@ -3562,25 +4985,99 @@ async function listDeletedEventAutomationsCreatedBy(db, user, limit) {
3562
4985
  return (await db.select().from(juniorAutomations).where(and(inArray(juniorAutomations.creatorIdentityId, identityIds), eq(juniorAutomations.kind, "event"), eq(juniorAutomations.status, "deleted"))).orderBy(desc(juniorAutomations.createdAtMs), desc(juniorAutomations.id)).limit(limit)).map(parseEventAutomationRow);
3563
4986
  }
3564
4987
  /**
3565
- * Collect match keys from live event automations for these identifiers and event types.
3566
- * Does not evaluate match values — only reports keys that filters use.
4988
+ * Select live rows whose identifier is one of these, and rows with no
4989
+ * identifier. A row with no identifier runs for every resource of its
4990
+ * namespace, so it gets every copy of an Event.
4991
+ */
4992
+ function eventIdentifierWhere(identifiers) {
4993
+ return or(isNull(juniorAutomations.identifier), ...identifiers.length > 0 ? [inArray(juniorAutomations.identifier, [...identifiers])] : []);
4994
+ }
4995
+ /** SQL for rows whose trigger selects any of these event types. */
4996
+ function eventTypesWhere(eventTypes) {
4997
+ return or(...eventTypes.map((eventType) => sql`${juniorAutomations.record}->'trigger'->'events' @> ${JSON.stringify([eventType])}::jsonb`));
4998
+ }
4999
+ /**
5000
+ * Return why a row must not run for an Event with one of these identifiers,
5001
+ * or nothing when it may.
5002
+ *
5003
+ * The identifier column and the saved trigger must agree. A row with no
5004
+ * identifier must also have a trigger that the enabled `catalog` accepts. A
5005
+ * plugin can later require a filter or stop letting an event run for every
5006
+ * resource, and the row must not stay broad.
5007
+ */
5008
+ function eventAutomationGateProblem(row, task, identifiers, catalog) {
5009
+ const identifier = task.trigger.identifier;
5010
+ if (identifier !== void 0) return identifier === row.identifier && identifiers.includes(identifier) ? void 0 : "The saved trigger has a different identifier from its row.";
5011
+ if (row.identifier !== null) return "The saved trigger has no identifier, but its row has one.";
5012
+ const problems = registeredEventTriggerProblems(catalog, task.trigger);
5013
+ return problems.length > 0 ? problems.map((problem) => problem.message).join(" ") : void 0;
5014
+ }
5015
+ /**
5016
+ * Read the rows that can match an Event: live rows of one team and
5017
+ * namespace whose identifier is one of these, or that have no identifier,
5018
+ * and that select one of these event types. Skip and log a row that does
5019
+ * not parse or that the gate rejects.
5020
+ */
5021
+ async function readGatedEventAutomations(db, input) {
5022
+ const rows = await db.select().from(juniorAutomations).where(and(eq(juniorAutomations.kind, "event"), eq(juniorAutomations.teamId, input.teamId), eq(juniorAutomations.namespace, input.namespace), eventIdentifierWhere(input.identifiers), eventTypesWhere(input.eventTypes), eq(juniorAutomations.status, "active"))).orderBy(asc(juniorAutomations.createdAtMs), asc(juniorAutomations.id));
5023
+ const plugins = automationEventCatalog(input.catalog);
5024
+ return rows.flatMap((row) => {
5025
+ const task = readMatchableEventAutomationRow(row);
5026
+ if (!task) return [];
5027
+ const problem = eventAutomationGateProblem(row, task, input.identifiers, plugins);
5028
+ if (problem === void 0) return [task];
5029
+ logWarn("event_automation.gate.rejected", {
5030
+ "app.task.id": task.id,
5031
+ "app.task.type": "event",
5032
+ "app.event_automation.skip_reason": problem
5033
+ });
5034
+ return [];
5035
+ });
5036
+ }
5037
+ /** The filters of a match whose keys are in this data. */
5038
+ function knownMatch(match, data) {
5039
+ if (!match) return void 0;
5040
+ return Object.fromEntries(Object.entries(match).filter(([key]) => data[key] !== void 0));
5041
+ }
5042
+ /**
5043
+ * Collect match keys from live event automations for these identifiers and
5044
+ * event types, and from automations with no identifier. Does not evaluate
5045
+ * match values, except for automations with no identifier: one whose
5046
+ * filters on the keys in `data` fail adds no keys. So a broad trigger for
5047
+ * one repository does not make every repository load its lazy data.
3567
5048
  */
3568
5049
  async function collectEventAutomationMatchKeys(db, input) {
3569
- const eventTypes = new Set(input.eventTypes.map((eventType) => eventType.trim()).filter(Boolean));
5050
+ const eventTypes = [...new Set(input.eventTypes.map((eventType) => eventType.trim()).filter(Boolean))];
3570
5051
  const identifiers = [...new Set(input.identifiers.map((value) => value.trim()).filter(Boolean))];
3571
- if (eventTypes.size === 0 || identifiers.length === 0) return [];
3572
- const rows = await db.select().from(juniorAutomations).where(and(eq(juniorAutomations.kind, "event"), eq(juniorAutomations.teamId, input.teamId), eq(juniorAutomations.namespace, input.namespace), inArray(juniorAutomations.identifier, identifiers), eq(juniorAutomations.status, "active"))).orderBy(asc(juniorAutomations.createdAtMs), asc(juniorAutomations.id));
5052
+ if (eventTypes.length === 0) return [];
5053
+ const tasks = await readGatedEventAutomations(db, {
5054
+ catalog: input.catalog,
5055
+ eventTypes,
5056
+ identifiers,
5057
+ namespace: input.namespace,
5058
+ teamId: input.teamId
5059
+ });
3573
5060
  const keys = /* @__PURE__ */ new Set();
3574
- for (const row of rows) {
3575
- const task = parseEventAutomationRow(row);
3576
- if (!task.trigger.events.some((eventType) => eventTypes.has(eventType))) continue;
5061
+ for (const task of tasks) {
5062
+ if (task.trigger.identifier === void 0 && input.data && !eventMatches(knownMatch(task.trigger.match, input.data), input.data)) continue;
3577
5063
  for (const key of Object.keys(task.trigger.match ?? {})) keys.add(key);
3578
5064
  }
3579
5065
  return [...keys].sort();
3580
5066
  }
3581
- /** Find every live task matching one normalized event. */
3582
- async function findMatchingEventAutomations(db, event, teamId) {
3583
- return (await db.select().from(juniorAutomations).where(and(eq(juniorAutomations.kind, "event"), eq(juniorAutomations.teamId, teamId), eq(juniorAutomations.namespace, event.namespace), eq(juniorAutomations.identifier, event.identifier), eq(juniorAutomations.status, "active"))).orderBy(asc(juniorAutomations.createdAtMs), asc(juniorAutomations.id))).map(parseEventAutomationRow).filter((task) => task.trigger.events.includes(event.eventType) && eventMatches(task.trigger.match, event.data));
5067
+ /**
5068
+ * Find every live task matching one normalized event: tasks for its exact
5069
+ * identifier, and tasks with no identifier whose filters match. The gate
5070
+ * skips a row whose trigger and identifier column disagree, and a row with
5071
+ * no identifier whose trigger the enabled `catalog` rejects.
5072
+ */
5073
+ async function findMatchingEventAutomations(db, event, teamId, catalog) {
5074
+ return (await readGatedEventAutomations(db, {
5075
+ catalog,
5076
+ eventTypes: [event.eventType],
5077
+ identifiers: [event.identifier],
5078
+ namespace: event.namespace,
5079
+ teamId
5080
+ })).filter((task) => task.trigger.events.includes(event.eventType) && eventMatches(task.trigger.match, event.data));
3584
5081
  }
3585
5082
  /**
3586
5083
  * Change lifecycle under the same row lock as edits, without changing
@@ -3753,4 +5250,4 @@ async function saveScheduledAutomation(db, task, expectedRevision, editedBy) {
3753
5250
  async function saveScheduledAutomationInLock(db, task, current) {
3754
5251
  await writeScheduledAutomation(db, requireStoredTask(task), current);
3755
5252
  }
3756
- export { persistConversationMessageSummaries as $, isToolResultMessage as $t, commitAcceptedReply as A, abandonTurnRecord as At, recordAttachmentsDelivered as B, buildTurnErrorResponse as Bt, automationVersionOf as C, getConfigDefaults as Cn, canReusePendingAuthLink as Ct, AutomationConflictError as D, continuableMessages as Dt, sameDefinitionValue as E, wasPendingAuthStopped as Et, loadProjection as F, recordTurnSummary as Ft, recordMcpProviderConnected as G, getPiMessageRole as Gt, recordAuthorizationCompleted as H, isTurnExecutionLimitExceededError as Ht, loadTurnProjection as I, TURN_LIMIT_DISABLED_REASON as It, recordTurnRoute as J, hasRuntimeTurnContext as Jt, recordSubscribedReplyRoute as K, getTerminalAssistantMessages as Kt, loadTurnRoute as L, TurnBudgetExceededError as Lt, loadConnectedMcpProviders as M, failTurnRecord as Mt, loadConversationProjection as N, getTurnRecord as Nt, automationRevision as O, loadTurnCheckpoint as Ot, loadLastModelProfile as P, listTurnSummaries as Pt, projectConversationMessages as Q, isToolResultError as Qt, openConversationProjection as R, TurnSliceLimitExceededError as Rt, automationDefinition as S, piMessageFromHistoryItem as Sn, abandonReplacedPendingAuth as St, readAutomationVersion as T, getConversationPendingAuth as Tt, recordAuthorizationRequested as U, persistWithRetry as Ut, recordAuthenticationLinked as V, createTurnLimitGate as Vt, recordGuardianActionReviewed as W, extractAssistantText as Wt, hydrateConversationMessages as X, isAssistantMessage as Xt, appendConversationMessages as Y, instructionTextForProjection as Yt, persistConversationMessages as Z, isContinuablePiBoundary as Zt, parseEventAutomationRow as _, isExperimentalFeatureEnabled as _n, createPluginAuthOrchestration as _t, saveScheduledAutomation as a, SESSION_CONTEXT_SECTION_TAG as an, buildConversationContext as at, admitEventAutomationRun as b, configValueSchema as bn, AuthorizationFlowDisabledError as bt, withScheduledAutomationLock as c, buildDeterministicTurnId as cn, getConversationMessageSlackTs as ct, createEventAutomation as d, getSubscribedReplyPreflightDecision as dn, normalizeConversationText as dt, normalizeToolNameFromResult as en, resolveConversationTitle as et, deleteEventAutomation as f, getThreadStopDecision as fn, recordDeliveredAssistantMessage as ft, listEventAutomationsForTeam as g, getExperimentalFeatures as gn, PluginCredentialFailureError as gt, listDeletedEventAutomationsCreatedBy as h, getAgentContextInputLimitTokens as hn, createPluginEgress as ht, readScheduledAutomation as i, REQUEST_CONTEXT_SECTION_TAG as in, appendThreadContextMessages as it, commitMessages as j, completeTurnRecord as jt, requireAutomationRevision as k, saveTurnCheckpoint as kt, blockEventAutomation as l, SubscribedReplyReason as ln, isHumanConversationMessage as lt, getEventAutomation as m, getAgentContextCompactionTriggerTokens as mn, upsertConversationMessage as mt, listScheduledAutomationsForTeam as n, stripRuntimeTurnContext as nn, fallbackShortTitle as nt, saveScheduledAutomationInLock as o, TURN_CONTEXT_TAG as on, createConversationMemoryService as ot, findMatchingEventAutomations as p, estimateTextTokens as pn, turnHasReply as pt, recordToolExecutionStarted as q, getUserMessageInstructionText as qt, parseScheduledAutomationRow as r, trimTrailingAssistantMessages as rn, resolveTaskTitle as rt, scheduledRunId as s, buildDeterministicAssistantMessageId as sn, estimateConversationContextTokens as st, createScheduledAutomation as t, retainRuntimeTurnContext as tn, settleConversationTitleWork as tt, collectEventAutomationMatchKeys as u, decideSubscribedThreadReply as un, markConversationMessage as ut, saveEventAutomation as v, setExperimentalFeatures as vn, authorizationId as vt, listAutomationVersions as w, setConfigDefaults as wn, clearPendingAuth as wt, eventAutomationRunLimitReason as x, historyItemFromPiMessage as xn, AuthorizationPauseError as xt, setEventAutomationStatus as y, createDurableLocationConfigurationService as yn, AuthPausePersistenceError as yt, recordAgentsInstructionsUpdated as z, TurnToolCallLimitExceededError as zt };
5253
+ export { loadTurnRoute as $, TURN_CONTEXT_TAG as $n, CONVERSATION_WORK_STALE_ENQUEUE_MS as $t, resolveConversationRouting as A, TurnBudgetExceededError as An, upsertConversationMessage as At, registeredEventMatchSchema as B, getUserMessageInstructionText as Bn, clearPendingAuth as Bt, automationVersionOf as C, removeActiveConversation as Cn, estimateConversationContextTokens as Ct, AutomationConflictError as D, steerWebPendingMessages as Dn, normalizeConversationText as Dt, sameDefinitionValue as E, startConversationWork as En, markConversationMessage as Et, eventCatalogEntry as F, isTurnExecutionLimitExceededError as Fn, AuthPausePersistenceError as Ft, requireValidEventTrigger as G, isToolResultError as Gn, saveTurnCheckpoint as Gt, registeredEventTypeSchema as H, instructionTextForProjection as Hn, wasPendingAuthStopped as Ht, eventGuidance as I, persistWithRetry as In, AuthorizationFlowDisabledError as It, loadConnectedMcpProviders as J, retainRuntimeTurnContext as Jn, failTurnRecord as Jt, commitAcceptedReply as K, isToolResultMessage as Kn, abandonTurnRecord as Kt, eventNamespaceSchema as L, extractAssistantText as Ln, AuthorizationPauseError as Lt, CORE_EVENT_NAMESPACE as M, TurnToolCallLimitExceededError as Mn, PluginCredentialFailureError as Mt, automationEventCatalog as N, buildTurnErrorResponse as Nn, createPluginAuthOrchestration as Nt, automationRevision as O, stopConversationWork as On, recordDeliveredAssistantMessage as Ot, describeRegisteredEventTrigger as P, createTurnLimitGate as Pn, authorizationId as Pt, loadTurnProjection as Q, SESSION_CONTEXT_SECTION_TAG as Qn, CONVERSATION_WORK_CHECK_IN_INTERVAL_MS as Qt, eventWatchUnavailable as R, getPiMessageRole as Rn, abandonReplacedPendingAuth as Rt, automationDefinition as S, releaseConversationWork as Sn, createConversationMemoryService as St, readAutomationVersion as T, requestConversationWork as Tn, isHumanConversationMessage as Tt, registeredResourceTypeSchema as U, isAssistantMessage as Un, continuableMessages as Ut, registeredEventTriggerProblems as V, hasRuntimeTurnContext as Vn, getConversationPendingAuth as Vt, requireEventIdentifier as W, isContinuablePiBoundary as Wn, loadTurnCheckpoint as Wt, loadLastModelProfile as X, trimTrailingAssistantMessages as Xn, listTurnSummaries as Xt, loadConversationProjection as Y, stripRuntimeTurnContext as Yn, getTurnRecord as Yt, loadProjection as Z, REQUEST_CONTEXT_SECTION_TAG as Zn, recordTurnSummary as Zt, parseEventAutomationRow as _, isInvalidConversationRecordError as _n, setConfigDefaults as _r, settleConversationTitleWork as _t, saveScheduledAutomation as a, checkInConversationWork as an, getThreadStopDecision as ar, recordAuthorizationRequested as at, admitEventAutomationRun as b, recordAttemptFailure as bn, appendThreadContextMessages as bt, withScheduledAutomationLock as c, completeConversationStop as cn, getAgentContextInputLimitTokens as cr, recordSubscribedReplyRoute as ct, createEventAutomation as d, drainConversationMailbox as dn, setExperimentalFeatures as dr, appendConversationMessages as dt, ackMessages as en, buildDeterministicAssistantMessageId as er, openConversationProjection as et, deleteEventAutomation as f, getConversation as fn, createDurableLocationConfigurationService as fr, hydrateConversationMessages as ft, listEventAutomationsForTeam as g, isFinalAttempt as gn, getConfigDefaults as gr, resolveConversationTitle as gt, listDeletedEventAutomationsCreatedBy as h, hasRunnableConversationWork as hn, piMessageFromHistoryItem as hr, persistConversationMessageSummaries as ht, readScheduledAutomation as i, cancelHumanFacingPendingMessages as in, getSubscribedReplyPreflightDecision as ir, recordAuthorizationCompleted as it, resolveTurnSessionRouting as j, TurnSliceLimitExceededError as jn, createPluginEgress as jt, requireAutomationRevision as k, TURN_LIMIT_DISABLED_REASON as kn, turnHasReply as kt, blockEventAutomation as l, completeConversationWork as ln, getExperimentalFeatures as lr, recordToolExecutionStarted as lt, getEventAutomation as m, hasConversationStop as mn, historyItemFromPiMessage as mr, projectConversationMessages as mt, listScheduledAutomationsForTeam as n, appendInboundMessage as nn, SubscribedReplyReason as nr, recordAttachmentsDelivered as nt, saveScheduledAutomationInLock as o, clearConsumedConversationWake as on, estimateTextTokens as or, recordGuardianActionReviewed as ot, findMatchingEventAutomations as p, getConversationWorkState as pn, configValueSchema as pr, persistConversationMessages as pt, commitMessages as q, normalizeToolNameFromResult as qn, completeTurnRecord as qt, parseScheduledAutomationRow as r, beginConversationResume as rn, decideSubscribedThreadReply as rr, recordAuthenticationLinked as rt, scheduledRunId as s, clearExpiredConversationLease as sn, getAgentContextCompactionTriggerTokens as sr, recordMcpProviderConnected as st, createScheduledAutomation as t, appendExclusiveInboundMessage as tn, buildDeterministicTurnId as tr, recordAgentsInstructionsUpdated as tt, collectEventAutomationMatchKeys as u, deadLetterAttempt as un, isExperimentalFeatureEnabled as ur, recordTurnRoute as ut, saveEventAutomation as v, listActiveConversationIds as vn, fallbackShortTitle as vt, listAutomationVersions as w, requestAnotherSlice as wn, getConversationMessageSlackTs as wt, eventAutomationRunLimitReason as x, recordConversationRetry as xn, buildConversationContext as xt, setEventAutomationStatus as y, markConversationWorkEnqueued as yn, resolveTaskTitle as yt, hasEventCatalogEntries as z, getTerminalAssistantMessages as zn, canReusePendingAuthLink as zt };