@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.
- package/dist/{adapter-BDYDi1F9.js → adapter-umap1cth.js} +1 -1
- package/dist/{admin-Dj7EFycG.js → admin-0o6E_3lo.js} +1 -1
- package/dist/{agent-runner-Cl6Usx2Y.js → agent-runner-D7Qz-lGO.js} +517 -298
- package/dist/api/schema.d.ts +1 -1
- package/dist/api/schema.js +4 -4
- package/dist/api.d.ts +1 -1
- package/dist/api.js +56 -573
- package/dist/{app-DjZblE2J.d.ts → app-BcyhJq8x.d.ts} +4 -9
- package/dist/app.d.ts +1 -1
- package/dist/app.js +673 -127
- package/dist/{assistant-reply-DN2w6JL_.js → assistant-reply-wZ7i4OFa.js} +1 -1
- package/dist/{automation-CV7g5h3A.js → automation-yffXcZt9.js} +1 -1
- package/dist/{briefs-PysAqnjV.js → briefs-CgMnlpIp.js} +4 -4
- package/dist/{catalog-runtime-BNeCWIaF.js → catalog-runtime-TygDsPOU.js} +1 -1
- package/dist/cli/chat.js +15 -15
- package/dist/cli/check.js +3 -3
- package/dist/cli/init.js +1 -1
- package/dist/cli/main.js +3 -3
- package/dist/cli/plugins.js +4 -4
- package/dist/cli/snapshot-create.js +4 -4
- package/dist/cli/upgrade.d.ts +1 -1
- package/dist/cli/upgrade.js +3 -3
- package/dist/{client-FRphcDWW.js → client-DhqbV0Fe.js} +63 -5
- package/dist/{reporting-schema-CZREhYc-.js → common-Eu6sx09c.js} +4 -1
- package/dist/{config-BUP9wjiR.js → config-C6FTd7PX.js} +6 -14
- package/dist/{connections-CesHau9u.js → connections-Dq72KajR.js} +11 -11
- package/dist/{conversation-BtQzLoK8.js → conversation-T4oabbBi.js} +10 -0
- package/dist/{conversation-privacy-DiGvA9a3.js → conversation-privacy-BeRgrsph.js} +1 -1
- package/dist/{credential-sync-D2odJC30.js → credential-sync-BmCbHAp4.js} +15 -16
- package/dist/{db-BPLmOwXf.d.ts → db-DOzBGLn5.d.ts} +1 -1
- package/dist/{executor-DGDpyegI.js → executor-BafgvQcc.js} +2 -2
- package/dist/instrumentation.js +114 -3
- package/dist/{job-queue-CjB97sii.js → job-queue-C4lphW9Y.js} +3 -3
- package/dist/{logging-CjkDr-jP.js → logging-CNs1e04n.js} +6 -1
- package/dist/{message-D9y8PKA1.js → message-CETC5lsl.js} +9 -9
- package/dist/nitro.d.ts +1 -1
- package/dist/nitro.js +3 -3
- package/dist/{oauth-callback-server-02Pej6hj.js → oauth-callback-server-DcQZsKMm.js} +2 -2
- package/dist/{credentialed-CgTulRDH.js → projection-BOpi891B.js} +4576 -49
- package/dist/{prompt-vfceH0c-.js → prompt-B1ySjUyf.js} +1 -1
- package/dist/{registry-7XgwNgQe.js → registry-B-yUrv3h.js} +2 -2
- package/dist/{resolve-CuuxNfWG.js → resolve-DqVtd4Q9.js} +3 -3
- package/dist/{result-BRhb6iN1.js → result-DQwTiLTw.js} +9 -9
- package/dist/{runner-DaWCofuS.js → runner-Dco5qMq6.js} +11 -11
- package/dist/{sandbox-egress-signals-Bn6FA1UC.js → sandbox-egress-signals-DFc_9Mcv.js} +1 -1
- package/dist/{schema-D5VLF4NT.js → schema-5xHDGlBW.js} +9 -7
- package/dist/{schema-CNjAzYOw.d.ts → schema-9OGdsaxF.d.ts} +13 -0
- package/dist/{service-account-D9vrsi8d.js → service-account-CWfk9Cs-.js} +3 -3
- package/dist/{service-account-H45LIAG2.js → service-account-smdPFWNy.js} +3 -3
- package/dist/{session-stop-HKl0ECX0.js → session-stop-DaT_7tYa.js} +11 -11
- package/dist/{skills-CsiKu3-g.js → skills-BbtJSF2-.js} +2 -2
- package/dist/{spawn-DqjBxqWh.js → spawn-Ceikdjkp.js} +2 -2
- package/dist/{list-JZUP1yog.js → stop-dKUc-x2K.js} +996 -432
- package/dist/{store-DyVKH0QB.js → store-C1xuZcLd.js} +4 -4
- package/dist/{store-CPF2kzbu.js → store-DaxUuOmD.js} +3 -3
- package/dist/{task-queue-BbQXCcNX.js → task-queue-ymAh_-P1.js} +4 -4
- package/dist/{task-runner-ChzW5E7R.js → task-runner-vlH6pEm3.js} +6 -6
- package/dist/{thread-state-Ds8JTsX6.js → thread-state-BkUpE9Ae.js} +4 -5
- package/dist/{turn-failure-response-DCMvjsA0.js → turn-failure-response-DIsD7ip_.js} +7 -8
- package/dist/{turn-lifecycle-DM_77q33.js → turn-lifecycle-BdPKdPS8.js} +1 -1
- package/dist/turn-session-routing-BdCTDq9m.js +1625 -0
- package/dist/{validation-DcGelbBi.js → validation-BQjCfh84.js} +1 -1
- package/dist/{version-C48azPKq.js → version-BpiFGZZW.js} +1 -1
- package/dist/version.js +1 -1
- package/dist/{viewer-BCRuO8Wg.js → viewer-C6Y1aXjZ.js} +27 -15
- package/dist/{work-zxkmbJAp.js → work-ChPh_YEh.js} +10 -38
- package/dist/{worker-CNNxHF1V.js → worker-Be_sK_ou.js} +6 -6
- package/package.json +4 -4
- package/dist/turn-session-routing-BV_gX7n3.js +0 -6072
- 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 };
|