@sentry/junior 0.272.0 → 0.273.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 (70) hide show
  1. package/dist/{adapter-BDYDi1F9.js → adapter-umap1cth.js} +1 -1
  2. package/dist/{admin-Dj7EFycG.js → admin-0o6E_3lo.js} +1 -1
  3. package/dist/{agent-runner-Cl6Usx2Y.js → agent-runner-D7Qz-lGO.js} +517 -298
  4. package/dist/api/schema.d.ts +1 -1
  5. package/dist/api/schema.js +4 -4
  6. package/dist/api.d.ts +1 -1
  7. package/dist/api.js +56 -573
  8. package/dist/{app-DjZblE2J.d.ts → app-BcyhJq8x.d.ts} +4 -9
  9. package/dist/app.d.ts +1 -1
  10. package/dist/app.js +673 -127
  11. package/dist/{assistant-reply-DN2w6JL_.js → assistant-reply-wZ7i4OFa.js} +1 -1
  12. package/dist/{automation-CV7g5h3A.js → automation-yffXcZt9.js} +1 -1
  13. package/dist/{briefs-PysAqnjV.js → briefs-CgMnlpIp.js} +4 -4
  14. package/dist/{catalog-runtime-BNeCWIaF.js → catalog-runtime-TygDsPOU.js} +1 -1
  15. package/dist/cli/chat.js +15 -15
  16. package/dist/cli/check.js +3 -3
  17. package/dist/cli/init.js +1 -1
  18. package/dist/cli/main.js +3 -3
  19. package/dist/cli/plugins.js +4 -4
  20. package/dist/cli/snapshot-create.js +4 -4
  21. package/dist/cli/upgrade.d.ts +1 -1
  22. package/dist/cli/upgrade.js +3 -3
  23. package/dist/{client-FRphcDWW.js → client-DhqbV0Fe.js} +63 -5
  24. package/dist/{reporting-schema-CZREhYc-.js → common-Eu6sx09c.js} +4 -1
  25. package/dist/{config-BUP9wjiR.js → config-C6FTd7PX.js} +6 -14
  26. package/dist/{connections-CesHau9u.js → connections-Dq72KajR.js} +11 -11
  27. package/dist/{conversation-BtQzLoK8.js → conversation-T4oabbBi.js} +10 -0
  28. package/dist/{conversation-privacy-DiGvA9a3.js → conversation-privacy-BeRgrsph.js} +1 -1
  29. package/dist/{credential-sync-D2odJC30.js → credential-sync-BmCbHAp4.js} +15 -16
  30. package/dist/{db-BPLmOwXf.d.ts → db-DOzBGLn5.d.ts} +1 -1
  31. package/dist/{executor-DGDpyegI.js → executor-BafgvQcc.js} +2 -2
  32. package/dist/instrumentation.js +114 -3
  33. package/dist/{job-queue-CjB97sii.js → job-queue-C4lphW9Y.js} +3 -3
  34. package/dist/{logging-CjkDr-jP.js → logging-CNs1e04n.js} +6 -1
  35. package/dist/{message-D9y8PKA1.js → message-CETC5lsl.js} +9 -9
  36. package/dist/nitro.d.ts +1 -1
  37. package/dist/nitro.js +3 -3
  38. package/dist/{oauth-callback-server-02Pej6hj.js → oauth-callback-server-DcQZsKMm.js} +2 -2
  39. package/dist/{credentialed-CgTulRDH.js → projection-BOpi891B.js} +4576 -49
  40. package/dist/{prompt-vfceH0c-.js → prompt-B1ySjUyf.js} +1 -1
  41. package/dist/{registry-7XgwNgQe.js → registry-B-yUrv3h.js} +2 -2
  42. package/dist/{resolve-CuuxNfWG.js → resolve-DqVtd4Q9.js} +3 -3
  43. package/dist/{result-BRhb6iN1.js → result-DQwTiLTw.js} +9 -9
  44. package/dist/{runner-DaWCofuS.js → runner-Dco5qMq6.js} +11 -11
  45. package/dist/{sandbox-egress-signals-Bn6FA1UC.js → sandbox-egress-signals-DFc_9Mcv.js} +1 -1
  46. package/dist/{schema-D5VLF4NT.js → schema-5xHDGlBW.js} +9 -7
  47. package/dist/{schema-CNjAzYOw.d.ts → schema-9OGdsaxF.d.ts} +13 -0
  48. package/dist/{service-account-D9vrsi8d.js → service-account-CWfk9Cs-.js} +3 -3
  49. package/dist/{service-account-H45LIAG2.js → service-account-smdPFWNy.js} +3 -3
  50. package/dist/{session-stop-HKl0ECX0.js → session-stop-DaT_7tYa.js} +11 -11
  51. package/dist/{skills-CsiKu3-g.js → skills-BbtJSF2-.js} +2 -2
  52. package/dist/{spawn-DqjBxqWh.js → spawn-Ceikdjkp.js} +2 -2
  53. package/dist/{list-JZUP1yog.js → stop-dKUc-x2K.js} +996 -432
  54. package/dist/{store-DyVKH0QB.js → store-C1xuZcLd.js} +4 -4
  55. package/dist/{store-CPF2kzbu.js → store-DaxUuOmD.js} +3 -3
  56. package/dist/{task-queue-BbQXCcNX.js → task-queue-ymAh_-P1.js} +4 -4
  57. package/dist/{task-runner-ChzW5E7R.js → task-runner-vlH6pEm3.js} +6 -6
  58. package/dist/{thread-state-Ds8JTsX6.js → thread-state-BkUpE9Ae.js} +4 -5
  59. package/dist/{turn-failure-response-DCMvjsA0.js → turn-failure-response-DIsD7ip_.js} +7 -8
  60. package/dist/{turn-lifecycle-DM_77q33.js → turn-lifecycle-BdPKdPS8.js} +1 -1
  61. package/dist/turn-session-routing-BdCTDq9m.js +1625 -0
  62. package/dist/{validation-DcGelbBi.js → validation-BQjCfh84.js} +1 -1
  63. package/dist/{version-C48azPKq.js → version-BpiFGZZW.js} +1 -1
  64. package/dist/version.js +1 -1
  65. package/dist/{viewer-BCRuO8Wg.js → viewer-C6Y1aXjZ.js} +27 -15
  66. package/dist/{work-zxkmbJAp.js → work-ChPh_YEh.js} +10 -38
  67. package/dist/{worker-CNNxHF1V.js → worker-Be_sK_ou.js} +6 -6
  68. package/package.json +4 -4
  69. package/dist/turn-session-routing-BV_gX7n3.js +0 -6072
  70. package/dist/{oauth-relay-CTygMkNx.js → oauth-relay-C6ZAyJgf.js} +1 -1
@@ -0,0 +1,1625 @@
1
+ import { D as fallbackShortTitle, Fr as formatSlackLink, Kr as postSlackMessage, Pr as escapeSlackMrkdwnText, Ur as openSlackDirectMessage, ea as JUNIOR_THREAD_STATE_TTL_MS, ia as getDashboardTaskLink, oa as ToolInputError } from "./projection-BOpi891B.js";
2
+ import { n as botConfig } from "./config-C6FTd7PX.js";
3
+ import { _ as runBestEffort, h as logWarn } from "./logging-CNs1e04n.js";
4
+ import { G as agentTurnCostUsd, K as agentTurnTotalTokens, S as getDb } from "./viewer-C6Y1aXjZ.js";
5
+ import { s as juniorConversations } from "./agent-invocations-C1RNOCs8.js";
6
+ import { d as juniorAutomations, l as juniorAutomationRuns, u as juniorAutomationVersions } from "./executor-BafgvQcc.js";
7
+ import { h as credentialSubjectSchema } from "./registry-B-yUrv3h.js";
8
+ import { r as getStateAdapter } from "./adapter-umap1cth.js";
9
+ import { O as eventAutomationSchema, S as automationVersionSchema, j as scheduledAutomationSchema } from "./automation-yffXcZt9.js";
10
+ import { createHash } from "node:crypto";
11
+ import { z } from "zod";
12
+ import { and, asc, count, desc, eq, gte, inArray, isNotNull, isNull, lte, max, ne, or, sql } from "drizzle-orm";
13
+ import { anyResourceMatchFields, describeEventTrigger, destinationSchema, destinationVisibilitySchema, eventMatchSchema, eventMatches, eventTriggerChoices, eventTriggerProblems, isSlackDestination, normalizeEventIdentifier, normalizeEventMatch, replyAttributionSchema, sourceSchema, taskOutcomeSchema } from "@sentry/junior-plugin-api";
14
+ // src/chat/events/catalog.ts
15
+ /** A source with nothing enabled, for a namespace that the catalog lacks. */
16
+ const NO_SOURCE = {
17
+ resourceTypes: [],
18
+ events: {},
19
+ anyResourceFields: {}
20
+ };
21
+ /**
22
+ * Build one catalog entry. It merges the filters of every resource type here,
23
+ * once, so trigger checks for each event copy and each row do not merge them
24
+ * again.
25
+ */
26
+ function eventCatalogEntry(registration) {
27
+ return {
28
+ ...registration,
29
+ anyResourceFields: anyResourceMatchFields(registration.resourceTypes)
30
+ };
31
+ }
32
+ /** Core-owned event namespace. Not a plugin publisher. */
33
+ const CORE_EVENT_NAMESPACE = "junior";
34
+ /** True when the catalog has any enabled registration. */
35
+ function hasEventCatalogEntries(catalog) {
36
+ return Object.keys(catalog).length > 0;
37
+ }
38
+ /**
39
+ * Catalog entries that durable event automations may select.
40
+ *
41
+ * Resource types with `automation: false` stay out of create/update schemas
42
+ * and trigger availability. A namespace with no such resource type left is
43
+ * removed.
44
+ */
45
+ function automationEventCatalog(catalog) {
46
+ return Object.fromEntries(Object.entries(catalog).flatMap(([namespace, registration]) => {
47
+ const resourceTypes = registration.resourceTypes.filter((resourceType) => registration.limits?.[resourceType.type]?.automation !== false);
48
+ if (resourceTypes.length === 0) return [];
49
+ if (resourceTypes.length === registration.resourceTypes.length) return [[namespace, registration]];
50
+ const supported = new Set(resourceTypes.flatMap((resourceType) => resourceType.supportedEvents));
51
+ return [[namespace, eventCatalogEntry({
52
+ ...registration,
53
+ resourceTypes,
54
+ events: Object.fromEntries(Object.entries(registration.events).filter(([eventType]) => supported.has(eventType)))
55
+ })]];
56
+ }));
57
+ }
58
+ /** Return why watchEvents cannot watch one resource type, if it cannot. */
59
+ function eventWatchUnavailable(catalog, namespace, resourceType) {
60
+ const limits = catalog[namespace]?.limits?.[resourceType];
61
+ return limits?.watch === false ? limits.watchUnavailableMessage : void 0;
62
+ }
63
+ function enumSchema(values, unavailableMessage) {
64
+ if (values.length === 0) return z.string().refine(() => false, unavailableMessage);
65
+ return z.enum(values);
66
+ }
67
+ /** Build the model-facing namespace enum from enabled event plugins. */
68
+ function eventNamespaceSchema(catalog) {
69
+ return enumSchema(Object.keys(catalog).sort(), "No event namespaces are enabled.");
70
+ }
71
+ /** Build the model-facing event enum from enabled plugin registrations. */
72
+ function registeredEventTypeSchema(catalog) {
73
+ return enumSchema([...new Set(Object.values(catalog).flatMap((registration) => registration.resourceTypes.flatMap((resourceType) => resourceType.supportedEvents)))].sort(), "No event types are enabled.");
74
+ }
75
+ /** Build the model-facing resource type enum from enabled plugin registrations. */
76
+ function registeredResourceTypeSchema(catalog) {
77
+ return enumSchema([...new Set(Object.values(catalog).flatMap((registration) => registration.resourceTypes.map((resourceType) => resourceType.type)))].sort(), "No resource types are enabled.");
78
+ }
79
+ /**
80
+ * Return app guidance for one event of a namespace. The source declares it
81
+ * once for the event, so every resource type and a trigger with no resource
82
+ * type get the same guidance.
83
+ */
84
+ function eventGuidance(catalog, namespace, eventType) {
85
+ return catalog[namespace]?.events[eventType]?.guidance;
86
+ }
87
+ /** Build the match object the tools accept for one resource type. */
88
+ function registeredEventMatchSchema() {
89
+ 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.");
90
+ }
91
+ /**
92
+ * List why a selection cannot work with the enabled plugins. Use it for new
93
+ * selections and for saved triggers, which can go stale after a plugin renames
94
+ * or removes a resource type, event, or filter.
95
+ */
96
+ function registeredEventTriggerProblems(catalog, selection, options) {
97
+ return eventTriggerProblems(catalog[selection.namespace] ?? NO_SOURCE, selection, options);
98
+ }
99
+ /**
100
+ * Describe a selection with the enabled plugins, for the dashboard and Slack.
101
+ * Its problems are the same as `registeredEventTriggerProblems`.
102
+ */
103
+ function describeRegisteredEventTrigger(catalog, selection) {
104
+ return describeEventTrigger(catalog[selection.namespace] ?? NO_SOURCE, selection);
105
+ }
106
+ function uniqueMatchValues(match) {
107
+ if (!match || Object.keys(match).length === 0) return void 0;
108
+ return Object.fromEntries(Object.entries(match).map(([key, value]) => [key, Array.isArray(value) ? [...new Set(value)] : value]));
109
+ }
110
+ /**
111
+ * Reject an event automation trigger or a Watch that cannot work, with every
112
+ * problem in one message for the agent. Return its match as it is saved:
113
+ * each text value in the case that its plugin declares, with no repeated
114
+ * list values.
115
+ */
116
+ function requireValidEventTrigger(catalog, selection, target) {
117
+ const fields = eventTriggerChoices(catalog[selection.namespace] ?? NO_SOURCE, selection)?.fields ?? {};
118
+ const normalized = {
119
+ ...selection,
120
+ match: normalizeEventMatch(fields, selection.match)
121
+ };
122
+ const problems = registeredEventTriggerProblems(catalog, normalized, { inputKeys: true });
123
+ if (problems.length > 0) {
124
+ const source = catalog[selection.namespace]?.displayName ?? selection.namespace;
125
+ const resource = selection.resourceType ? `${source} ${selection.resourceType.replaceAll("_", " ")}` : source;
126
+ throw new ToolInputError([target === "watch" ? `This ${resource} watch cannot be created.` : `This ${resource} trigger cannot be saved.`, ...problems.map((problem) => problem.message)].join(" "));
127
+ }
128
+ return uniqueMatchValues(normalized.match);
129
+ }
130
+ /**
131
+ * Normalize one selector with the convention declared by its plugin.
132
+ *
133
+ * Reject a selector that does not fit its resource type. Resource types can
134
+ * share event types, so only the identifier shows which one a selector names.
135
+ */
136
+ function requireEventIdentifier(catalog, input) {
137
+ const registration = catalog[input.namespace];
138
+ const identifier = normalizeEventIdentifier(registration, input.identifier);
139
+ const resourceTypes = registration?.resourceTypes ?? [];
140
+ const fits = (resourceType) => resourceType.identifier !== void 0 && new RegExp(resourceType.identifier.pattern).test(identifier);
141
+ const resourceType = resourceTypes.find((candidate) => candidate.type === input.resourceType);
142
+ if (!resourceType?.identifier || fits(resourceType)) return identifier;
143
+ const fitting = resourceTypes.filter(fits).map((candidate) => candidate.type);
144
+ 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(" "));
145
+ }
146
+ // src/chat/automations/revision.ts
147
+ /** Storage revisions prevent stale edits from replacing newer values. */
148
+ var AutomationConflictError = class extends ToolInputError {
149
+ constructor() {
150
+ super("This Automation changed after it was loaded. Reload it before saving.");
151
+ this.name = "AutomationConflictError";
152
+ }
153
+ };
154
+ /** Sort object keys so equal values produce equal JSON. */
155
+ function canonicalJson(value) {
156
+ if (Array.isArray(value)) return value.map(canonicalJson);
157
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, child]) => [key, canonicalJson(child)]));
158
+ return value;
159
+ }
160
+ /** Identify the stored values being edited, including lifecycle and creator authority. */
161
+ function automationRevision(task) {
162
+ return createHash("sha256").update(JSON.stringify(canonicalJson(task))).digest("hex");
163
+ }
164
+ /** Reject a stale edit while the storage owner holds the write lock. */
165
+ function requireAutomationRevision(task, revision) {
166
+ if (!task || automationRevision(task) !== revision) throw new AutomationConflictError();
167
+ }
168
+ // src/chat/automations/versions.ts
169
+ /** Automation versions keep each saved definition so people can see how it changed. */
170
+ /** Return the versioned part of an Automation. Lifecycle and run state are excluded. */
171
+ function automationDefinition(task) {
172
+ const common = {
173
+ title: task.title?.trim() || null,
174
+ instruction: task.task.text,
175
+ credentialMode: task.credentialMode,
176
+ destination: task.destination,
177
+ outcomes: task.outcomes
178
+ };
179
+ return "schedule" in task ? {
180
+ ...common,
181
+ schedule: task.schedule
182
+ } : {
183
+ ...common,
184
+ trigger: task.trigger
185
+ };
186
+ }
187
+ /** Compare definition values after key sorting. */
188
+ function sameDefinitionValue(left, right) {
189
+ return JSON.stringify(canonicalJson(left)) === JSON.stringify(canonicalJson(right));
190
+ }
191
+ /**
192
+ * Save a version when the definition changed. Call this in the same
193
+ * transaction and lock as the Automation write. Without `current`, the
194
+ * Automation is new, so the creator saves version 1.
195
+ */
196
+ async function recordAutomationVersion(db, task, current, editedBy) {
197
+ const definition = automationDefinition(task);
198
+ if (current && sameDefinitionValue(automationDefinition(current), definition)) return;
199
+ await db.insert(juniorAutomationVersions).values({
200
+ automationId: task.id,
201
+ version: sql`(
202
+ SELECT coalesce(max(${juniorAutomationVersions.version}), 0) + 1
203
+ FROM ${juniorAutomationVersions}
204
+ WHERE ${juniorAutomationVersions.automationId} = ${task.id}
205
+ )`,
206
+ createdAtMs: Date.now(),
207
+ editedBy: editedBy ?? (current ? null : task.createdBy),
208
+ definition
209
+ });
210
+ }
211
+ /**
212
+ * Select the newest saved version with the same definition as `task`.
213
+ *
214
+ * An Automation run stores this version so that a reader can see the
215
+ * instruction that ran after the definition changes. The value is null when
216
+ * no saved version has this definition.
217
+ */
218
+ function automationVersionOf(task) {
219
+ return sql`(
220
+ SELECT max(${juniorAutomationVersions.version})
221
+ FROM ${juniorAutomationVersions}
222
+ WHERE ${juniorAutomationVersions.automationId} = ${task.id}
223
+ AND ${juniorAutomationVersions.definition} = ${JSON.stringify(automationDefinition(task))}::jsonb
224
+ )`;
225
+ }
226
+ const versionColumns = {
227
+ createdAtMs: juniorAutomationVersions.createdAtMs,
228
+ definition: juniorAutomationVersions.definition,
229
+ editedBy: juniorAutomationVersions.editedBy,
230
+ kind: juniorAutomations.kind,
231
+ version: juniorAutomationVersions.version
232
+ };
233
+ function parseVersionRow(row) {
234
+ return automationVersionSchema.parse({
235
+ kind: row.kind,
236
+ version: row.version,
237
+ createdAt: new Date(row.createdAtMs).toISOString(),
238
+ editedBy: row.editedBy,
239
+ definition: row.definition
240
+ });
241
+ }
242
+ /** Read one saved definition. */
243
+ async function readAutomationVersion(db, automationId, version) {
244
+ const [row] = await db.select(versionColumns).from(juniorAutomationVersions).innerJoin(juniorAutomations, eq(juniorAutomations.id, juniorAutomationVersions.automationId)).where(and(eq(juniorAutomationVersions.automationId, automationId), eq(juniorAutomationVersions.version, version))).limit(1);
245
+ return row ? parseVersionRow(row) : void 0;
246
+ }
247
+ /** Read saved definitions, newest first. */
248
+ async function listAutomationVersions(db, automationId, limit) {
249
+ return (await db.select(versionColumns).from(juniorAutomationVersions).innerJoin(juniorAutomations, eq(juniorAutomations.id, juniorAutomationVersions.automationId)).where(eq(juniorAutomationVersions.automationId, automationId)).orderBy(desc(juniorAutomationVersions.version)).limit(limit)).map(parseVersionRow);
250
+ }
251
+ // src/chat/event-automations/run-limit.ts
252
+ const RUN_LIST_PREFIX = "junior:event_automation_runs:v1";
253
+ const RUN_LIST_BURST_ROOM = 100;
254
+ const runEntrySchema = z.union([z.object({
255
+ atMs: z.number().finite(),
256
+ id: z.string().min(1)
257
+ }).strict(), z.object({ reset: z.literal(true) }).strict()]);
258
+ function runListKey(automationId) {
259
+ return `${RUN_LIST_PREFIX}:${automationId}`;
260
+ }
261
+ function runListMaxLength() {
262
+ return botConfig.maxEventAutomationRuns + RUN_LIST_BURST_ROOM;
263
+ }
264
+ async function appendRunEntry(state, automationId, entry) {
265
+ await state.connect();
266
+ await state.appendToList(runListKey(automationId), entry, {
267
+ maxLength: runListMaxLength(),
268
+ ttlMs: botConfig.eventAutomationRunWindowMs
269
+ });
270
+ }
271
+ function describeRunWindow(windowMs) {
272
+ for (const [unit, unitMs] of [
273
+ ["day", 864e5],
274
+ ["hour", 36e5],
275
+ ["minute", 6e4],
276
+ ["second", 1e3]
277
+ ]) if (windowMs % unitMs === 0) {
278
+ const count = windowMs / unitMs;
279
+ return count === 1 ? unit : `${count} ${unit}s`;
280
+ }
281
+ return `${windowMs} ms`;
282
+ }
283
+ /** Reason stored on an Event automation that the run limit blocked. */
284
+ function eventAutomationRunLimitReason() {
285
+ const window = describeRunWindow(botConfig.eventAutomationRunWindowMs);
286
+ return `This automation reached its limit of ${botConfig.maxEventAutomationRuns} runs per ${window}. Check that its trigger is not too broad, then resume it.`;
287
+ }
288
+ function hasRunKey(entries, runKey) {
289
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
290
+ const entry = entries[index];
291
+ if ("reset" in entry) return false;
292
+ if (entry.id === runKey) return true;
293
+ }
294
+ return false;
295
+ }
296
+ /**
297
+ * Count one run of an Event automation, or refuse when its window is full.
298
+ *
299
+ * `runKey` is the dispatch idempotency key, so a provider retry of the same
300
+ * Event does not count again.
301
+ */
302
+ async function admitEventAutomationRun(args) {
303
+ const state = args.state ?? getStateAdapter();
304
+ await state.connect();
305
+ const readEntries = async () => {
306
+ const values = await state.getList(runListKey(args.automationId));
307
+ return {
308
+ entries: values.flatMap((value) => {
309
+ const entry = runEntrySchema.safeParse(value);
310
+ return entry.success ? [entry.data] : [];
311
+ }),
312
+ length: values.length
313
+ };
314
+ };
315
+ if (!hasRunKey((await readEntries()).entries, args.runKey)) await appendRunEntry(state, args.automationId, {
316
+ atMs: args.nowMs,
317
+ id: args.runKey
318
+ });
319
+ const windowStartMs = args.nowMs - botConfig.eventAutomationRunWindowMs;
320
+ const { entries, length } = await readEntries();
321
+ let lastReset = -1;
322
+ entries.forEach((entry, index) => {
323
+ if ("reset" in entry) lastReset = index;
324
+ });
325
+ const oldest = entries[0];
326
+ const full = lastReset === -1 && length >= runListMaxLength() && oldest !== void 0 && "atMs" in oldest && oldest.atMs > windowStartMs;
327
+ const runKeys = /* @__PURE__ */ new Set();
328
+ for (const entry of entries.slice(lastReset + 1)) {
329
+ if ("reset" in entry) continue;
330
+ if (entry.atMs > windowStartMs) runKeys.add(entry.id);
331
+ if (entry.id === args.runKey) return full || runKeys.size > botConfig.maxEventAutomationRuns ? "limited" : "allowed";
332
+ }
333
+ return "limited";
334
+ }
335
+ /**
336
+ * Forget the runs counted for one Event automation.
337
+ *
338
+ * Resume and revive call this so the automation starts with a new window.
339
+ * Call it before the automation becomes active, so an Event cannot see the
340
+ * old full count and block it again.
341
+ */
342
+ async function clearEventAutomationRuns(automationId, state = getStateAdapter()) {
343
+ await appendRunEntry(state, automationId, { reset: true });
344
+ }
345
+ // src/chat/event-automations/store.ts
346
+ /**
347
+ * Validate an event automation and split it into its SQL columns. Lifecycle
348
+ * columns are written separately.
349
+ */
350
+ function eventAutomationColumns(task) {
351
+ const { status: _status, statusReason: _statusReason, title, ...definition } = task;
352
+ const { createdAtMs: _createdAtMs, creatorIdentityId, id: _id, ...record } = eventAutomationSchema.parse(definition);
353
+ return {
354
+ channelId: record.destination.channelId,
355
+ creatorIdentityId,
356
+ creatorSlackUserId: record.createdBy.slackUserId,
357
+ identifier: record.trigger.identifier ?? null,
358
+ instruction: record.task.text,
359
+ namespace: record.trigger.namespace,
360
+ record,
361
+ teamId: record.destination.teamId,
362
+ title: title?.trim() || null
363
+ };
364
+ }
365
+ function eventAutomationRowInput(row) {
366
+ return {
367
+ ...row.record,
368
+ createdAtMs: row.createdAtMs,
369
+ creatorIdentityId: row.creatorIdentityId,
370
+ id: row.id
371
+ };
372
+ }
373
+ /** Decode an event Automation row with its SQL-backed fields. */
374
+ function parseEventAutomationRow(row) {
375
+ return withRowFields(row, eventAutomationSchema.parse(eventAutomationRowInput(row)));
376
+ }
377
+ /**
378
+ * Decode a row for Event matching. Log and skip a row that does not parse,
379
+ * so one bad row cannot stop matching for its whole namespace.
380
+ */
381
+ function readMatchableEventAutomationRow(row) {
382
+ const parsed = eventAutomationSchema.safeParse(eventAutomationRowInput(row));
383
+ if (parsed.success) return withRowFields(row, parsed.data);
384
+ logWarn("event_automation.row.invalid", {
385
+ "app.task.id": row.id,
386
+ "app.task.type": "event",
387
+ "error.message": parsed.error.message
388
+ });
389
+ }
390
+ function withRowFields(row, task) {
391
+ const title = row.title?.trim();
392
+ return {
393
+ ...task,
394
+ status: row.status,
395
+ ...row.statusReason ? { statusReason: row.statusReason } : void 0,
396
+ ...title ? { title } : void 0
397
+ };
398
+ }
399
+ function eventAutomationWhere(id) {
400
+ return and(eq(juniorAutomations.id, id), eq(juniorAutomations.kind, "event"));
401
+ }
402
+ function activeEventAutomationWhere() {
403
+ return and(eq(juniorAutomations.kind, "event"), ne(juniorAutomations.status, "deleted"));
404
+ }
405
+ /** Read one event automation by id, including deleted rows when present. */
406
+ async function getEventAutomation(db, id) {
407
+ const rows = await db.select().from(juniorAutomations).where(eventAutomationWhere(id)).limit(1);
408
+ return rows[0] ? parseEventAutomationRow(rows[0]) : void 0;
409
+ }
410
+ /** Create one retry-stable event automation, or revive a deleted row with the new payload. */
411
+ async function createEventAutomation(db, task) {
412
+ const columns = eventAutomationColumns(task);
413
+ if ((await getEventAutomation(db, task.id))?.status === "deleted") await clearEventAutomationRuns(task.id);
414
+ return db.transaction(async (tx) => {
415
+ const inserted = await tx.insert(juniorAutomations).values({
416
+ ...columns,
417
+ createdAtMs: task.createdAtMs,
418
+ id: task.id,
419
+ kind: "event",
420
+ status: "active"
421
+ }).onConflictDoNothing().returning();
422
+ if (inserted[0]) {
423
+ const created = parseEventAutomationRow(inserted[0]);
424
+ await recordAutomationVersion(tx, created, void 0);
425
+ return created;
426
+ }
427
+ const existing = await getEventAutomation(tx, task.id);
428
+ if (!existing) throw new Error(`Automation ${task.id} already exists with another kind.`);
429
+ if (existing.status !== "deleted") return existing;
430
+ const rows = await tx.update(juniorAutomations).set({
431
+ ...columns,
432
+ status: "active",
433
+ statusReason: null
434
+ }).where(and(eventAutomationWhere(task.id), eq(juniorAutomations.status, "deleted"))).returning();
435
+ if (!rows[0]) return await getEventAutomation(tx, task.id);
436
+ const revived = parseEventAutomationRow(rows[0]);
437
+ await recordAutomationVersion(tx, revived, void 0);
438
+ return revived;
439
+ });
440
+ }
441
+ /**
442
+ * Replace an existing non-deleted event automation under a row lock.
443
+ * Pass `editedBy` when a person changes the definition so its version names them.
444
+ */
445
+ async function saveEventAutomation(db, task, expectedRevision, editedBy) {
446
+ const columns = eventAutomationColumns(task);
447
+ return db.transaction(async (tx) => {
448
+ const rows = await tx.select().from(juniorAutomations).where(eventAutomationWhere(task.id)).for("update");
449
+ const current = rows[0] ? parseEventAutomationRow(rows[0]) : void 0;
450
+ if (expectedRevision !== void 0) requireAutomationRevision(current, expectedRevision);
451
+ if (!current || current.status === "deleted") return void 0;
452
+ const saved = parseEventAutomationRow((await tx.update(juniorAutomations).set(columns).where(eventAutomationWhere(task.id)).returning())[0]);
453
+ await recordAutomationVersion(tx, saved, current, editedBy);
454
+ return saved;
455
+ });
456
+ }
457
+ /** Mark one existing event automation deleted while retaining the row for history. */
458
+ async function deleteEventAutomation(db, id) {
459
+ const rows = await db.update(juniorAutomations).set({ status: "deleted" }).where(and(eq(juniorAutomations.id, id), activeEventAutomationWhere())).returning();
460
+ return rows[0] ? parseEventAutomationRow(rows[0]) : void 0;
461
+ }
462
+ /**
463
+ * List deleted event automations created by one user, newest first.
464
+ * Used only to keep historical runs after the live automation list drops the row.
465
+ */
466
+ async function listDeletedEventAutomationsCreatedBy(db, user, limit) {
467
+ const identityIds = user.identities.map((identity) => identity.id);
468
+ if (identityIds.length === 0) return [];
469
+ 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);
470
+ }
471
+ /**
472
+ * Select live rows whose identifier is one of these, and rows with no
473
+ * identifier. A row with no identifier runs for every resource of its
474
+ * namespace, so it gets every copy of an Event.
475
+ */
476
+ function eventIdentifierWhere(identifiers) {
477
+ return or(isNull(juniorAutomations.identifier), ...identifiers.length > 0 ? [inArray(juniorAutomations.identifier, [...identifiers])] : []);
478
+ }
479
+ /** SQL for rows whose trigger selects any of these event types. */
480
+ function eventTypesWhere(eventTypes) {
481
+ return or(...eventTypes.map((eventType) => sql`${juniorAutomations.record}->'trigger'->'events' @> ${JSON.stringify([eventType])}::jsonb`));
482
+ }
483
+ /**
484
+ * Return why a row must not run for an Event with one of these identifiers,
485
+ * or nothing when it may.
486
+ *
487
+ * The identifier column and the saved trigger must agree. A row with no
488
+ * identifier must also have a trigger that the enabled `catalog` accepts. A
489
+ * plugin can later require a filter or stop letting an event run for every
490
+ * resource, and the row must not stay broad.
491
+ */
492
+ function eventAutomationGateProblem(row, task, identifiers, catalog) {
493
+ const identifier = task.trigger.identifier;
494
+ if (identifier !== void 0) return identifier === row.identifier && identifiers.includes(identifier) ? void 0 : "The saved trigger has a different identifier from its row.";
495
+ if (row.identifier !== null) return "The saved trigger has no identifier, but its row has one.";
496
+ const problems = registeredEventTriggerProblems(catalog, task.trigger);
497
+ return problems.length > 0 ? problems.map((problem) => problem.message).join(" ") : void 0;
498
+ }
499
+ /**
500
+ * Read the rows that can match an Event: live rows of one team and
501
+ * namespace whose identifier is one of these, or that have no identifier,
502
+ * and that select one of these event types. Skip and log a row that does
503
+ * not parse or that the gate rejects.
504
+ */
505
+ async function readGatedEventAutomations(db, input) {
506
+ 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));
507
+ const plugins = automationEventCatalog(input.catalog);
508
+ return rows.flatMap((row) => {
509
+ const task = readMatchableEventAutomationRow(row);
510
+ if (!task) return [];
511
+ const problem = eventAutomationGateProblem(row, task, input.identifiers, plugins);
512
+ if (problem === void 0) return [task];
513
+ logWarn("event_automation.gate.rejected", {
514
+ "app.task.id": task.id,
515
+ "app.task.type": "event",
516
+ "app.event_automation.skip_reason": problem
517
+ });
518
+ return [];
519
+ });
520
+ }
521
+ /** The filters of a match whose keys are in this data. */
522
+ function knownMatch(match, data) {
523
+ if (!match) return void 0;
524
+ return Object.fromEntries(Object.entries(match).filter(([key]) => data[key] !== void 0));
525
+ }
526
+ /**
527
+ * Collect match keys from live event automations for these identifiers and
528
+ * event types, and from automations with no identifier. Does not evaluate
529
+ * match values, except for automations with no identifier: one whose
530
+ * filters on the keys in `data` fail adds no keys. So a broad trigger for
531
+ * one repository does not make every repository load its lazy data.
532
+ */
533
+ async function collectEventAutomationMatchKeys(db, input) {
534
+ const eventTypes = [...new Set(input.eventTypes.map((eventType) => eventType.trim()).filter(Boolean))];
535
+ const identifiers = [...new Set(input.identifiers.map((value) => value.trim()).filter(Boolean))];
536
+ if (eventTypes.length === 0) return [];
537
+ const tasks = await readGatedEventAutomations(db, {
538
+ catalog: input.catalog,
539
+ eventTypes,
540
+ identifiers,
541
+ namespace: input.namespace,
542
+ teamId: input.teamId
543
+ });
544
+ const keys = /* @__PURE__ */ new Set();
545
+ for (const task of tasks) {
546
+ if (task.trigger.identifier === void 0 && input.data && !eventMatches(knownMatch(task.trigger.match, input.data), input.data)) continue;
547
+ for (const key of Object.keys(task.trigger.match ?? {})) keys.add(key);
548
+ }
549
+ return [...keys].sort();
550
+ }
551
+ /**
552
+ * Find every live task matching one normalized event: tasks for its exact
553
+ * identifier, and tasks with no identifier whose filters match. The gate
554
+ * skips a row whose trigger and identifier column disagree, and a row with
555
+ * no identifier whose trigger the enabled `catalog` rejects.
556
+ */
557
+ async function findMatchingEventAutomations(db, event, teamId, catalog) {
558
+ return (await readGatedEventAutomations(db, {
559
+ catalog,
560
+ eventTypes: [event.eventType],
561
+ identifiers: [event.identifier],
562
+ namespace: event.namespace,
563
+ teamId
564
+ })).filter((task) => task.trigger.events.includes(event.eventType) && eventMatches(task.trigger.match, event.data));
565
+ }
566
+ /**
567
+ * Change lifecycle under the same row lock as edits, without changing
568
+ * credentials. A pause keeps an unresolved block reason, so resuming a paused
569
+ * blocked automation returns it to blocked. Resume a blocked automation to
570
+ * clear the reason. A resume also clears the run-limit count first.
571
+ */
572
+ async function setEventAutomationStatus(db, id, status, revision) {
573
+ if (status === "active" && (await getEventAutomation(db, id))?.status !== "active") await clearEventAutomationRuns(id);
574
+ return db.transaction(async (tx) => {
575
+ const rows = await tx.select().from(juniorAutomations).where(eventAutomationWhere(id)).for("update");
576
+ const current = rows[0] ? parseEventAutomationRow(rows[0]) : void 0;
577
+ requireAutomationRevision(current, revision);
578
+ if (!current || current.status === "deleted") throw new Error("Automation no longer exists.");
579
+ const next = status === "paused" ? {
580
+ status,
581
+ statusReason: current.statusReason
582
+ } : current.status === "paused" && current.statusReason ? {
583
+ status: "blocked",
584
+ statusReason: current.statusReason
585
+ } : {
586
+ status,
587
+ statusReason: void 0
588
+ };
589
+ await tx.update(juniorAutomations).set({
590
+ status: next.status,
591
+ statusReason: next.statusReason ?? null
592
+ }).where(eventAutomationWhere(id));
593
+ const { statusReason: _statusReason, ...rest } = current;
594
+ return {
595
+ ...rest,
596
+ status: next.status,
597
+ ...next.statusReason ? { statusReason: next.statusReason } : void 0
598
+ };
599
+ });
600
+ }
601
+ /**
602
+ * Stop an event automation after a run reports a problem that only its
603
+ * creator can fix. An active automation becomes blocked. A paused automation
604
+ * stays paused and keeps the reason, so resume returns it to blocked.
605
+ * Deleted automations do not change. Returns true only when this call set
606
+ * the automation to blocked, so the creator notice goes once and never for
607
+ * an automation that the dashboard shows as paused.
608
+ */
609
+ async function blockEventAutomation(db, id, reason) {
610
+ return (await db.update(juniorAutomations).set({
611
+ status: sql`case when ${juniorAutomations.status} = 'active' then 'blocked' else ${juniorAutomations.status} end`,
612
+ statusReason: reason
613
+ }).where(and(eventAutomationWhere(id), or(eq(juniorAutomations.status, "active"), and(eq(juniorAutomations.status, "paused"), sql`${juniorAutomations.statusReason} is distinct from ${reason}`)))).returning({ status: juniorAutomations.status }))[0]?.status === "blocked";
614
+ }
615
+ // src/chat/scheduled-automations/tasks.ts
616
+ const SCHEDULER_KEY_PREFIX = "junior:scheduler";
617
+ /** Decode a Scheduled automation row. Returns undefined for an invalid row. */
618
+ function parseScheduledAutomationRow(row) {
619
+ const columns = {
620
+ createdAtMs: row.createdAtMs,
621
+ creatorIdentityId: row.creatorIdentityId,
622
+ id: row.id,
623
+ nextRunAtMs: row.nextRunAtMs,
624
+ runNowAtMs: row.runNowAtMs,
625
+ status: row.status,
626
+ statusReason: row.statusReason,
627
+ title: row.title
628
+ };
629
+ const parsed = scheduledAutomationSchema.safeParse({
630
+ ...row.record,
631
+ ...Object.fromEntries(Object.entries(columns).filter(([, value]) => value !== null))
632
+ });
633
+ return parsed.success ? parsed.data : void 0;
634
+ }
635
+ /** Keep scan paths from returning current or legacy tombstones. */
636
+ function isListedScheduledAutomation(task) {
637
+ return task !== void 0 && task.status !== "deleted";
638
+ }
639
+ /** Build the stable run id for one task occurrence. */
640
+ function scheduledRunId(taskId, scheduledForMs) {
641
+ return `${taskId}:${scheduledForMs}`;
642
+ }
643
+ /** Acquire the deployed advisory lock for one scheduled automation. */
644
+ function withScheduledAutomationLock(db, taskId, callback) {
645
+ return db.transaction(async (tx) => {
646
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${`${SCHEDULER_KEY_PREFIX}:task:${taskId}:lock`}))`);
647
+ return await callback(tx);
648
+ });
649
+ }
650
+ function requireStoredTask(task) {
651
+ const parsed = scheduledAutomationSchema.safeParse(task);
652
+ if (!parsed.success) throw new Error("Scheduled automation routing context is invalid.");
653
+ const { title, ...current } = parsed.data;
654
+ const normalizedTitle = title?.trim() || void 0;
655
+ return {
656
+ ...current,
657
+ ...normalizedTitle ? { title: normalizedTitle } : void 0
658
+ };
659
+ }
660
+ async function upsertScheduledAutomation(db, task) {
661
+ const { createdAtMs, creatorIdentityId, id, nextRunAtMs, runNowAtMs, status, statusReason, title, ...record } = task;
662
+ const columns = {
663
+ channelId: record.destination.channelId,
664
+ createdAtMs,
665
+ creatorIdentityId,
666
+ creatorSlackUserId: record.createdBy.slackUserId,
667
+ instruction: record.task.text,
668
+ nextRunAtMs: nextRunAtMs ?? null,
669
+ record,
670
+ runNowAtMs: runNowAtMs ?? null,
671
+ status,
672
+ statusReason: statusReason ?? null,
673
+ teamId: record.destination.teamId,
674
+ title: title?.trim() || null
675
+ };
676
+ await db.insert(juniorAutomations).values({
677
+ ...columns,
678
+ id,
679
+ kind: "scheduled"
680
+ }).onConflictDoUpdate({
681
+ target: juniorAutomations.id,
682
+ set: columns
683
+ });
684
+ }
685
+ /** Read and decode one scheduled automation. */
686
+ async function readScheduledAutomation(db, taskId) {
687
+ const rows = await db.select().from(juniorAutomations).where(and(eq(juniorAutomations.id, taskId), eq(juniorAutomations.kind, "scheduled"))).limit(1);
688
+ return rows[0] ? parseScheduledAutomationRow(rows[0]) : void 0;
689
+ }
690
+ async function readListedScheduledAutomations(db, teamId) {
691
+ return (await db.select().from(juniorAutomations).where(and(eq(juniorAutomations.kind, "scheduled"), ne(juniorAutomations.status, "deleted"), teamId === void 0 ? void 0 : eq(juniorAutomations.teamId, teamId))).orderBy(asc(juniorAutomations.createdAtMs), asc(juniorAutomations.id))).map(parseScheduledAutomationRow).filter(isListedScheduledAutomation);
692
+ }
693
+ /** List decoded scheduled automations for one team in stable creation order. */
694
+ async function listScheduledAutomationsForTeam(db, teamId) {
695
+ return await readListedScheduledAutomations(db, teamId);
696
+ }
697
+ async function writeScheduledAutomation(db, task, current, editedBy) {
698
+ if (current?.status === "blocked" && task.status === "active" && typeof task.nextRunAtMs === "number" && Number.isFinite(task.nextRunAtMs)) await db.delete(juniorAutomationRuns).where(and(eq(juniorAutomationRuns.id, scheduledRunId(task.id, task.nextRunAtMs)), eq(juniorAutomationRuns.status, "blocked")));
699
+ if (task.status === "deleted" && current?.status !== "deleted") await skipPendingRunsForDeletedTask(db, task);
700
+ await upsertScheduledAutomation(db, task);
701
+ await recordAutomationVersion(db, task, current, editedBy);
702
+ }
703
+ async function skipPendingRunsForDeletedTask(db, task) {
704
+ await db.update(juniorAutomationRuns).set({
705
+ completedAtMs: task.updatedAtMs,
706
+ errorMessage: `Scheduled automation ${task.id} was deleted before the run started.`,
707
+ status: "skipped"
708
+ }).where(and(eq(juniorAutomationRuns.automationId, task.id), eq(juniorAutomationRuns.status, "pending")));
709
+ }
710
+ /** Create a scheduled automation once under its retry-stable task lock. */
711
+ async function createScheduledAutomation(db, task) {
712
+ const next = requireStoredTask(task);
713
+ return await withScheduledAutomationLock(db, task.id, async (tx) => {
714
+ const current = await readScheduledAutomation(tx, task.id);
715
+ if (current) return current;
716
+ await writeScheduledAutomation(tx, next, void 0);
717
+ return next;
718
+ });
719
+ }
720
+ /**
721
+ * Save a scheduled automation and clear its blocked occurrence on reactivation.
722
+ * Pass `editedBy` when a person changes the definition so its version names them.
723
+ */
724
+ async function saveScheduledAutomation(db, task, expectedRevision, editedBy) {
725
+ const next = requireStoredTask(task);
726
+ await withScheduledAutomationLock(db, task.id, async (tx) => {
727
+ const current = await readScheduledAutomation(tx, task.id);
728
+ if (expectedRevision !== void 0) requireAutomationRevision(current, expectedRevision);
729
+ await writeScheduledAutomation(tx, next, current, editedBy);
730
+ });
731
+ return next;
732
+ }
733
+ /** Save a scheduled automation inside a task lock already held by the caller. */
734
+ async function saveScheduledAutomationInLock(db, task, current) {
735
+ await writeScheduledAutomation(db, requireStoredTask(task), current);
736
+ }
737
+ /** Mark one live scheduled automation deleted while keeping its history. */
738
+ async function deleteScheduledAutomation(db, id, nowMs = Date.now()) {
739
+ return await withScheduledAutomationLock(db, id, async (tx) => {
740
+ const current = await readScheduledAutomation(tx, id);
741
+ if (!current || current.status === "deleted") return void 0;
742
+ const deleted = requireStoredTask({
743
+ ...current,
744
+ nextRunAtMs: void 0,
745
+ runNowAtMs: void 0,
746
+ status: "deleted",
747
+ updatedAtMs: nowMs
748
+ });
749
+ await writeScheduledAutomation(tx, deleted, current);
750
+ return deleted;
751
+ });
752
+ }
753
+ // src/api/conversations/aggregate.ts
754
+ function usageTokenValue(source, field) {
755
+ return sql`CASE
756
+ WHEN ${source.usage}->>${field} IS NOT NULL
757
+ THEN (${source.usage}->>${field})::double precision
758
+ ELSE NULL
759
+ END`;
760
+ }
761
+ function tokenValue(source) {
762
+ return sql`
763
+ CASE
764
+ WHEN COALESCE(
765
+ ${source.usage}->>'inputTokens',
766
+ ${source.usage}->>'outputTokens',
767
+ ${source.usage}->>'cachedInputTokens',
768
+ ${source.usage}->>'cacheCreationTokens'
769
+ ) IS NOT NULL
770
+ THEN COALESCE((${source.usage}->>'inputTokens')::double precision, 0)
771
+ + COALESCE((${source.usage}->>'outputTokens')::double precision, 0)
772
+ + COALESCE((${source.usage}->>'cachedInputTokens')::double precision, 0)
773
+ + COALESCE((${source.usage}->>'cacheCreationTokens')::double precision, 0)
774
+ WHEN ${source.usage}->>'totalTokens' IS NOT NULL
775
+ THEN (${source.usage}->>'totalTokens')::double precision
776
+ ELSE NULL
777
+ END
778
+ `;
779
+ }
780
+ function costValue(source) {
781
+ return conversationUsageCostExpr(source.usage);
782
+ }
783
+ /** Read one conversation row's usage cost as a SQL expression. */
784
+ function conversationUsageCostExpr(usage) {
785
+ return sql`
786
+ CASE
787
+ WHEN ${usage}->'cost'->>'total' IS NOT NULL
788
+ THEN (${usage}->'cost'->>'total')::double precision
789
+ WHEN COALESCE(
790
+ ${usage}->'cost'->>'input',
791
+ ${usage}->'cost'->>'output',
792
+ ${usage}->'cost'->>'cacheRead',
793
+ ${usage}->'cost'->>'cacheWrite'
794
+ ) IS NOT NULL
795
+ THEN COALESCE((${usage}->'cost'->>'input')::double precision, 0)
796
+ + COALESCE((${usage}->'cost'->>'output')::double precision, 0)
797
+ + COALESCE((${usage}->'cost'->>'cacheRead')::double precision, 0)
798
+ + COALESCE((${usage}->'cost'->>'cacheWrite')::double precision, 0)
799
+ ELSE NULL
800
+ END
801
+ `;
802
+ }
803
+ /** Select complete conversation metrics inside the database instead of materializing source rows. */
804
+ function conversationAggregateColumns(sources) {
805
+ const metrics = sources?.metrics ?? juniorConversations;
806
+ const roots = sources?.roots ?? juniorConversations;
807
+ const conversationCount = sources ? sql`COUNT(DISTINCT ${roots.conversationId})` : sql`COUNT(*)`;
808
+ return {
809
+ active: sql`${conversationCount} FILTER (
810
+ WHERE ${roots.executionStatus} NOT IN ('idle', 'failed')
811
+ )::integer`,
812
+ conversations: sql`${conversationCount}::integer`,
813
+ cachedInputTokens: sql`SUM(${usageTokenValue(metrics, "cachedInputTokens")})::double precision`,
814
+ cacheCreationTokens: sql`SUM(${usageTokenValue(metrics, "cacheCreationTokens")})::double precision`,
815
+ costUsd: sql`SUM(${costValue(metrics)})::double precision`,
816
+ durationMs: sql`COALESCE(SUM(${metrics.durationMs}), 0)::double precision`,
817
+ failed: sql`${conversationCount} FILTER (
818
+ WHERE ${roots.executionStatus} = 'failed'
819
+ )::integer`,
820
+ inputTokens: sql`SUM(${usageTokenValue(metrics, "inputTokens")})::double precision`,
821
+ tokens: sql`SUM(${tokenValue(metrics)})::double precision`
822
+ };
823
+ }
824
+ /** Select the complete first/last activity range for a grouped conversation aggregate. */
825
+ function conversationRangeColumns() {
826
+ return {
827
+ firstSeenAt: sql`MIN(${juniorConversations.createdAt})`.mapWith(juniorConversations.createdAt),
828
+ lastSeenAt: sql`MAX(${juniorConversations.lastActivityAt})`.mapWith(juniorConversations.lastActivityAt)
829
+ };
830
+ }
831
+ /** Count distinct UTC activity dates without loading conversation timestamps into the app. */
832
+ function conversationActiveDaysColumn() {
833
+ return sql`COUNT(DISTINCT (${juniorConversations.lastActivityAt} AT TIME ZONE 'UTC')::date)::integer`;
834
+ }
835
+ // src/chat/automations/execution-stats.ts
836
+ const AUTOMATION_EXECUTION_STATUSES = [
837
+ "blocked",
838
+ "completed",
839
+ "failed"
840
+ ];
841
+ /** An execution is a run with an outcome. Skipped runs are not executions. */
842
+ function isExecution() {
843
+ return and(inArray(juniorAutomationRuns.status, [...AUTOMATION_EXECUTION_STATUSES]), isNotNull(juniorAutomationRuns.completedAtMs));
844
+ }
845
+ function addUsd(current, next) {
846
+ return Math.round((current + next) * 0xe8d4a51000) / 0xe8d4a51000;
847
+ }
848
+ const EMPTY_RUN_WINDOWS = {
849
+ 1: 0,
850
+ 7: 0,
851
+ 30: 0,
852
+ 90: 0
853
+ };
854
+ /** Empty run or cost windows for automations with no execution history. */
855
+ function emptyAutomationRunWindows() {
856
+ return { ...EMPTY_RUN_WINDOWS };
857
+ }
858
+ function utcDate(nowMs) {
859
+ return new Date(nowMs).toISOString().slice(0, 10);
860
+ }
861
+ function emptyDay(date) {
862
+ return {
863
+ costUsd: 0,
864
+ date,
865
+ event: 0,
866
+ scheduled: 0
867
+ };
868
+ }
869
+ function emptyStatusDay(date) {
870
+ return {
871
+ blocked: 0,
872
+ completed: 0,
873
+ date,
874
+ failed: 0
875
+ };
876
+ }
877
+ /**
878
+ * Return the Conversation id when that Conversation exists, else null. A
879
+ * dispatch can end before it creates its Conversation, and the run's
880
+ * `conversation_id` is a foreign key.
881
+ */
882
+ function existingConversationId(conversationId) {
883
+ return sql`(select ${juniorConversations.conversationId} from ${juniorConversations} where ${juniorConversations.conversationId} = ${conversationId})`;
884
+ }
885
+ /**
886
+ * Record the outcome of one Automation run. A run that already has an outcome
887
+ * keeps it. A failed write is logged, and the heartbeat settles a started
888
+ * event run later.
889
+ */
890
+ async function recordAutomationExecution(automationId, options) {
891
+ const nowMs = options.nowMs ?? Date.now();
892
+ const status = options.status ?? "completed";
893
+ const outcome = {
894
+ completedAtMs: nowMs,
895
+ errorMessage: options.errorMessage ?? null,
896
+ status
897
+ };
898
+ try {
899
+ await getDb().insert(juniorAutomationRuns).values({
900
+ id: options.executionId,
901
+ automationId,
902
+ conversationId: options.conversationId ? existingConversationId(options.conversationId) : null,
903
+ ...outcome
904
+ }).onConflictDoUpdate({
905
+ target: juniorAutomationRuns.id,
906
+ set: {
907
+ ...outcome,
908
+ conversationId: sql`coalesce(${juniorAutomationRuns.conversationId}, excluded.conversation_id)`
909
+ },
910
+ setWhere: inArray(juniorAutomationRuns.status, ["pending", "running"])
911
+ });
912
+ } catch (error) {
913
+ logWarn("task.execution.stat_failed", {
914
+ error,
915
+ "app.task.execution.id": options.executionId,
916
+ "app.task.execution.status": status,
917
+ "app.task.execution.task_id": automationId
918
+ });
919
+ }
920
+ }
921
+ /** Load usage analytics for the given automations. */
922
+ async function readAutomationExecutionSummaries(automationIds, options = {}) {
923
+ if (automationIds.length === 0) return /* @__PURE__ */ new Map();
924
+ const nowMs = options.nowMs ?? Date.now();
925
+ const oneDayAgoMs = nowMs - 864e5;
926
+ const sevenDaysAgoMs = nowMs - 6048e5;
927
+ const thirtyDaysAgoMs = nowMs - 2592e6;
928
+ const ninetyDaysAgoMs = nowMs - 7776e6;
929
+ const db = getDb();
930
+ const selected = and(isExecution(), inArray(juniorAutomationRuns.automationId, automationIds));
931
+ const latest = db.selectDistinctOn([juniorAutomationRuns.automationId], {
932
+ status: juniorAutomationRuns.status,
933
+ conversationId: juniorAutomationRuns.conversationId,
934
+ automationId: juniorAutomationRuns.automationId
935
+ }).from(juniorAutomationRuns).where(selected).orderBy(juniorAutomationRuns.automationId, desc(juniorAutomationRuns.completedAtMs), desc(juniorAutomationRuns.id)).as("latest_task_execution");
936
+ const conversationCost = conversationUsageCostExpr(juniorConversations.usage);
937
+ const costSince = (sinceMs) => sql`coalesce(sum(${conversationCost}) filter (where ${juniorAutomationRuns.completedAtMs} >= ${sinceMs}), 0)::double precision`;
938
+ const rows = await db.select({
939
+ costLast1Day: costSince(oneDayAgoMs),
940
+ costLast7Days: costSince(sevenDaysAgoMs),
941
+ costLast30Days: costSince(thirtyDaysAgoMs),
942
+ costLast90Days: costSince(ninetyDaysAgoMs),
943
+ lastRunStatus: latest.status,
944
+ lastConversationId: latest.conversationId,
945
+ lastExecutedAtMs: max(juniorAutomationRuns.completedAtMs),
946
+ runsLast1Day: sql`count(*) filter (where ${juniorAutomationRuns.completedAtMs} >= ${oneDayAgoMs})::int`,
947
+ runsLast7Days: sql`count(*) filter (where ${juniorAutomationRuns.completedAtMs} >= ${sevenDaysAgoMs})::int`,
948
+ runsLast30Days: sql`count(*) filter (where ${juniorAutomationRuns.completedAtMs} >= ${thirtyDaysAgoMs})::int`,
949
+ runsLast90Days: sql`count(*) filter (where ${juniorAutomationRuns.completedAtMs} >= ${ninetyDaysAgoMs})::int`,
950
+ automationId: juniorAutomationRuns.automationId,
951
+ totalRuns: sql`count(*)::int`
952
+ }).from(juniorAutomationRuns).innerJoin(latest, eq(latest.automationId, juniorAutomationRuns.automationId)).leftJoin(juniorConversations, executionConversation()).where(selected).groupBy(juniorAutomationRuns.automationId, latest.conversationId, latest.status);
953
+ return new Map(rows.map((row) => [row.automationId, {
954
+ costUsd: {
955
+ 1: addUsd(0, row.costLast1Day),
956
+ 7: addUsd(0, row.costLast7Days),
957
+ 30: addUsd(0, row.costLast30Days),
958
+ 90: addUsd(0, row.costLast90Days)
959
+ },
960
+ lastRunStatus: row.lastRunStatus,
961
+ ...row.lastConversationId ? { lastConversationId: row.lastConversationId } : void 0,
962
+ ...row.lastExecutedAtMs !== null ? { lastExecutedAtMs: row.lastExecutedAtMs } : void 0,
963
+ runs: {
964
+ 1: row.runsLast1Day,
965
+ 7: row.runsLast7Days,
966
+ 30: row.runsLast30Days,
967
+ 90: row.runsLast90Days
968
+ },
969
+ totalRuns: row.totalRuns
970
+ }]));
971
+ }
972
+ /** Load a fixed trailing window of completed executions stacked by task type. */
973
+ async function readAutomationExecutionDays(dayCount = 90, options = {}) {
974
+ const end = utcDate(options.nowMs ?? Date.now());
975
+ const endMs = Date.parse(`${end}T23:59:59.999Z`);
976
+ const startMs = Date.parse(`${end}T00:00:00.000Z`) - (dayCount - 1) * 864e5;
977
+ const executionDate = sql`to_char(to_timestamp(${juniorAutomationRuns.completedAtMs} / 1000.0) at time zone 'UTC', 'YYYY-MM-DD')`;
978
+ const rows = await readCompletedExecutionBuckets({
979
+ access: options.access,
980
+ bucket: executionDate,
981
+ endMs,
982
+ startMs
983
+ });
984
+ const byDate = /* @__PURE__ */ new Map();
985
+ for (let offset = 0; offset < dayCount; offset += 1) {
986
+ const date = utcDate(startMs + offset * 864e5);
987
+ byDate.set(date, emptyDay(date));
988
+ }
989
+ for (const row of rows) {
990
+ const day = byDate.get(row.date);
991
+ if (!day) continue;
992
+ if (row.kind === "scheduled") day.scheduled = row.count;
993
+ else if (row.kind === "event") day.event = row.count;
994
+ if (row.costUsd !== null && row.costUsd > 0) day.costUsd = addUsd(day.costUsd, row.costUsd);
995
+ }
996
+ return [...byDate.values()];
997
+ }
998
+ /** Load a fixed trailing window of completed executions stacked by hour. */
999
+ async function readAutomationExecutionHours(hourCount = 168, options = {}) {
1000
+ const nowMs = options.nowMs ?? Date.now();
1001
+ const end = new Date(nowMs);
1002
+ end.setUTCMinutes(0, 0, 0);
1003
+ const endMs = end.getTime() + 3599999;
1004
+ const startMs = end.getTime() - (hourCount - 1) * 36e5;
1005
+ const executionHour = sql`to_char(to_timestamp(${juniorAutomationRuns.completedAtMs} / 1000.0) at time zone 'UTC', 'YYYY-MM-DD"T"HH24')`;
1006
+ const rows = await readCompletedExecutionBuckets({
1007
+ access: options.access,
1008
+ bucket: executionHour,
1009
+ endMs,
1010
+ startMs
1011
+ });
1012
+ const byHour = /* @__PURE__ */ new Map();
1013
+ for (let offset = 0; offset < hourCount; offset += 1) {
1014
+ const date = new Date(startMs + offset * 36e5).toISOString().slice(0, 13);
1015
+ byHour.set(date, emptyDay(date));
1016
+ }
1017
+ for (const row of rows) {
1018
+ const hour = byHour.get(row.date);
1019
+ if (!hour) continue;
1020
+ if (row.kind === "scheduled") hour.scheduled = row.count;
1021
+ else if (row.kind === "event") hour.event = row.count;
1022
+ if (row.costUsd !== null && row.costUsd > 0) hour.costUsd = addUsd(hour.costUsd, row.costUsd);
1023
+ }
1024
+ return [...byHour.values()];
1025
+ }
1026
+ /** Count completed executions and their cost per time bucket and kind. */
1027
+ async function readCompletedExecutionBuckets(args) {
1028
+ const conversationCost = conversationUsageCostExpr(juniorConversations.usage);
1029
+ return await getDb().select({
1030
+ count: count(),
1031
+ costUsd: sql`SUM(${conversationCost})::double precision`,
1032
+ date: args.bucket,
1033
+ kind: juniorAutomations.kind
1034
+ }).from(juniorAutomationRuns).innerJoin(juniorAutomations, eq(juniorAutomations.id, juniorAutomationRuns.automationId)).leftJoin(juniorConversations, eq(juniorConversations.conversationId, juniorAutomationRuns.conversationId)).where(and(gte(juniorAutomationRuns.completedAtMs, args.startMs), lte(juniorAutomationRuns.completedAtMs, args.endMs), eq(juniorAutomationRuns.status, "completed"), args.access)).groupBy(args.bucket, juniorAutomations.kind).orderBy(asc(args.bucket), asc(juniorAutomations.kind));
1035
+ }
1036
+ /** Load the Automation run linked to one conversation, if any. */
1037
+ async function readAutomationExecutionByConversationId(args) {
1038
+ const [row] = await getDb().select({
1039
+ kind: juniorAutomations.kind,
1040
+ automationId: juniorAutomationRuns.automationId
1041
+ }).from(juniorAutomationRuns).innerJoin(juniorAutomations, eq(juniorAutomations.id, juniorAutomationRuns.automationId)).where(eq(juniorAutomationRuns.conversationId, args.conversationId)).limit(1);
1042
+ return row;
1043
+ }
1044
+ /**
1045
+ * Load the display name of the Automation linked to each untitled
1046
+ * conversation. Like the Automation list, the name falls back to the first
1047
+ * line of the instruction. Titled conversations and conversations without a
1048
+ * linked Automation are absent from the result.
1049
+ */
1050
+ async function readAutomationTitlesByConversationId(conversations) {
1051
+ const titles = /* @__PURE__ */ new Map();
1052
+ const untitledIds = conversations.flatMap((conversation) => conversation.title ? [] : [conversation.conversationId]);
1053
+ if (untitledIds.length === 0) return titles;
1054
+ const rows = await getDb().select({
1055
+ conversationId: juniorAutomationRuns.conversationId,
1056
+ instruction: juniorAutomations.instruction,
1057
+ title: juniorAutomations.title
1058
+ }).from(juniorAutomationRuns).innerJoin(juniorAutomations, eq(juniorAutomations.id, juniorAutomationRuns.automationId)).where(inArray(juniorAutomationRuns.conversationId, untitledIds));
1059
+ for (const row of rows) {
1060
+ const title = row.title?.trim() || fallbackShortTitle(row.instruction, "");
1061
+ if (row.conversationId && title) titles.set(row.conversationId, title);
1062
+ }
1063
+ return titles;
1064
+ }
1065
+ /** Load newest-first executions for one automation, with conversation titles when present. */
1066
+ async function readAutomationExecutions(args) {
1067
+ return (await getDb().select(executionColumns).from(juniorAutomationRuns).leftJoin(juniorConversations, executionConversation()).leftJoin(juniorAutomationVersions, executionVersion()).where(and(isExecution(), eq(juniorAutomationRuns.automationId, args.automationId))).orderBy(desc(juniorAutomationRuns.completedAtMs), desc(juniorAutomationRuns.id)).limit(args.limit)).map((row) => mapAutomationExecutionRecord(row));
1068
+ }
1069
+ /** Load newest-first executions for the supplied viewer-visible automations. */
1070
+ async function readAutomationRuns(args) {
1071
+ if (args.automationIds.length === 0) return [];
1072
+ return (await getDb().select({
1073
+ ...executionColumns,
1074
+ kind: juniorAutomations.kind,
1075
+ automationId: juniorAutomationRuns.automationId
1076
+ }).from(juniorAutomationRuns).innerJoin(juniorAutomations, eq(juniorAutomations.id, juniorAutomationRuns.automationId)).leftJoin(juniorConversations, executionConversation()).leftJoin(juniorAutomationVersions, executionVersion()).where(and(isExecution(), inArray(juniorAutomationRuns.automationId, args.automationIds))).orderBy(desc(juniorAutomationRuns.completedAtMs), desc(juniorAutomationRuns.id)).limit(args.limit)).map((row) => ({
1077
+ ...mapAutomationExecutionRecord(row),
1078
+ kind: row.kind,
1079
+ automationId: row.automationId
1080
+ }));
1081
+ }
1082
+ /** Columns that describe one execution, its Conversation, and its version. */
1083
+ const executionColumns = {
1084
+ automationVersion: juniorAutomationRuns.automationVersion,
1085
+ conversationId: juniorAutomationRuns.conversationId,
1086
+ credentialMode: sql`${juniorAutomationVersions.definition}->>'credentialMode'`,
1087
+ durationMs: juniorConversations.durationMs,
1088
+ errorMessage: juniorAutomationRuns.errorMessage,
1089
+ executedAtMs: juniorAutomationRuns.completedAtMs,
1090
+ executionId: juniorAutomationRuns.id,
1091
+ scheduledForMs: juniorAutomationRuns.scheduledForMs,
1092
+ startedAtMs: juniorAutomationRuns.startedAtMs,
1093
+ status: juniorAutomationRuns.status,
1094
+ title: juniorConversations.title,
1095
+ usage: juniorConversations.usage
1096
+ };
1097
+ function executionConversation() {
1098
+ return eq(juniorConversations.conversationId, juniorAutomationRuns.conversationId);
1099
+ }
1100
+ function executionVersion() {
1101
+ return and(eq(juniorAutomationVersions.automationId, juniorAutomationRuns.automationId), eq(juniorAutomationVersions.version, juniorAutomationRuns.automationVersion));
1102
+ }
1103
+ function isoTime(ms) {
1104
+ return ms === null ? void 0 : new Date(ms).toISOString();
1105
+ }
1106
+ function mapAutomationExecutionRecord(row) {
1107
+ const title = row.title?.trim();
1108
+ const costUsd = agentTurnCostUsd(row.usage ?? void 0);
1109
+ const totalTokens = agentTurnTotalTokens(row.usage ?? void 0);
1110
+ const durationMs = typeof row.durationMs === "number" && Number.isFinite(row.durationMs) && row.durationMs > 0 ? row.durationMs : void 0;
1111
+ const error = row.errorMessage?.trim();
1112
+ const scheduledFor = isoTime(row.scheduledForMs);
1113
+ const startedAt = isoTime(row.startedAtMs);
1114
+ return {
1115
+ ...row.automationVersion !== null ? { automationVersion: row.automationVersion } : void 0,
1116
+ ...row.conversationId ? { conversationId: row.conversationId } : void 0,
1117
+ ...costUsd !== void 0 ? { costUsd } : void 0,
1118
+ ...row.credentialMode === "creator" || row.credentialMode === "system" ? { credentialMode: row.credentialMode } : void 0,
1119
+ ...durationMs !== void 0 ? { durationMs } : void 0,
1120
+ ...error ? { error } : void 0,
1121
+ executedAt: new Date(row.executedAtMs).toISOString(),
1122
+ executionId: row.executionId,
1123
+ ...scheduledFor ? { scheduledFor } : void 0,
1124
+ ...startedAt ? { startedAt } : void 0,
1125
+ status: row.status,
1126
+ ...title ? { title } : void 0,
1127
+ ...totalTokens !== void 0 ? { totalTokens } : void 0
1128
+ };
1129
+ }
1130
+ /** Load a fixed trailing window of terminal executions for one automation by status. */
1131
+ async function readAutomationExecutionStatusDays(args) {
1132
+ const dayCount = args.dayCount ?? 90;
1133
+ const end = utcDate(args.nowMs ?? Date.now());
1134
+ const endMs = Date.parse(`${end}T23:59:59.999Z`);
1135
+ const startMs = Date.parse(`${end}T00:00:00.000Z`) - (dayCount - 1) * 864e5;
1136
+ const executionDate = sql`to_char(to_timestamp(${juniorAutomationRuns.completedAtMs} / 1000.0) at time zone 'UTC', 'YYYY-MM-DD')`;
1137
+ const rows = await readExecutionStatusBuckets({
1138
+ automationId: args.automationId,
1139
+ bucket: executionDate,
1140
+ endMs,
1141
+ startMs
1142
+ });
1143
+ const byDate = /* @__PURE__ */ new Map();
1144
+ for (let offset = 0; offset < dayCount; offset += 1) {
1145
+ const date = utcDate(startMs + offset * 864e5);
1146
+ byDate.set(date, emptyStatusDay(date));
1147
+ }
1148
+ for (const row of rows) {
1149
+ const day = byDate.get(row.date);
1150
+ if (!day) continue;
1151
+ if (row.status === "completed") day.completed = row.count;
1152
+ else if (row.status === "failed") day.failed = row.count;
1153
+ else if (row.status === "blocked") day.blocked = row.count;
1154
+ }
1155
+ return [...byDate.values()];
1156
+ }
1157
+ /** Load a fixed trailing window of terminal executions for one automation by hour. */
1158
+ async function readAutomationExecutionStatusHours(args) {
1159
+ const hourCount = args.hourCount ?? 168;
1160
+ const nowMs = args.nowMs ?? Date.now();
1161
+ const end = new Date(nowMs);
1162
+ end.setUTCMinutes(0, 0, 0);
1163
+ const endMs = end.getTime() + 3599999;
1164
+ const startMs = end.getTime() - (hourCount - 1) * 36e5;
1165
+ const executionHour = sql`to_char(to_timestamp(${juniorAutomationRuns.completedAtMs} / 1000.0) at time zone 'UTC', 'YYYY-MM-DD"T"HH24')`;
1166
+ const rows = await readExecutionStatusBuckets({
1167
+ automationId: args.automationId,
1168
+ bucket: executionHour,
1169
+ endMs,
1170
+ startMs
1171
+ });
1172
+ const byHour = /* @__PURE__ */ new Map();
1173
+ for (let offset = 0; offset < hourCount; offset += 1) {
1174
+ const date = new Date(startMs + offset * 36e5).toISOString().slice(0, 13);
1175
+ byHour.set(date, emptyStatusDay(date));
1176
+ }
1177
+ for (const row of rows) {
1178
+ const hour = byHour.get(row.date);
1179
+ if (!hour) continue;
1180
+ if (row.status === "completed") hour.completed = row.count;
1181
+ else if (row.status === "failed") hour.failed = row.count;
1182
+ else if (row.status === "blocked") hour.blocked = row.count;
1183
+ }
1184
+ return [...byHour.values()];
1185
+ }
1186
+ /** Count one automation's executions per time bucket and status. */
1187
+ async function readExecutionStatusBuckets(args) {
1188
+ return await getDb().select({
1189
+ count: count(),
1190
+ date: args.bucket,
1191
+ status: juniorAutomationRuns.status
1192
+ }).from(juniorAutomationRuns).where(and(isExecution(), eq(juniorAutomationRuns.automationId, args.automationId), gte(juniorAutomationRuns.completedAtMs, args.startMs), lte(juniorAutomationRuns.completedAtMs, args.endMs))).groupBy(args.bucket, juniorAutomationRuns.status).orderBy(asc(args.bucket), asc(juniorAutomationRuns.status));
1193
+ }
1194
+ // src/chat/automations/blocked-notice.ts
1195
+ function buildBlockedNoticeText(automationId, title, reason) {
1196
+ const url = getDashboardTaskLink(automationId);
1197
+ return [
1198
+ `Your automation *${escapeSlackMrkdwnText(title)}* is blocked. It won't run again until you resume it.`,
1199
+ `> ${escapeSlackMrkdwnText(reason.replace(/\s+/g, " ").trim())}`,
1200
+ url ? `Fix the problem, then ${formatSlackLink(url, "resume it")}.` : "Fix the problem, then ask me to resume it."
1201
+ ].join("\n");
1202
+ }
1203
+ /**
1204
+ * Tell an Automation's creator in a direct message that a run blocked it,
1205
+ * and why. Call this once, after the Automation itself is stored as blocked,
1206
+ * so the dashboard matches the notice. The notice is best-effort: the reason
1207
+ * also shows on the dashboard and in the Automation tools.
1208
+ */
1209
+ async function notifyAutomationBlocked(ref) {
1210
+ await runBestEffort(async () => {
1211
+ const task = ref.kind === "scheduled" ? await readScheduledAutomation(getDb(), ref.automationId) : await getEventAutomation(getDb(), ref.automationId);
1212
+ if (!task) return;
1213
+ const title = task.title ?? fallbackShortTitle(task.task.text, ref.kind === "scheduled" ? "Scheduled automation" : "Event automation");
1214
+ await postSlackMessage({
1215
+ channelId: await openSlackDirectMessage(task.createdBy.slackUserId),
1216
+ text: buildBlockedNoticeText(ref.automationId, title, ref.reason)
1217
+ });
1218
+ }, "automation.blocked_notice.failed", {
1219
+ "app.task.type": ref.kind,
1220
+ ...ref.dispatchId ? { "app.dispatch.id": ref.dispatchId } : void 0
1221
+ });
1222
+ }
1223
+ // src/chat/agent-dispatch/store.ts
1224
+ const DISPATCH_PREFIX = "junior:agent_dispatch";
1225
+ const DISPATCH_LOCK_TTL_MS = 6e5;
1226
+ const DISPATCH_MAILBOX_APPEND_INDEX_LOCK_TTL_MS = 1e4;
1227
+ const nonEmptyExactStringSchema = z.string().min(1).refine((value) => value === value.trim() && value.toLowerCase() !== "unknown");
1228
+ const pendingDispatchMailboxAppendIndexSchema = z.array(nonEmptyExactStringSchema);
1229
+ const dispatchStatusSchema = z.enum([
1230
+ "pending",
1231
+ "running",
1232
+ "awaiting_resume",
1233
+ "completed",
1234
+ "failed",
1235
+ "blocked"
1236
+ ]);
1237
+ const dispatchActorSchema = z.object({
1238
+ platform: z.literal("system"),
1239
+ name: nonEmptyExactStringSchema
1240
+ }).strict();
1241
+ const dispatchRecordSchema = z.object({
1242
+ actor: dispatchActorSchema,
1243
+ createdAtMs: z.number().finite(),
1244
+ credentialSubject: credentialSubjectSchema.optional(),
1245
+ destination: destinationSchema,
1246
+ destinationVisibility: destinationVisibilitySchema,
1247
+ errorMessage: z.string().optional(),
1248
+ id: nonEmptyExactStringSchema,
1249
+ idempotencyKey: z.string().min(1),
1250
+ input: z.string().min(1),
1251
+ metadata: z.record(z.string(), z.string()).optional(),
1252
+ plugin: nonEmptyExactStringSchema,
1253
+ replyAttribution: replyAttributionSchema.optional(),
1254
+ resultMessageTs: z.string().optional(),
1255
+ source: sourceSchema,
1256
+ outcomes: z.array(taskOutcomeSchema).max(5).optional(),
1257
+ status: dispatchStatusSchema,
1258
+ updatedAtMs: z.number().finite()
1259
+ }).strict().superRefine((record, ctx) => {
1260
+ if (!isSlackDestination(record.destination)) {
1261
+ ctx.addIssue({
1262
+ code: z.ZodIssueCode.custom,
1263
+ message: "Dispatch destination platform must be slack",
1264
+ path: ["destination"]
1265
+ });
1266
+ return;
1267
+ }
1268
+ const subject = record.credentialSubject;
1269
+ if (!subject) return;
1270
+ if (subject.allowedWhen === "private-direct-conversation") {
1271
+ if (!record.destination.channelId.startsWith("D")) {
1272
+ ctx.addIssue({
1273
+ code: z.ZodIssueCode.custom,
1274
+ message: "Dispatch credentialSubject requires a private direct Slack destination",
1275
+ path: ["credentialSubject"]
1276
+ });
1277
+ return;
1278
+ }
1279
+ if (subject.binding.type !== "slack-direct-conversation" || subject.binding.teamId !== record.destination.teamId || subject.binding.channelId !== record.destination.channelId) ctx.addIssue({
1280
+ code: z.ZodIssueCode.custom,
1281
+ message: "Dispatch credentialSubject binding must match destination",
1282
+ path: ["credentialSubject", "binding"]
1283
+ });
1284
+ return;
1285
+ }
1286
+ if (subject.allowedWhen === "timer-watch") {
1287
+ ctx.addIssue({
1288
+ code: z.ZodIssueCode.custom,
1289
+ message: "Dispatch credentialSubject cannot use a timer Watch binding",
1290
+ path: ["credentialSubject", "allowedWhen"]
1291
+ });
1292
+ return;
1293
+ }
1294
+ if (subject.binding.type !== subject.allowedWhen || subject.binding.plugin !== record.plugin || subject.binding.taskId !== subject.taskId) ctx.addIssue({
1295
+ code: z.ZodIssueCode.custom,
1296
+ message: "Dispatch credentialSubject binding must match task",
1297
+ path: ["credentialSubject", "binding"]
1298
+ });
1299
+ });
1300
+ /** Return the durable key for one plugin-facing dispatch projection. */
1301
+ function getDispatchStorageKey(id) {
1302
+ return `${DISPATCH_PREFIX}:record:${id}`;
1303
+ }
1304
+ function dispatchLockKey(id) {
1305
+ return `${DISPATCH_PREFIX}:lock:${id}`;
1306
+ }
1307
+ function pendingDispatchMailboxAppendIndexKey() {
1308
+ return `${DISPATCH_PREFIX}:incomplete`;
1309
+ }
1310
+ function pendingDispatchMailboxAppendIndexLockKey() {
1311
+ return `${DISPATCH_PREFIX}:incomplete:lock`;
1312
+ }
1313
+ function normalizeMetadata(metadata) {
1314
+ if (!metadata) return;
1315
+ const entries = Object.entries(metadata).filter((entry) => typeof entry[0] === "string" && typeof entry[1] === "string");
1316
+ return entries.length > 0 ? Object.fromEntries(entries) : void 0;
1317
+ }
1318
+ const DISPATCH_ID_PREFIX = "agent-dispatch:";
1319
+ /** Return the stable dispatch id for one plugin idempotency key. */
1320
+ function buildDispatchId(plugin, idempotencyKey) {
1321
+ return `dispatch_${createHash("sha256").update(plugin).update("\0").update(idempotencyKey).digest("hex").slice(0, 32)}`;
1322
+ }
1323
+ /** Parse one durable dispatch projection record. */
1324
+ function parseDispatchRecord(value) {
1325
+ const parsed = dispatchRecordSchema.safeParse(value);
1326
+ return parsed.success ? parsed.data : void 0;
1327
+ }
1328
+ /** Return the isolated durable conversation id for one dispatch. */
1329
+ function getDispatchConversationId(dispatch) {
1330
+ return `${DISPATCH_ID_PREFIX}${dispatch.id}`;
1331
+ }
1332
+ /** Return the stable synthetic input message id for one dispatch turn. */
1333
+ function getDispatchInputMessageId(dispatchId) {
1334
+ return `${DISPATCH_ID_PREFIX}${dispatchId}`;
1335
+ }
1336
+ /**
1337
+ * Return the dispatch id of a dispatch Conversation, such as an Automation
1338
+ * run. Other Conversations have no dispatch.
1339
+ */
1340
+ function getDispatchIdForConversation(conversationId) {
1341
+ if (!conversationId.startsWith(DISPATCH_ID_PREFIX)) return void 0;
1342
+ return conversationId.slice(15) || void 0;
1343
+ }
1344
+ /** Return the stable turn id used by every run of one dispatch. */
1345
+ function getDispatchTurnId(dispatchId) {
1346
+ return `dispatch:${dispatchId}`;
1347
+ }
1348
+ function toDispatchProjection(record) {
1349
+ return {
1350
+ id: record.id,
1351
+ status: record.status,
1352
+ ...record.resultMessageTs ? { resultMessageTs: record.resultMessageTs } : void 0,
1353
+ ...record.errorMessage ? { errorMessage: record.errorMessage } : void 0
1354
+ };
1355
+ }
1356
+ /** Gate recovery to dispatches that can still make progress. */
1357
+ function isTerminalDispatchStatus(status) {
1358
+ return status === "completed" || status === "failed" || status === "blocked";
1359
+ }
1360
+ /** Serialize dispatch projection mutations. */
1361
+ async function withDispatchLock(dispatchId, task) {
1362
+ const state = getStateAdapter();
1363
+ await state.connect();
1364
+ const lock = await state.acquireLock(dispatchLockKey(dispatchId), DISPATCH_LOCK_TTL_MS);
1365
+ if (!lock) throw new Error(`Could not acquire dispatch lock for ${dispatchId}`);
1366
+ try {
1367
+ return await task(state);
1368
+ } finally {
1369
+ await state.releaseLock(lock);
1370
+ }
1371
+ }
1372
+ async function putRecord(state, record) {
1373
+ const parsed = dispatchRecordSchema.safeParse(record);
1374
+ if (!parsed.success) throw new Error(`Dispatch record is invalid: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "<root>"} ${issue.message}`).join("; ")}`);
1375
+ const next = parsed.data;
1376
+ await state.set(getDispatchStorageKey(next.id), next, JUNIOR_THREAD_STATE_TTL_MS);
1377
+ }
1378
+ async function updatePendingDispatchMailboxAppendIndex(state, update) {
1379
+ const lock = await state.acquireLock(pendingDispatchMailboxAppendIndexLockKey(), DISPATCH_MAILBOX_APPEND_INDEX_LOCK_TTL_MS);
1380
+ if (!lock) throw new Error("Could not acquire dispatch mailbox append index lock");
1381
+ try {
1382
+ const stored = await state.get(pendingDispatchMailboxAppendIndexKey());
1383
+ const ids = stored === void 0 || stored === null ? [] : pendingDispatchMailboxAppendIndexSchema.parse(stored);
1384
+ const next = [...new Set(update([...new Set(ids)]))].slice(-1e4);
1385
+ if (next.length === ids.length && next.every((id, index) => id === ids[index])) return;
1386
+ await state.set(pendingDispatchMailboxAppendIndexKey(), next, JUNIOR_THREAD_STATE_TTL_MS);
1387
+ } finally {
1388
+ await state.releaseLock(lock);
1389
+ }
1390
+ }
1391
+ /** Load dispatch state for conversation work and plugin projections. */
1392
+ async function getDispatchRecord(id) {
1393
+ const state = getStateAdapter();
1394
+ await state.connect();
1395
+ return parseDispatchRecord(await state.get(getDispatchStorageKey(id)));
1396
+ }
1397
+ /** Create a dispatch idempotently within its owner namespace. */
1398
+ async function createOrGetDispatch(args) {
1399
+ const id = buildDispatchId(args.plugin, args.options.idempotencyKey);
1400
+ return await withDispatchLock(id, async (state) => {
1401
+ const existing = parseDispatchRecord(await state.get(getDispatchStorageKey(id)));
1402
+ if (existing) return {
1403
+ record: existing,
1404
+ status: "already_exists"
1405
+ };
1406
+ const metadata = normalizeMetadata(args.options.metadata);
1407
+ const record = {
1408
+ actor: {
1409
+ platform: "system",
1410
+ name: args.plugin
1411
+ },
1412
+ createdAtMs: args.nowMs,
1413
+ ...args.options.credentialSubject ? { credentialSubject: args.options.credentialSubject } : void 0,
1414
+ destination: args.options.destination,
1415
+ destinationVisibility: args.options.destinationVisibility,
1416
+ id,
1417
+ idempotencyKey: args.options.idempotencyKey,
1418
+ input: args.options.input,
1419
+ ...metadata ? { metadata } : void 0,
1420
+ plugin: args.plugin,
1421
+ ...args.options.replyAttribution ? { replyAttribution: args.options.replyAttribution } : void 0,
1422
+ ...args.options.outcomes ? { outcomes: args.options.outcomes } : void 0,
1423
+ status: "pending",
1424
+ source: args.options.source,
1425
+ updatedAtMs: args.nowMs
1426
+ };
1427
+ await updatePendingDispatchMailboxAppendIndex(state, (ids) => ids.includes(id) ? ids : [...ids, id]);
1428
+ await putRecord(state, record);
1429
+ return {
1430
+ record,
1431
+ status: "created"
1432
+ };
1433
+ });
1434
+ }
1435
+ async function transitionDispatch(id, transition) {
1436
+ return await withDispatchLock(id, async (state) => {
1437
+ const current = parseDispatchRecord(await state.get(getDispatchStorageKey(id)));
1438
+ if (!current) return;
1439
+ const transitioned = transition(current);
1440
+ if (transitioned === current) return current;
1441
+ const next = {
1442
+ ...transitioned,
1443
+ updatedAtMs: Date.now()
1444
+ };
1445
+ await putRecord(state, next);
1446
+ return next;
1447
+ });
1448
+ }
1449
+ /** Mark a dispatch projection as actively running. */
1450
+ async function markDispatchRunning(id) {
1451
+ return await transitionDispatch(id, (record) => isTerminalDispatchStatus(record.status) ? record : {
1452
+ ...record,
1453
+ status: "running",
1454
+ errorMessage: void 0
1455
+ });
1456
+ }
1457
+ /** Project a durable awaiting-resume turn state to the plugin API. */
1458
+ async function markDispatchAwaitingResume(id) {
1459
+ return await transitionDispatch(id, (record) => isTerminalDispatchStatus(record.status) ? record : {
1460
+ ...record,
1461
+ status: "awaiting_resume"
1462
+ });
1463
+ }
1464
+ async function recordEventAutomationExecution(previous, next, status) {
1465
+ if (!next || next.status !== status || previous?.status === status) return;
1466
+ if (next.plugin !== "junior") return;
1467
+ const eventAutomationId = next.metadata?.eventAutomationId;
1468
+ if (!eventAutomationId) return;
1469
+ await recordAutomationExecution(eventAutomationId, {
1470
+ conversationId: getDispatchConversationId(next),
1471
+ errorMessage: next.errorMessage,
1472
+ executionId: next.id,
1473
+ nowMs: next.updatedAtMs,
1474
+ status
1475
+ });
1476
+ }
1477
+ /**
1478
+ * Project a blocked turn to the plugin API. An Event automation is blocked
1479
+ * first, so a retry after a failed write still blocks it. Its creator is told
1480
+ * when the block is first stored. The heartbeat blocks a Scheduled automation
1481
+ * and tells its creator.
1482
+ */
1483
+ async function markDispatchBlocked(id, errorMessage, resultMessageTs) {
1484
+ const previous = await getDispatchRecord(id);
1485
+ const eventAutomationId = previous?.metadata?.eventAutomationId;
1486
+ if (previous?.plugin === "junior" && eventAutomationId && !isTerminalDispatchStatus(previous.status) && await blockEventAutomation(getDb(), eventAutomationId, errorMessage)) await notifyAutomationBlocked({
1487
+ automationId: eventAutomationId,
1488
+ dispatchId: id,
1489
+ kind: "event",
1490
+ reason: errorMessage
1491
+ });
1492
+ const next = await transitionDispatch(id, (record) => isTerminalDispatchStatus(record.status) ? record : {
1493
+ ...record,
1494
+ errorMessage,
1495
+ ...resultMessageTs ? { resultMessageTs } : void 0,
1496
+ status: "blocked"
1497
+ });
1498
+ await recordEventAutomationExecution(previous, next, "blocked");
1499
+ return next;
1500
+ }
1501
+ /** Project a completed turn and its accepted Slack message to the plugin API. */
1502
+ async function markDispatchCompleted(id, resultMessageTs) {
1503
+ const previous = await getDispatchRecord(id);
1504
+ const next = await transitionDispatch(id, (record) => isTerminalDispatchStatus(record.status) ? record : {
1505
+ ...record,
1506
+ errorMessage: void 0,
1507
+ ...resultMessageTs ? { resultMessageTs } : void 0,
1508
+ status: "completed"
1509
+ });
1510
+ await recordEventAutomationExecution(previous, next, "completed");
1511
+ return next;
1512
+ }
1513
+ /** Project a terminal conversation turn failure to the plugin API. */
1514
+ async function markDispatchFailed(id, errorMessage, resultMessageTs) {
1515
+ const previous = await getDispatchRecord(id);
1516
+ const next = await transitionDispatch(id, (record) => isTerminalDispatchStatus(record.status) ? record : {
1517
+ ...record,
1518
+ errorMessage,
1519
+ ...resultMessageTs ? { resultMessageTs } : void 0,
1520
+ status: "failed"
1521
+ });
1522
+ await recordEventAutomationExecution(previous, next, "failed");
1523
+ return next;
1524
+ }
1525
+ /** Remove a dispatch after its durable mailbox append has succeeded. */
1526
+ async function confirmDispatchMailboxAppend(id) {
1527
+ const state = getStateAdapter();
1528
+ await state.connect();
1529
+ await updatePendingDispatchMailboxAppendIndex(state, (ids) => ids.filter((candidate) => candidate !== id));
1530
+ }
1531
+ /** List dispatches whose durable mailbox append may not have completed. */
1532
+ async function listPendingDispatchMailboxAppends() {
1533
+ const state = getStateAdapter();
1534
+ await state.connect();
1535
+ const stored = await state.get(pendingDispatchMailboxAppendIndexKey());
1536
+ return stored === void 0 || stored === null ? [] : [...new Set(pendingDispatchMailboxAppendIndexSchema.parse(stored))];
1537
+ }
1538
+ /** Load the dispatch under lock before its mailbox append becomes visible. */
1539
+ async function claimDispatchMailboxAppend(id) {
1540
+ return await withDispatchLock(id, async (state) => {
1541
+ return parseDispatchRecord(await state.get(getDispatchStorageKey(id)));
1542
+ });
1543
+ }
1544
+ /** Return a plugin-scoped dispatch projection without exposing raw runtime state. */
1545
+ async function getPluginDispatchProjection(args) {
1546
+ const record = await getDispatchRecord(args.id);
1547
+ if (!record || record.plugin !== args.plugin) return;
1548
+ return toDispatchProjection(record);
1549
+ }
1550
+ // src/chat/services/turn-session-routing.ts
1551
+ async function loadConversationChain(conversationId, conversationStore) {
1552
+ const chain = [];
1553
+ const seen = /* @__PURE__ */ new Set();
1554
+ let cursor = conversationId;
1555
+ while (cursor && !seen.has(cursor)) {
1556
+ seen.add(cursor);
1557
+ const conversation = await conversationStore.get({ conversationId: cursor });
1558
+ if (!conversation) break;
1559
+ chain.push(conversation);
1560
+ cursor = conversation.parentConversationId;
1561
+ }
1562
+ return chain;
1563
+ }
1564
+ /**
1565
+ * Normalize a stored Slack session source against the conversation destination.
1566
+ *
1567
+ * Does not create channel or thread values from the conversation id.
1568
+ */
1569
+ function slackSourceForDestination(args) {
1570
+ if (args.source?.kind !== "slack" || args.source.channelId !== args.destination.channelId) return;
1571
+ const threadTs = args.source.threadTs?.trim();
1572
+ return {
1573
+ kind: "slack",
1574
+ visibility: args.source.visibility ?? "public",
1575
+ teamId: args.source.teamId?.trim() || args.destination.teamId,
1576
+ channelId: args.source.channelId,
1577
+ ...threadTs ? { threadTs } : void 0
1578
+ };
1579
+ }
1580
+ /**
1581
+ * Resolve the Conversation Location, Destination, and session Source.
1582
+ *
1583
+ * Reads parent Conversations when this Conversation has no Destination.
1584
+ */
1585
+ async function resolveConversationRouting(args) {
1586
+ const conversationStore = args.conversationStore ?? (await import("./viewer-C6Y1aXjZ.js").then((n) => n.v)).getConversationStore();
1587
+ const chain = await loadConversationChain(args.conversationId, conversationStore);
1588
+ let destination;
1589
+ let source;
1590
+ let location;
1591
+ for (const conversation of chain) {
1592
+ destination ??= conversation.destination;
1593
+ source ??= conversation.sessionSource;
1594
+ location ??= conversation.location;
1595
+ if (destination && source) break;
1596
+ }
1597
+ if (!destination) return;
1598
+ if (destination.platform === "slack") {
1599
+ const slackSource = slackSourceForDestination({
1600
+ destination,
1601
+ source
1602
+ });
1603
+ return {
1604
+ destination,
1605
+ ...location ? { location } : void 0,
1606
+ ...slackSource ? { source: slackSource } : void 0
1607
+ };
1608
+ }
1609
+ return {
1610
+ destination,
1611
+ ...location ? { location } : void 0,
1612
+ ...source ? { source } : void 0
1613
+ };
1614
+ }
1615
+ /** Require the Conversation Destination and Source from SQL. */
1616
+ async function resolveTurnSessionRouting(args) {
1617
+ const routing = await resolveConversationRouting(args);
1618
+ if (!routing?.destination || !routing.source) throw new Error(`Conversation ${args.conversationId} is missing durable routing metadata`);
1619
+ return {
1620
+ destination: routing.destination,
1621
+ ...routing.location ? { location: routing.location } : void 0,
1622
+ source: routing.source
1623
+ };
1624
+ }
1625
+ export { saveEventAutomation as $, readAutomationRuns as A, readScheduledAutomation as B, readAutomationExecutionByConversationId as C, requireEventIdentifier as Ct, readAutomationExecutionStatusHours as D, readAutomationExecutionStatusDays as E, conversationRangeColumns as F, blockEventAutomation as G, saveScheduledAutomationInLock as H, createScheduledAutomation as I, deleteEventAutomation as J, collectEventAutomationMatchKeys as K, deleteScheduledAutomation as L, recordAutomationExecution as M, conversationActiveDaysColumn as N, readAutomationExecutionSummaries as O, conversationAggregateColumns as P, parseEventAutomationRow as Q, listScheduledAutomationsForTeam as R, existingConversationId as S, registeredResourceTypeSchema as St, readAutomationExecutionHours as T, scheduledRunId as U, saveScheduledAutomation as V, withScheduledAutomationLock as W, getEventAutomation as X, findMatchingEventAutomations as Y, listDeletedEventAutomationsCreatedBy as Z, markDispatchCompleted as _, eventWatchUnavailable as _t, confirmDispatchMailboxAppend as a, listAutomationVersions as at, notifyAutomationBlocked as b, registeredEventTriggerProblems as bt, getDispatchIdForConversation as c, AutomationConflictError as ct, getDispatchTurnId as d, CORE_EVENT_NAMESPACE as dt, setEventAutomationStatus as et, getPluginDispatchProjection as f, automationEventCatalog as ft, markDispatchBlocked as g, eventNamespaceSchema as gt, markDispatchAwaitingResume as h, eventGuidance as ht, claimDispatchMailboxAppend as i, automationVersionOf as it, readAutomationTitlesByConversationId as j, readAutomationExecutions as k, getDispatchInputMessageId as l, automationRevision as lt, listPendingDispatchMailboxAppends as m, eventCatalogEntry as mt, resolveTurnSessionRouting as n, eventAutomationRunLimitReason as nt, createOrGetDispatch as o, readAutomationVersion as ot, isTerminalDispatchStatus as p, describeRegisteredEventTrigger as pt, createEventAutomation as q, buildDispatchId as r, automationDefinition as rt, getDispatchConversationId as s, sameDefinitionValue as st, resolveConversationRouting as t, admitEventAutomationRun as tt, getDispatchRecord as u, requireAutomationRevision as ut, markDispatchFailed as v, hasEventCatalogEntries as vt, readAutomationExecutionDays as w, requireValidEventTrigger as wt, emptyAutomationRunWindows as x, registeredEventTypeSchema as xt, markDispatchRunning as y, registeredEventMatchSchema as yt, parseScheduledAutomationRow as z };