@sentry/junior 0.240.0 → 0.242.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/{admin-D6fRPRgC.js → admin-C54kvHVC.js} +1 -1
- package/dist/{agent-hooks-JFbgedMZ.js → agent-hooks-BVttjZvb.js} +1952 -1952
- package/dist/{agent-runner-0IjJxsL1.js → agent-runner-BTgnxJ7g.js} +336 -1125
- package/dist/api/schema.d.ts +9 -0
- package/dist/api/schema.js +2 -2
- package/dist/api.js +21 -21
- package/dist/app.js +104 -31
- package/dist/{automation-B5_c8hEb.js → automation-gznOMaQ2.js} +11 -2
- package/dist/{briefs-BGWTp-nz.js → briefs-CKUsv9F1.js} +2 -2
- package/dist/{catalog-runtime-DdigNl7s.js → catalog-runtime-C2YkfMZz.js} +1 -1
- package/dist/cli/chat.js +12 -12
- package/dist/cli/check.js +3 -3
- package/dist/cli/main.js +2 -2
- package/dist/cli/plugins.js +4 -4
- package/dist/cli/snapshot-create.js +3 -3
- package/dist/cli/upgrade.js +3 -3
- package/dist/{client-Cn73E7br.js → client-D51G-Jdg.js} +2 -2
- package/dist/{config-B0S86yw9.js → config-C6Uu3Re9.js} +2 -2
- package/dist/{conversation-privacy-BKlX7D_R.js → conversation-privacy-CzL47nXs.js} +1 -1
- package/dist/{credential-sync-DeNQm7st.js → credential-sync-DvDaE-Uj.js} +14 -14
- package/dist/{executor-BQ-A5nov.js → executor-CGUukwus.js} +4 -2
- package/dist/{images-C7OKd6uy.js → images-DSxZt5vk.js} +2 -2
- package/dist/instrumentation.js +2 -2
- package/dist/{job-queue-DgXLqoeB.js → job-queue-hLylM9cy.js} +1 -1
- package/dist/{logging-BSNF167N.js → logging-BdZ_mK3q.js} +1 -1
- package/dist/nitro.js +3 -3
- package/dist/{oauth-authorization-BM7g61od.js → oauth-authorization-BokpeHv-.js} +1 -1
- package/dist/{oauth-callback-server-Dw-BwI6x.js → oauth-callback-server-C3BOSmT_.js} +2 -2
- package/dist/{pending-auth-Chn_EA1_.js → pending-auth-DOzL4D24.js} +2 -2
- package/dist/{profile-3t9H_IwL.js → profile-2gPzzMib.js} +4 -4
- package/dist/{registry-DLZlAhdP.js → registry-XJkXDNK2.js} +1 -1
- package/dist/{resolve-9_cIPQsq.js → resolve-DhcdtDt6.js} +2 -2
- package/dist/{runner-C_eUbFyp.js → runner-CGyrnIPm.js} +9 -9
- package/dist/{sandbox-egress-signals-C1ycxTNA.js → sandbox-egress-signals-D6F3OZJP.js} +1 -1
- package/dist/{schema-C4XwHTY4.js → schema-Cabm4jcj.js} +1 -1
- package/dist/{skills-Dqgjr_Tl.js → skills-C6PqcBPU.js} +2 -2
- package/dist/{spawn-BfEr3t7b.js → spawn-DL9x3T1o.js} +2 -2
- package/dist/{task-queue-CuFZDOXn.js → task-queue-vZnBGu9B.js} +3 -3
- package/dist/{task-runner-c7gwV8dj.js → task-runner-C-c0t5F5.js} +54 -12
- package/dist/{checkpoint-C1ZnSz7r.js → tasks-EqCtmUJ2.js} +1056 -14
- package/dist/{thread-state-exTu-G1w.js → thread-state-B8IKs_J8.js} +6 -6
- package/dist/{turn-failure-response-DFp99ziF.js → turn-failure-response-BS7OO1BE.js} +9 -8
- package/dist/{turn-lifecycle-C4TjuOzG.js → turn-lifecycle-Cxrt8qMD.js} +1 -1
- package/dist/{turn-session-routing-Vpg8jUD1.js → turn-session-routing-BlGt2AUb.js} +1 -1
- package/dist/{validation-D2lULEG9.js → validation-C8hci6pj.js} +1 -1
- package/dist/{version-BjBMCc2o.js → version-CjnHQ8aY.js} +1 -1
- package/dist/version.js +1 -1
- package/dist/{viewer-XE3nFqKM.js → viewer-BOgEy5zC.js} +3 -3
- package/dist/{web-authorization-8E8IrWOj.js → web-authorization-DSMV5tMK.js} +6 -6
- package/dist/{web-input-DF4GGJrf.js → web-input-TD5P9jdo.js} +135 -129
- package/dist/{work-Dt_JjfbX.js → work-D39OAwP1.js} +7 -7
- package/dist/{worker-BrbnLmD3.js → worker-CWiLgCWH.js} +5 -5
- package/migrations/0049_event_automation_status_reason.sql +1 -0
- package/migrations/meta/0049_snapshot.json +3581 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +5 -5
|
@@ -1,17 +1,21 @@
|
|
|
1
|
-
import { d as unwrapCurrentInstruction, f as escapeXml, i as completeText, o as resolveGatewayModel } from "./client-
|
|
2
|
-
import { d as logException, o as getActiveTraceId, p as logWarn, t as buildTurnFailureResponse, v as setSpanAttributes } from "./logging-
|
|
3
|
-
import { n as
|
|
4
|
-
import {
|
|
5
|
-
import { c as
|
|
1
|
+
import { d as unwrapCurrentInstruction, f as escapeXml, i as completeText, o as resolveGatewayModel } from "./client-D51G-Jdg.js";
|
|
2
|
+
import { d as logException, o as getActiveTraceId, p as logWarn, s as getLogContextAttributes, t as buildTurnFailureResponse, v as setSpanAttributes } from "./logging-BdZ_mK3q.js";
|
|
3
|
+
import { n as captureMessage } from "./sentry-CgWZM_kV.js";
|
|
4
|
+
import { n as botConfig, v as normalizeSlackEmojiName } from "./config-C6Uu3Re9.js";
|
|
5
|
+
import { c as toStoredSlackActor, f as parseSlackUserId, g as parseSlackMessageTs } from "./actor-BTtLaeWy.js";
|
|
6
|
+
import { c as getConversationEventStore, f as getSqlExecutor, m as withConversationEventLock, p as createSqlConversationEventStore, u as getConversationStore, v as startLocationThread, x as addAgentTurnUsage, y as bindProviderConversation } from "./viewer-BOgEy5zC.js";
|
|
7
|
+
import { a as juniorSchedulerRuns, o as juniorSchedulerTasks, s as juniorAutomationVersions, u as juniorEventAutomations } from "./executor-CGUukwus.js";
|
|
6
8
|
import { d as instructionActors, f as instructionProvenanceFor, n as agentHistoryItemSchema, u as contextProvenance } from "./history-BLTUgFYO.js";
|
|
7
|
-
import { p as credentialSubjectSchema } from "./registry-
|
|
8
|
-
import { Jt as agentsInstructionsUpdatedEvent, Xt as authenticationUnlinkedEvent, Yt as authenticationLinkedEvent, qt as JUNIOR_NATIVE_EVENT_NAMESPACE } from "./agent-hooks-
|
|
9
|
+
import { p as credentialSubjectSchema } from "./registry-XJkXDNK2.js";
|
|
10
|
+
import { Ft as SlackActionError, Ht as normalizeSlackConversationId, Jt as agentsInstructionsUpdatedEvent, Wt as withSlackRetries, Xt as authenticationUnlinkedEvent, Yt as authenticationLinkedEvent, n as applyPluginFormatMarkdown, qt as JUNIOR_NATIVE_EVENT_NAMESPACE, zt as getSlackClient } from "./agent-hooks-BVttjZvb.js";
|
|
9
11
|
import { H as messageCardRef } from "./conversation-DwD-vquD.js";
|
|
10
|
-
import { D as fenceLock, O as withLock, T as MUTATION_LOCK_TTL_MS, k as sleep, w as getStateAdapter } from "./profile-
|
|
12
|
+
import { D as fenceLock, O as withLock, T as MUTATION_LOCK_TTL_MS, k as sleep, w as getStateAdapter } from "./profile-2gPzzMib.js";
|
|
13
|
+
import { A as eventAutomationSchema, D as scheduledRunSchema, E as scheduledAutomationSchema, S as automationVersionSchema } from "./automation-gznOMaQ2.js";
|
|
11
14
|
import { isDeepStrictEqual } from "node:util";
|
|
12
15
|
import { z } from "zod";
|
|
13
16
|
import { createHash } from "node:crypto";
|
|
14
|
-
import {
|
|
17
|
+
import { and, asc, desc, eq, inArray, ne, notInArray, or, sql } from "drizzle-orm";
|
|
18
|
+
import { actorSchema, eventMatches, slackDestinationSchema, sourceSchema, taskOutcomeSchema } from "@sentry/junior-plugin-api";
|
|
15
19
|
//#region src/chat/pi/messages.ts
|
|
16
20
|
/** Permissive schema for durable Pi SDK messages whose content shape may evolve. */
|
|
17
21
|
const piMessageSchema = z.object({ role: z.string() }).passthrough().transform((value) => value);
|
|
@@ -711,13 +715,13 @@ function toStoredConversationMessage(message) {
|
|
|
711
715
|
createdAtMs: message.createdAtMs
|
|
712
716
|
};
|
|
713
717
|
}
|
|
714
|
-
function canonicalJson(value) {
|
|
715
|
-
if (Array.isArray(value)) return value.map(canonicalJson);
|
|
718
|
+
function canonicalJson$1(value) {
|
|
719
|
+
if (Array.isArray(value)) return value.map(canonicalJson$1);
|
|
716
720
|
if (!value || typeof value !== "object") return value;
|
|
717
|
-
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== void 0).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalJson(item)]));
|
|
721
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== void 0).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalJson$1(item)]));
|
|
718
722
|
}
|
|
719
723
|
function messageStateKey(message) {
|
|
720
|
-
return createHash("sha256").update(JSON.stringify(canonicalJson(message))).digest("hex").slice(0, 32);
|
|
724
|
+
return createHash("sha256").update(JSON.stringify(canonicalJson$1(message))).digest("hex").slice(0, 32);
|
|
721
725
|
}
|
|
722
726
|
/** Hydrate the current message working set from canonical conversation events. */
|
|
723
727
|
async function hydrateConversationMessages(args) {
|
|
@@ -1241,6 +1245,15 @@ async function recordGuardianActionReviewed(args) {
|
|
|
1241
1245
|
}]);
|
|
1242
1246
|
}
|
|
1243
1247
|
//#endregion
|
|
1248
|
+
//#region src/chat/tools/execution/tool-input-error.ts
|
|
1249
|
+
/** Thrown when a tool fails due to invalid model/user input, not a system error. */
|
|
1250
|
+
var ToolInputError = class extends Error {
|
|
1251
|
+
constructor(message, options) {
|
|
1252
|
+
super(message, options);
|
|
1253
|
+
this.name = "ToolInputError";
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
//#endregion
|
|
1244
1257
|
//#region src/chat/turn-context-tag.ts
|
|
1245
1258
|
/**
|
|
1246
1259
|
* Canonical tag name for the runtime turn context block injected into Pi
|
|
@@ -2159,4 +2172,1033 @@ async function saveDone(args) {
|
|
|
2159
2172
|
});
|
|
2160
2173
|
}
|
|
2161
2174
|
//#endregion
|
|
2162
|
-
|
|
2175
|
+
//#region src/chat/slack/post-warning.ts
|
|
2176
|
+
const codes = /* @__PURE__ */ new Set([
|
|
2177
|
+
"invalid_metadata_format",
|
|
2178
|
+
"invalid_metadata_schema",
|
|
2179
|
+
"metadata_too_large",
|
|
2180
|
+
"invalid_arguments",
|
|
2181
|
+
"invalid_blocks",
|
|
2182
|
+
"missing_scope",
|
|
2183
|
+
"missing_charset",
|
|
2184
|
+
"superfluous_charset",
|
|
2185
|
+
"message_truncated"
|
|
2186
|
+
]);
|
|
2187
|
+
const pathFields = /* @__PURE__ */ new Set([
|
|
2188
|
+
"metadata",
|
|
2189
|
+
"event_type",
|
|
2190
|
+
"event_payload",
|
|
2191
|
+
"entities",
|
|
2192
|
+
"entity_type",
|
|
2193
|
+
"entity_payload",
|
|
2194
|
+
"external_ref",
|
|
2195
|
+
"id",
|
|
2196
|
+
"type",
|
|
2197
|
+
"url",
|
|
2198
|
+
"attributes",
|
|
2199
|
+
"title",
|
|
2200
|
+
"text",
|
|
2201
|
+
"display_id",
|
|
2202
|
+
"display_type",
|
|
2203
|
+
"product_name",
|
|
2204
|
+
"fields",
|
|
2205
|
+
"status",
|
|
2206
|
+
"custom_fields",
|
|
2207
|
+
"key",
|
|
2208
|
+
"label",
|
|
2209
|
+
"value",
|
|
2210
|
+
"long",
|
|
2211
|
+
"format",
|
|
2212
|
+
"link",
|
|
2213
|
+
"display_order",
|
|
2214
|
+
"app_unfurl_url",
|
|
2215
|
+
"app_id",
|
|
2216
|
+
"created_by",
|
|
2217
|
+
"created_at",
|
|
2218
|
+
"updated_by",
|
|
2219
|
+
"updated_at",
|
|
2220
|
+
"full_size_preview",
|
|
2221
|
+
"preview_url",
|
|
2222
|
+
"mime_type"
|
|
2223
|
+
]);
|
|
2224
|
+
const diagnosticWords = new Set(`WARN ERROR Message message metadata was incorrectly formatted The the will be
|
|
2225
|
+
ignored as a result For for event entity refer to following errors error
|
|
2226
|
+
missing required field fields property properties additional unexpected unknown
|
|
2227
|
+
unsupported invalid expected received actual type types must should match matches
|
|
2228
|
+
matching does not is are has have of at in on and or an only allowed allow
|
|
2229
|
+
value values string number integer boolean object array null minimum maximum
|
|
2230
|
+
length min max items item empty nonempty too long short large small size limit
|
|
2231
|
+
exceeded exceeds less greater than equal valid format schema validation failed
|
|
2232
|
+
failure cannot contain contains found one any all none enum pattern definition
|
|
2233
|
+
defined constraint constraints satisfy satisfies with without provided specified
|
|
2234
|
+
unrecognized unrecognised recognized recognised extraneous disallowed duplicate
|
|
2235
|
+
unique malformed nested root exactly needs need requires requirement supported
|
|
2236
|
+
json-pointer input different schema_uri schema_id schemas additionalProperties
|
|
2237
|
+
minLength maxLength minItems maxItems anyOf oneOf allOf`.split(/\s+/));
|
|
2238
|
+
function summarizeDiagnostic(value) {
|
|
2239
|
+
return value.slice(0, 2e3).replace(/\b(?:https?:\/\/|www\.)[^\s"'`<>]+/gi, "[value]").replace(/\/[^\s"'`[\],;()]*|[\p{L}\p{N}\p{M}_]+(?:[-./:@=+%~][\p{L}\p{N}\p{M}_]+)*|[^\s\w[\]{}():,;."'`/*=<>!?+-]/gu, (token) => {
|
|
2240
|
+
if (token.startsWith("/")) return token.split("/").slice(0, 17).map((part, index) => index === 0 ? "" : pathFields.has(part) ? part : "*").join("/");
|
|
2241
|
+
return codes.has(token) || pathFields.has(token) || diagnosticWords.has(token) ? token : "[value]";
|
|
2242
|
+
}).replace(/\s+/g, " ").slice(0, 2e3);
|
|
2243
|
+
}
|
|
2244
|
+
/** Report Slack warnings once, on the request span, without retrying the post. */
|
|
2245
|
+
function captureSlackPostWarning(response, attributes) {
|
|
2246
|
+
const warnings = response.response_metadata?.warnings ?? [];
|
|
2247
|
+
const messages = response.response_metadata?.messages ?? [];
|
|
2248
|
+
const knownCodes = [...new Set(warnings.slice(0, 20).filter((code) => codes.has(code)))].sort();
|
|
2249
|
+
const diagnostics = {
|
|
2250
|
+
"app.slack.warning_count": warnings.length,
|
|
2251
|
+
"app.slack.response_message_count": messages.length,
|
|
2252
|
+
"app.slack.diagnostic_codes": knownCodes
|
|
2253
|
+
};
|
|
2254
|
+
setSpanAttributes(diagnostics);
|
|
2255
|
+
if (!warnings.length && !messages.length) return;
|
|
2256
|
+
captureMessage("Slack chat.postMessage returned warnings", {
|
|
2257
|
+
level: "warning",
|
|
2258
|
+
fingerprint: ["slack.chat.postMessage.warning", ...knownCodes.length ? knownCodes : ["unknown"]],
|
|
2259
|
+
tags: { "app.slack.method": "chat.postMessage" },
|
|
2260
|
+
extra: {
|
|
2261
|
+
...getLogContextAttributes(),
|
|
2262
|
+
...attributes,
|
|
2263
|
+
...diagnostics,
|
|
2264
|
+
"app.slack.response_diagnostics": {
|
|
2265
|
+
warnings: warnings.slice(0, 20).map(summarizeDiagnostic),
|
|
2266
|
+
messages: messages.slice(0, 20).map(summarizeDiagnostic)
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
//#endregion
|
|
2272
|
+
//#region src/chat/slack/work-object.ts
|
|
2273
|
+
const url = z.url({ protocol: /^https?$/ });
|
|
2274
|
+
const textField = z.strictObject({
|
|
2275
|
+
type: z.literal("string"),
|
|
2276
|
+
key: z.string().min(1),
|
|
2277
|
+
label: z.string().min(1),
|
|
2278
|
+
value: z.string(),
|
|
2279
|
+
long: z.boolean().optional(),
|
|
2280
|
+
format: z.literal("markdown").optional(),
|
|
2281
|
+
link: url.optional()
|
|
2282
|
+
}).refine((field) => !field.format || !field.link, { message: "Slack string fields cannot combine format and link" });
|
|
2283
|
+
const customField = z.discriminatedUnion("type", [textField, z.strictObject({
|
|
2284
|
+
type: z.literal("slack#/types/timestamp"),
|
|
2285
|
+
key: z.string().min(1),
|
|
2286
|
+
label: z.string().min(1),
|
|
2287
|
+
value: z.number().int(),
|
|
2288
|
+
link: url.optional()
|
|
2289
|
+
})]);
|
|
2290
|
+
const payload = z.strictObject({
|
|
2291
|
+
attributes: z.strictObject({
|
|
2292
|
+
title: z.strictObject({ text: z.string().min(1) }),
|
|
2293
|
+
display_id: z.string().optional(),
|
|
2294
|
+
display_type: z.string().optional(),
|
|
2295
|
+
product_name: z.string().optional(),
|
|
2296
|
+
product_icon: z.strictObject({
|
|
2297
|
+
url,
|
|
2298
|
+
alt_text: z.string().min(1)
|
|
2299
|
+
}).optional()
|
|
2300
|
+
}),
|
|
2301
|
+
custom_fields: z.array(customField).optional(),
|
|
2302
|
+
display_order: z.array(z.string()).optional()
|
|
2303
|
+
});
|
|
2304
|
+
/** Limit IDs to Junior's safe subset; Slack silently rejects JSON punctuation. */
|
|
2305
|
+
const slackExternalRefIdSchema = z.string().min(1).regex(/^[A-Za-z0-9_-]+$/, "Unsupported Slack external reference ID");
|
|
2306
|
+
const entity = z.strictObject({
|
|
2307
|
+
external_ref: z.strictObject({
|
|
2308
|
+
id: slackExternalRefIdSchema,
|
|
2309
|
+
type: z.string().min(1).optional()
|
|
2310
|
+
}),
|
|
2311
|
+
url
|
|
2312
|
+
});
|
|
2313
|
+
/** Validate notification and detail metadata before Slack can ignore bad fields. */
|
|
2314
|
+
const slackEntitySchema = z.discriminatedUnion("entity_type", [entity.extend({
|
|
2315
|
+
entity_type: z.literal("slack#/entities/item"),
|
|
2316
|
+
entity_payload: payload.extend({ fields: z.never().optional() })
|
|
2317
|
+
}), entity.extend({
|
|
2318
|
+
entity_type: z.literal("slack#/entities/task"),
|
|
2319
|
+
entity_payload: payload.extend({ fields: z.strictObject({ status: z.strictObject({ value: z.string() }).optional() }).optional() })
|
|
2320
|
+
})]);
|
|
2321
|
+
//#endregion
|
|
2322
|
+
//#region src/chat/slack/outbound.ts
|
|
2323
|
+
const MAX_SLACK_MESSAGE_TEXT_CHARS = 4e4;
|
|
2324
|
+
function requireSlackConversationId(channelId, action) {
|
|
2325
|
+
const normalized = normalizeSlackConversationId(channelId);
|
|
2326
|
+
if (!normalized) throw new Error(`${action} requires a valid channel ID`);
|
|
2327
|
+
return normalized;
|
|
2328
|
+
}
|
|
2329
|
+
function requireSlackThreadTimestamp(threadTs, action) {
|
|
2330
|
+
const normalized = threadTs.trim();
|
|
2331
|
+
if (!normalized) throw new Error(`${action} requires a thread timestamp`);
|
|
2332
|
+
return normalized;
|
|
2333
|
+
}
|
|
2334
|
+
function requireSlackMessageTimestamp(timestamp, action) {
|
|
2335
|
+
const normalized = parseSlackMessageTs(timestamp);
|
|
2336
|
+
if (!normalized) throw new Error(`${action} requires a target message timestamp`);
|
|
2337
|
+
return normalized;
|
|
2338
|
+
}
|
|
2339
|
+
function requireSlackMessageText(text, action) {
|
|
2340
|
+
if (text.trim().length === 0) throw new Error(`${action} requires non-empty text`);
|
|
2341
|
+
if (text.length > MAX_SLACK_MESSAGE_TEXT_CHARS) throw new Error(`${action} text exceeds Slack's 40000 character truncation limit`);
|
|
2342
|
+
return text;
|
|
2343
|
+
}
|
|
2344
|
+
/** Resolve a Slack message permalink without making read-only workflows fail on lookup errors. */
|
|
2345
|
+
async function getSlackMessagePermalink(args) {
|
|
2346
|
+
try {
|
|
2347
|
+
return (await withSlackRetries(() => getSlackClient().chat.getPermalink({
|
|
2348
|
+
channel: args.channelId,
|
|
2349
|
+
message_ts: args.messageTs
|
|
2350
|
+
}), 3, {
|
|
2351
|
+
action: "chat.getPermalink",
|
|
2352
|
+
idempotent: true,
|
|
2353
|
+
spanAttributes: {
|
|
2354
|
+
"app.slack.channel_id": args.channelId,
|
|
2355
|
+
"app.slack.message_ts": args.messageTs
|
|
2356
|
+
}
|
|
2357
|
+
})).permalink;
|
|
2358
|
+
} catch {
|
|
2359
|
+
return;
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
/** Open the bot's direct message with one Slack user and return its channel ID. */
|
|
2363
|
+
async function openSlackDirectMessage(userId) {
|
|
2364
|
+
const channelId = (await withSlackRetries(() => getSlackClient().conversations.open({ users: userId }), 3, { action: "conversations.open" })).channel?.id;
|
|
2365
|
+
if (!channelId) throw new Error("Slack did not return a direct message channel.");
|
|
2366
|
+
return channelId;
|
|
2367
|
+
}
|
|
2368
|
+
/** Post Slack `mrkdwn` text to a conversation or thread via the shared outbound boundary. */
|
|
2369
|
+
async function postSlackMessage(input) {
|
|
2370
|
+
const channelId = requireSlackConversationId(input.channelId, "Slack message posting");
|
|
2371
|
+
const text = requireSlackMessageText(input.text, "Slack message posting");
|
|
2372
|
+
const entities = input.entities?.length ? slackEntitySchema.array().parse(input.entities) : void 0;
|
|
2373
|
+
const threadTs = input.threadTs ? requireSlackThreadTimestamp(input.threadTs, "Slack thread message posting") : void 0;
|
|
2374
|
+
const attributes = {
|
|
2375
|
+
"app.slack.channel_id": channelId,
|
|
2376
|
+
...threadTs ? { "app.slack.thread_ts": threadTs } : void 0,
|
|
2377
|
+
"app.slack.work_object.count": entities?.length ?? 0
|
|
2378
|
+
};
|
|
2379
|
+
const response = await withSlackRetries(async () => {
|
|
2380
|
+
const response = await getSlackClient().chat.postMessage({
|
|
2381
|
+
channel: channelId,
|
|
2382
|
+
text,
|
|
2383
|
+
unfurl_links: false,
|
|
2384
|
+
unfurl_media: false,
|
|
2385
|
+
...input.blocks?.length ? { blocks: input.blocks } : void 0,
|
|
2386
|
+
...entities ? { metadata: { entities } } : void 0,
|
|
2387
|
+
...threadTs ? { thread_ts: threadTs } : void 0
|
|
2388
|
+
});
|
|
2389
|
+
const messageId = parseSlackMessageTs(response.ts);
|
|
2390
|
+
setSpanAttributes({ "messaging.message.id": messageId });
|
|
2391
|
+
captureSlackPostWarning(response, {
|
|
2392
|
+
...attributes,
|
|
2393
|
+
"messaging.message.id": messageId
|
|
2394
|
+
});
|
|
2395
|
+
return response;
|
|
2396
|
+
}, 3, {
|
|
2397
|
+
action: "chat.postMessage",
|
|
2398
|
+
attributes
|
|
2399
|
+
});
|
|
2400
|
+
const messageTs = parseSlackMessageTs(response.ts);
|
|
2401
|
+
if (!messageTs) throw new Error("Slack message posted without ts");
|
|
2402
|
+
return {
|
|
2403
|
+
ts: messageTs,
|
|
2404
|
+
...input.includePermalink ? { permalink: await getSlackMessagePermalink({
|
|
2405
|
+
channelId,
|
|
2406
|
+
messageTs
|
|
2407
|
+
}) } : void 0
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
/**
|
|
2411
|
+
* Post an ephemeral Slack message. Delivery is best-effort on Slack's side, but
|
|
2412
|
+
* request validation and Web API behavior are centralized here.
|
|
2413
|
+
*/
|
|
2414
|
+
async function postSlackEphemeralMessage(input) {
|
|
2415
|
+
const channelId = requireSlackConversationId(input.channelId, "Slack ephemeral message posting");
|
|
2416
|
+
const userId = parseSlackUserId(input.userId);
|
|
2417
|
+
if (!userId) throw new Error("Slack ephemeral message posting requires a user ID");
|
|
2418
|
+
const text = requireSlackMessageText(input.text, "Slack ephemeral message posting");
|
|
2419
|
+
const threadTs = input.threadTs ? requireSlackThreadTimestamp(input.threadTs, "Slack ephemeral thread message posting") : void 0;
|
|
2420
|
+
return { messageTs: (await withSlackRetries(() => getSlackClient().chat.postEphemeral({
|
|
2421
|
+
channel: channelId,
|
|
2422
|
+
user: userId,
|
|
2423
|
+
text,
|
|
2424
|
+
...input.blocks?.length ? { blocks: input.blocks } : void 0,
|
|
2425
|
+
...threadTs ? { thread_ts: threadTs } : void 0
|
|
2426
|
+
}), 3, {
|
|
2427
|
+
action: "chat.postEphemeral",
|
|
2428
|
+
spanAttributes: {
|
|
2429
|
+
"app.slack.channel_id": channelId,
|
|
2430
|
+
"app.slack.user_id": userId,
|
|
2431
|
+
...threadTs ? { "app.slack.thread_ts": threadTs } : void 0
|
|
2432
|
+
}
|
|
2433
|
+
})).message_ts };
|
|
2434
|
+
}
|
|
2435
|
+
/** Upload files into a Slack conversation or thread via the shared outbound file boundary. */
|
|
2436
|
+
async function uploadFilesToConversation(input) {
|
|
2437
|
+
const channelId = requireSlackConversationId(input.channelId, "Slack file upload");
|
|
2438
|
+
const threadTs = input.threadTs ? requireSlackThreadTimestamp(input.threadTs, "Slack file upload") : void 0;
|
|
2439
|
+
if (input.files.length === 0) throw new Error("Slack file upload requires at least one file");
|
|
2440
|
+
const fileUploads = input.files.map((file) => {
|
|
2441
|
+
const filename = file.filename.trim();
|
|
2442
|
+
if (!filename) throw new Error("Slack file upload requires every file to have a filename");
|
|
2443
|
+
return {
|
|
2444
|
+
file: file.data,
|
|
2445
|
+
filename
|
|
2446
|
+
};
|
|
2447
|
+
});
|
|
2448
|
+
return { files: ((await withSlackRetries(() => getSlackClient().filesUploadV2({
|
|
2449
|
+
channel_id: channelId,
|
|
2450
|
+
...threadTs ? { thread_ts: threadTs } : void 0,
|
|
2451
|
+
file_uploads: fileUploads
|
|
2452
|
+
}), 3, {
|
|
2453
|
+
action: "filesUploadV2",
|
|
2454
|
+
spanAttributes: {
|
|
2455
|
+
"app.slack.channel_id": channelId,
|
|
2456
|
+
...threadTs ? { "app.slack.thread_ts": threadTs } : void 0
|
|
2457
|
+
}
|
|
2458
|
+
})).files ?? []).flatMap((completion) => (completion.files ?? []).map((file) => file.id ? { id: file.id } : {})) };
|
|
2459
|
+
}
|
|
2460
|
+
/** Add a reaction to a Slack message, treating `already_reacted` as idempotent success. */
|
|
2461
|
+
async function addReactionToMessage(input) {
|
|
2462
|
+
const channelId = requireSlackConversationId(input.channelId, "Slack reaction");
|
|
2463
|
+
const timestamp = requireSlackMessageTimestamp(input.timestamp, "Slack reaction");
|
|
2464
|
+
const emoji = normalizeSlackEmojiName(input.emoji);
|
|
2465
|
+
if (!emoji) throw new Error("Slack reaction requires a valid emoji alias name");
|
|
2466
|
+
try {
|
|
2467
|
+
await withSlackRetries(() => getSlackClient(input.timeoutMs === void 0 ? void 0 : { timeoutMs: input.timeoutMs }).reactions.add({
|
|
2468
|
+
channel: channelId,
|
|
2469
|
+
timestamp,
|
|
2470
|
+
name: emoji
|
|
2471
|
+
}), input.timeoutMs === void 0 ? 3 : 1, {
|
|
2472
|
+
action: "reactions.add",
|
|
2473
|
+
idempotent: true,
|
|
2474
|
+
spanAttributes: {
|
|
2475
|
+
"app.slack.channel_id": channelId,
|
|
2476
|
+
"app.slack.message_ts": timestamp,
|
|
2477
|
+
"app.slack.reaction": emoji
|
|
2478
|
+
}
|
|
2479
|
+
});
|
|
2480
|
+
} catch (error) {
|
|
2481
|
+
if (error instanceof SlackActionError && error.code === "already_reacted") return { ok: true };
|
|
2482
|
+
throw error;
|
|
2483
|
+
}
|
|
2484
|
+
return { ok: true };
|
|
2485
|
+
}
|
|
2486
|
+
/** Remove a reaction from a Slack message, treating `no_reaction` as idempotent success. */
|
|
2487
|
+
async function removeReactionFromMessage(input) {
|
|
2488
|
+
const channelId = requireSlackConversationId(input.channelId, "Slack reaction removal");
|
|
2489
|
+
const timestamp = requireSlackMessageTimestamp(input.timestamp, "Slack reaction removal");
|
|
2490
|
+
const emoji = normalizeSlackEmojiName(input.emoji);
|
|
2491
|
+
if (!emoji) throw new Error("Slack reaction removal requires a valid emoji alias name");
|
|
2492
|
+
try {
|
|
2493
|
+
await withSlackRetries(() => getSlackClient().reactions.remove({
|
|
2494
|
+
channel: channelId,
|
|
2495
|
+
timestamp,
|
|
2496
|
+
name: emoji
|
|
2497
|
+
}), 3, {
|
|
2498
|
+
action: "reactions.remove",
|
|
2499
|
+
idempotent: true,
|
|
2500
|
+
spanAttributes: {
|
|
2501
|
+
"app.slack.channel_id": channelId,
|
|
2502
|
+
"app.slack.message_ts": timestamp,
|
|
2503
|
+
"app.slack.reaction": emoji
|
|
2504
|
+
}
|
|
2505
|
+
});
|
|
2506
|
+
} catch (error) {
|
|
2507
|
+
if (error instanceof SlackActionError && error.code === "no_reaction") return { ok: true };
|
|
2508
|
+
throw error;
|
|
2509
|
+
}
|
|
2510
|
+
return { ok: true };
|
|
2511
|
+
}
|
|
2512
|
+
//#endregion
|
|
2513
|
+
//#region src/chat/slack/status-format.ts
|
|
2514
|
+
const SLACK_STATUS_MAX_LENGTH = 50;
|
|
2515
|
+
/** Truncate a status string to fit Slack's 50-char assistant status limit. */
|
|
2516
|
+
function truncateStatusText(text) {
|
|
2517
|
+
const trimmed = text.trim();
|
|
2518
|
+
if (!trimmed) return "";
|
|
2519
|
+
if (trimmed.length <= SLACK_STATUS_MAX_LENGTH) return trimmed;
|
|
2520
|
+
return `${trimmed.slice(0, 47).trimEnd()}...`;
|
|
2521
|
+
}
|
|
2522
|
+
//#endregion
|
|
2523
|
+
//#region src/chat/slack/mrkdwn.ts
|
|
2524
|
+
/** Escape dynamic text for Slack mrkdwn without changing intended formatting. */
|
|
2525
|
+
function escapeSlackMrkdwnText(text) {
|
|
2526
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
2527
|
+
}
|
|
2528
|
+
/** Escape a URL for Slack explicit link syntax while preserving query semantics. */
|
|
2529
|
+
function escapeSlackLinkUrl(url) {
|
|
2530
|
+
return url.replaceAll("&", "&").replaceAll("<", "%3C").replaceAll(">", "%3E");
|
|
2531
|
+
}
|
|
2532
|
+
/** Build a Slack explicit link from dynamic URL and label values. */
|
|
2533
|
+
function formatSlackLink(url, label) {
|
|
2534
|
+
return `<${escapeSlackLinkUrl(url)}|${escapeSlackMrkdwnText(label)}>`;
|
|
2535
|
+
}
|
|
2536
|
+
function readInlineCodeSpan(line, start) {
|
|
2537
|
+
if (line[start] !== "`") return;
|
|
2538
|
+
let n = 1;
|
|
2539
|
+
while (line[start + n] === "`") n++;
|
|
2540
|
+
const marker = "`".repeat(n);
|
|
2541
|
+
let search = start + n;
|
|
2542
|
+
while (search < line.length) {
|
|
2543
|
+
const close = line.indexOf(marker, search);
|
|
2544
|
+
if (close === -1) return;
|
|
2545
|
+
const after = close + n;
|
|
2546
|
+
if (line[after] !== "`") return {
|
|
2547
|
+
text: line.slice(start, after),
|
|
2548
|
+
end: after
|
|
2549
|
+
};
|
|
2550
|
+
search = after + 1;
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
function readExistingSlackAngleToken(line, start) {
|
|
2554
|
+
if (line[start] !== "<") return;
|
|
2555
|
+
const close = line.indexOf(">", start + 1);
|
|
2556
|
+
if (close === -1) return;
|
|
2557
|
+
const body = line.slice(start + 1, close);
|
|
2558
|
+
if (/^(?:https?:\/\/|@|#|!)/.test(body)) return {
|
|
2559
|
+
text: line.slice(start, close + 1),
|
|
2560
|
+
end: close + 1
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
function readMarkdownLink(line, start) {
|
|
2564
|
+
if (line[start] !== "[") return;
|
|
2565
|
+
const labelEnd = line.indexOf("](", start + 1);
|
|
2566
|
+
if (labelEnd === -1) return;
|
|
2567
|
+
const destStart = labelEnd + 2;
|
|
2568
|
+
if (!line.startsWith("http://", destStart) && !line.startsWith("https://", destStart)) return;
|
|
2569
|
+
const closeParens = line.indexOf(")", destStart);
|
|
2570
|
+
if (closeParens === -1) return;
|
|
2571
|
+
return {
|
|
2572
|
+
text: line.slice(start, closeParens + 1),
|
|
2573
|
+
end: closeParens + 1
|
|
2574
|
+
};
|
|
2575
|
+
}
|
|
2576
|
+
function hasUnmatchedClosingParen(text) {
|
|
2577
|
+
let balance = 0;
|
|
2578
|
+
for (const ch of text) if (ch === "(") balance++;
|
|
2579
|
+
else if (ch === ")") balance--;
|
|
2580
|
+
return balance < 0;
|
|
2581
|
+
}
|
|
2582
|
+
function readBareUrl(line, start) {
|
|
2583
|
+
let end = start;
|
|
2584
|
+
while (end < line.length) {
|
|
2585
|
+
const ch = line[end];
|
|
2586
|
+
if (/\s/.test(ch) || ch === "<" || ch === ">" || ch === "\"" || ch === "`" || ch === "|" || ch === "*") break;
|
|
2587
|
+
end++;
|
|
2588
|
+
}
|
|
2589
|
+
if (end === start) return;
|
|
2590
|
+
let raw = line.slice(start, end);
|
|
2591
|
+
let suffix = "";
|
|
2592
|
+
const peel = () => {
|
|
2593
|
+
suffix = raw.slice(-1) + suffix;
|
|
2594
|
+
raw = raw.slice(0, -1);
|
|
2595
|
+
};
|
|
2596
|
+
const shouldPeel = () => raw.endsWith("_") || /[.,!?;:]$/.test(raw) || raw.endsWith(")") && hasUnmatchedClosingParen(raw);
|
|
2597
|
+
while (raw.length > 0 && shouldPeel()) peel();
|
|
2598
|
+
if (!/^https?:\/\/.+/.test(raw)) return;
|
|
2599
|
+
return {
|
|
2600
|
+
url: raw,
|
|
2601
|
+
suffix,
|
|
2602
|
+
end
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
/** Wrap bare http(s) URLs on a single line as Slack explicit `<url>` links. */
|
|
2606
|
+
function wrapBareUrlsOnLine(line) {
|
|
2607
|
+
let result = "";
|
|
2608
|
+
let i = 0;
|
|
2609
|
+
while (i < line.length) {
|
|
2610
|
+
const codeSpan = readInlineCodeSpan(line, i);
|
|
2611
|
+
if (codeSpan) {
|
|
2612
|
+
result += codeSpan.text;
|
|
2613
|
+
i = codeSpan.end;
|
|
2614
|
+
continue;
|
|
2615
|
+
}
|
|
2616
|
+
const angleToken = readExistingSlackAngleToken(line, i);
|
|
2617
|
+
if (angleToken) {
|
|
2618
|
+
result += angleToken.text;
|
|
2619
|
+
i = angleToken.end;
|
|
2620
|
+
continue;
|
|
2621
|
+
}
|
|
2622
|
+
const mdLink = readMarkdownLink(line, i);
|
|
2623
|
+
if (mdLink) {
|
|
2624
|
+
result += mdLink.text;
|
|
2625
|
+
i = mdLink.end;
|
|
2626
|
+
continue;
|
|
2627
|
+
}
|
|
2628
|
+
if (line.startsWith("https://", i) || line.startsWith("http://", i)) {
|
|
2629
|
+
const parsed = readBareUrl(line, i);
|
|
2630
|
+
if (parsed) {
|
|
2631
|
+
result += `<${parsed.url}>${parsed.suffix}`;
|
|
2632
|
+
i = parsed.end;
|
|
2633
|
+
continue;
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
result += line[i];
|
|
2637
|
+
i++;
|
|
2638
|
+
}
|
|
2639
|
+
return result;
|
|
2640
|
+
}
|
|
2641
|
+
/**
|
|
2642
|
+
* Pre-wrap bare http(s) URLs outside fenced code blocks as Slack explicit
|
|
2643
|
+
* links, preventing Slack's auto-linker from consuming adjacent formatting
|
|
2644
|
+
* markers into the URL.
|
|
2645
|
+
*
|
|
2646
|
+
* Uses the same fence-toggle rule as `ensureBlockSpacing` so both passes
|
|
2647
|
+
* agree on which lines are code.
|
|
2648
|
+
*/
|
|
2649
|
+
function wrapBareUrls(text) {
|
|
2650
|
+
const lines = text.split("\n");
|
|
2651
|
+
const out = [];
|
|
2652
|
+
let inCodeBlock = false;
|
|
2653
|
+
for (const line of lines) {
|
|
2654
|
+
if (line.trimStart().startsWith("```")) {
|
|
2655
|
+
inCodeBlock = !inCodeBlock;
|
|
2656
|
+
out.push(line);
|
|
2657
|
+
continue;
|
|
2658
|
+
}
|
|
2659
|
+
out.push(inCodeBlock ? line : wrapBareUrlsOnLine(line));
|
|
2660
|
+
}
|
|
2661
|
+
return out.join("\n");
|
|
2662
|
+
}
|
|
2663
|
+
/** Insert blank lines between content blocks so Slack renders them with visual separation. */
|
|
2664
|
+
function ensureBlockSpacing(text) {
|
|
2665
|
+
const codeBlockPattern = /^```/;
|
|
2666
|
+
const listItemPattern = /^[-*•]\s|^\d+\.\s/;
|
|
2667
|
+
const lines = text.split("\n");
|
|
2668
|
+
const result = [];
|
|
2669
|
+
let inCodeBlock = false;
|
|
2670
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2671
|
+
const line = lines[i];
|
|
2672
|
+
if (codeBlockPattern.test(line.trimStart())) {
|
|
2673
|
+
if (!inCodeBlock) {
|
|
2674
|
+
const prev = result.length > 0 ? result[result.length - 1] : void 0;
|
|
2675
|
+
if (prev !== void 0 && prev.trim() !== "") result.push("");
|
|
2676
|
+
}
|
|
2677
|
+
inCodeBlock = !inCodeBlock;
|
|
2678
|
+
result.push(line);
|
|
2679
|
+
continue;
|
|
2680
|
+
}
|
|
2681
|
+
if (inCodeBlock) {
|
|
2682
|
+
result.push(line);
|
|
2683
|
+
continue;
|
|
2684
|
+
}
|
|
2685
|
+
const prev = result.length > 0 ? result[result.length - 1] : void 0;
|
|
2686
|
+
if (prev !== void 0 && prev.trim() !== "" && line.trim() !== "" && !(listItemPattern.test(prev.trimStart()) && listItemPattern.test(line.trimStart()))) result.push("");
|
|
2687
|
+
result.push(line);
|
|
2688
|
+
}
|
|
2689
|
+
return result.join("\n");
|
|
2690
|
+
}
|
|
2691
|
+
/**
|
|
2692
|
+
* Normalize model-authored Slack markdown for delivery via `markdown_text`
|
|
2693
|
+
* or `{ type: "markdown" }` blocks.
|
|
2694
|
+
*
|
|
2695
|
+
* Plugins may first rewrite ordinary Markdown. Core then applies Slack-only
|
|
2696
|
+
* delivery shaping: bare URL wrapping and block spacing. Slack reply delivery
|
|
2697
|
+
* owns chunking and continuation markers separately.
|
|
2698
|
+
*/
|
|
2699
|
+
function normalizeSlackReplyMarkdown(text) {
|
|
2700
|
+
let normalized = applyPluginFormatMarkdown(text).replace(/\r\n?/g, "\n").replace(/[ \t]+$/gm, "");
|
|
2701
|
+
normalized = wrapBareUrls(normalized);
|
|
2702
|
+
normalized = ensureBlockSpacing(normalized);
|
|
2703
|
+
return normalized.replace(/\n{3,}/g, "\n\n").trim();
|
|
2704
|
+
}
|
|
2705
|
+
/** Normalize assistant status text before handing it to Slack. */
|
|
2706
|
+
function normalizeSlackStatusText(text) {
|
|
2707
|
+
const trimmed = text.trim();
|
|
2708
|
+
if (!trimmed) return "";
|
|
2709
|
+
return truncateStatusText(trimmed.replace(/(?:\.\s*)+$/, "").trim());
|
|
2710
|
+
}
|
|
2711
|
+
/** Render a Slack user mention, which Slack shows as the person's name. */
|
|
2712
|
+
function slackMention(userId) {
|
|
2713
|
+
return `<@${userId}>`;
|
|
2714
|
+
}
|
|
2715
|
+
//#endregion
|
|
2716
|
+
//#region src/chat/automations/revision.ts
|
|
2717
|
+
/** Storage revisions prevent stale edits from replacing newer values. */
|
|
2718
|
+
var AutomationConflictError = class extends ToolInputError {
|
|
2719
|
+
constructor() {
|
|
2720
|
+
super("This Automation changed after it was loaded. Reload it before saving.");
|
|
2721
|
+
this.name = "AutomationConflictError";
|
|
2722
|
+
}
|
|
2723
|
+
};
|
|
2724
|
+
/** Sort object keys so equal values produce equal JSON. */
|
|
2725
|
+
function canonicalJson(value) {
|
|
2726
|
+
if (Array.isArray(value)) return value.map(canonicalJson);
|
|
2727
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, child]) => [key, canonicalJson(child)]));
|
|
2728
|
+
return value;
|
|
2729
|
+
}
|
|
2730
|
+
/** Identify the stored values being edited, including lifecycle and creator authority. */
|
|
2731
|
+
function automationRevision(task) {
|
|
2732
|
+
return createHash("sha256").update(JSON.stringify(canonicalJson(task))).digest("hex");
|
|
2733
|
+
}
|
|
2734
|
+
/** Reject a stale edit while the storage owner holds the write lock. */
|
|
2735
|
+
function requireAutomationRevision(task, revision) {
|
|
2736
|
+
if (!task || automationRevision(task) !== revision) throw new AutomationConflictError();
|
|
2737
|
+
}
|
|
2738
|
+
//#endregion
|
|
2739
|
+
//#region src/chat/automations/versions.ts
|
|
2740
|
+
/** Automation versions keep each saved definition so people can see how it changed. */
|
|
2741
|
+
/** Return the versioned part of an Automation. Lifecycle and run state are excluded. */
|
|
2742
|
+
function automationDefinition(task) {
|
|
2743
|
+
const common = {
|
|
2744
|
+
title: task.title?.trim() || null,
|
|
2745
|
+
instruction: task.task.text,
|
|
2746
|
+
credentialMode: task.credentialMode,
|
|
2747
|
+
destination: task.destination,
|
|
2748
|
+
outcomes: task.outcomes
|
|
2749
|
+
};
|
|
2750
|
+
return "schedule" in task ? {
|
|
2751
|
+
...common,
|
|
2752
|
+
schedule: task.schedule
|
|
2753
|
+
} : {
|
|
2754
|
+
...common,
|
|
2755
|
+
trigger: task.trigger
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
/** Compare definition values after key sorting. */
|
|
2759
|
+
function sameDefinitionValue(left, right) {
|
|
2760
|
+
return JSON.stringify(canonicalJson(left)) === JSON.stringify(canonicalJson(right));
|
|
2761
|
+
}
|
|
2762
|
+
/**
|
|
2763
|
+
* Save a version when the definition changed. Call this in the same
|
|
2764
|
+
* transaction and lock as the Automation write. Without `current`, the
|
|
2765
|
+
* Automation is new, so the creator saves version 1.
|
|
2766
|
+
*/
|
|
2767
|
+
async function recordAutomationVersion(db, kind, task, current, editedBy) {
|
|
2768
|
+
const definition = automationDefinition(task);
|
|
2769
|
+
if (current && sameDefinitionValue(automationDefinition(current), definition)) return;
|
|
2770
|
+
await db.insert(juniorAutomationVersions).values({
|
|
2771
|
+
kind,
|
|
2772
|
+
automationId: task.id,
|
|
2773
|
+
version: sql`(
|
|
2774
|
+
SELECT coalesce(max(${juniorAutomationVersions.version}), 0) + 1
|
|
2775
|
+
FROM ${juniorAutomationVersions}
|
|
2776
|
+
WHERE ${juniorAutomationVersions.kind} = ${kind}
|
|
2777
|
+
AND ${juniorAutomationVersions.automationId} = ${task.id}
|
|
2778
|
+
)`,
|
|
2779
|
+
createdAtMs: Date.now(),
|
|
2780
|
+
editedBy: editedBy ?? (current ? null : task.createdBy),
|
|
2781
|
+
definition
|
|
2782
|
+
});
|
|
2783
|
+
}
|
|
2784
|
+
function parseVersionRow(kind, row) {
|
|
2785
|
+
return automationVersionSchema.parse({
|
|
2786
|
+
kind,
|
|
2787
|
+
version: row.version,
|
|
2788
|
+
createdAt: new Date(row.createdAtMs).toISOString(),
|
|
2789
|
+
editedBy: row.editedBy,
|
|
2790
|
+
definition: row.definition
|
|
2791
|
+
});
|
|
2792
|
+
}
|
|
2793
|
+
/** Read one saved definition. */
|
|
2794
|
+
async function readAutomationVersion(db, kind, automationId, version) {
|
|
2795
|
+
const [row] = await db.select().from(juniorAutomationVersions).where(and(eq(juniorAutomationVersions.kind, kind), eq(juniorAutomationVersions.automationId, automationId), eq(juniorAutomationVersions.version, version))).limit(1);
|
|
2796
|
+
return row ? parseVersionRow(kind, row) : void 0;
|
|
2797
|
+
}
|
|
2798
|
+
/** Read saved definitions, newest first. */
|
|
2799
|
+
async function listAutomationVersions(db, kind, automationId, limit) {
|
|
2800
|
+
return (await db.select().from(juniorAutomationVersions).where(and(eq(juniorAutomationVersions.kind, kind), eq(juniorAutomationVersions.automationId, automationId))).orderBy(desc(juniorAutomationVersions.version)).limit(limit)).map((row) => parseVersionRow(kind, row));
|
|
2801
|
+
}
|
|
2802
|
+
//#endregion
|
|
2803
|
+
//#region src/chat/event-automations/store.ts
|
|
2804
|
+
const retainedEventAutomationSchema = eventAutomationSchema.extend({
|
|
2805
|
+
destination: slackDestinationSchema,
|
|
2806
|
+
outcomes: z.array(taskOutcomeSchema).max(5)
|
|
2807
|
+
});
|
|
2808
|
+
const eventAutomationRowColumns = {
|
|
2809
|
+
status: juniorEventAutomations.status,
|
|
2810
|
+
statusReason: juniorEventAutomations.statusReason,
|
|
2811
|
+
task: juniorEventAutomations.task,
|
|
2812
|
+
title: juniorEventAutomations.title
|
|
2813
|
+
};
|
|
2814
|
+
/** JSON task payload must not carry SQL-backed columns. */
|
|
2815
|
+
function eventAutomationJsonPayload(task) {
|
|
2816
|
+
const { status: _status, statusReason: _statusReason, title: _title, ...payload } = task;
|
|
2817
|
+
return payload;
|
|
2818
|
+
}
|
|
2819
|
+
/** Decode a retained event Automation with its SQL-backed fields. */
|
|
2820
|
+
function parseEventAutomationRow(row) {
|
|
2821
|
+
const raw = row.task && typeof row.task === "object" ? { ...row.task } : row.task;
|
|
2822
|
+
if (raw && typeof raw === "object" && "title" in raw) delete raw.title;
|
|
2823
|
+
if (raw && typeof raw === "object" && !("outcomes" in raw) && "destination" in raw) raw.outcomes = [{
|
|
2824
|
+
action: "send_message",
|
|
2825
|
+
destination: raw.destination
|
|
2826
|
+
}];
|
|
2827
|
+
const retained = retainedEventAutomationSchema.parse(raw);
|
|
2828
|
+
const { threadTs: _threadTs, ...destination } = retained.destination;
|
|
2829
|
+
const title = row.title?.trim();
|
|
2830
|
+
return {
|
|
2831
|
+
...retained,
|
|
2832
|
+
destination,
|
|
2833
|
+
outcomes: retained.outcomes.map((outcome) => {
|
|
2834
|
+
const { threadTs: _threadTs, ...destination } = outcome.destination;
|
|
2835
|
+
return {
|
|
2836
|
+
...outcome,
|
|
2837
|
+
destination
|
|
2838
|
+
};
|
|
2839
|
+
}),
|
|
2840
|
+
status: row.status ?? "active",
|
|
2841
|
+
...row.statusReason ? { statusReason: row.statusReason } : void 0,
|
|
2842
|
+
...title ? { title } : void 0
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
function activeEventAutomationWhere() {
|
|
2846
|
+
return ne(juniorEventAutomations.status, "deleted");
|
|
2847
|
+
}
|
|
2848
|
+
/** Read one event automation by id, including deleted rows when present. */
|
|
2849
|
+
async function getEventAutomation(db, id) {
|
|
2850
|
+
const rows = await db.select({ ...eventAutomationRowColumns }).from(juniorEventAutomations).where(eq(juniorEventAutomations.id, id)).limit(1);
|
|
2851
|
+
return rows[0] ? parseEventAutomationRow(rows[0]) : void 0;
|
|
2852
|
+
}
|
|
2853
|
+
/** Create one retry-stable event automation, or revive a deleted row with the new payload. */
|
|
2854
|
+
async function createEventAutomation(db, task) {
|
|
2855
|
+
const parsed = eventAutomationSchema.parse(eventAutomationJsonPayload(task));
|
|
2856
|
+
const title = task.title?.trim() || null;
|
|
2857
|
+
return db.transaction(async (tx) => {
|
|
2858
|
+
const inserted = await tx.insert(juniorEventAutomations).values({
|
|
2859
|
+
id: parsed.id,
|
|
2860
|
+
teamId: parsed.destination.teamId,
|
|
2861
|
+
namespace: parsed.trigger.namespace,
|
|
2862
|
+
identifier: parsed.trigger.identifier,
|
|
2863
|
+
createdAtMs: parsed.createdAtMs,
|
|
2864
|
+
status: "active",
|
|
2865
|
+
title,
|
|
2866
|
+
task: parsed
|
|
2867
|
+
}).onConflictDoNothing().returning({ ...eventAutomationRowColumns });
|
|
2868
|
+
if (inserted[0]) {
|
|
2869
|
+
const created = parseEventAutomationRow(inserted[0]);
|
|
2870
|
+
await recordAutomationVersion(tx, "event", created, void 0);
|
|
2871
|
+
return created;
|
|
2872
|
+
}
|
|
2873
|
+
const existing = await getEventAutomation(tx, parsed.id);
|
|
2874
|
+
if (!existing) return {
|
|
2875
|
+
...parsed,
|
|
2876
|
+
status: "active",
|
|
2877
|
+
...title ? { title } : void 0
|
|
2878
|
+
};
|
|
2879
|
+
if (existing.status !== "deleted") return existing;
|
|
2880
|
+
const rows = await tx.update(juniorEventAutomations).set({
|
|
2881
|
+
teamId: parsed.destination.teamId,
|
|
2882
|
+
namespace: parsed.trigger.namespace,
|
|
2883
|
+
identifier: parsed.trigger.identifier,
|
|
2884
|
+
status: "active",
|
|
2885
|
+
statusReason: null,
|
|
2886
|
+
title,
|
|
2887
|
+
task: parsed
|
|
2888
|
+
}).where(and(eq(juniorEventAutomations.id, parsed.id), eq(juniorEventAutomations.status, "deleted"))).returning({ ...eventAutomationRowColumns });
|
|
2889
|
+
if (!rows[0]) return await getEventAutomation(tx, parsed.id) ?? {
|
|
2890
|
+
...parsed,
|
|
2891
|
+
status: "active",
|
|
2892
|
+
...title ? { title } : void 0
|
|
2893
|
+
};
|
|
2894
|
+
const revived = parseEventAutomationRow(rows[0]);
|
|
2895
|
+
await recordAutomationVersion(tx, "event", revived, void 0);
|
|
2896
|
+
return revived;
|
|
2897
|
+
});
|
|
2898
|
+
}
|
|
2899
|
+
/**
|
|
2900
|
+
* Replace an existing non-deleted event automation under a row lock.
|
|
2901
|
+
* Pass `editedBy` when a person changes the definition so its version names them.
|
|
2902
|
+
*/
|
|
2903
|
+
async function saveEventAutomation(db, task, expectedRevision, editedBy) {
|
|
2904
|
+
const parsed = eventAutomationSchema.parse(eventAutomationJsonPayload(task));
|
|
2905
|
+
const title = task.title?.trim() || null;
|
|
2906
|
+
return db.transaction(async (tx) => {
|
|
2907
|
+
const rows = await tx.select({ ...eventAutomationRowColumns }).from(juniorEventAutomations).where(eq(juniorEventAutomations.id, parsed.id)).for("update");
|
|
2908
|
+
const current = rows[0] ? parseEventAutomationRow(rows[0]) : void 0;
|
|
2909
|
+
if (expectedRevision !== void 0) requireAutomationRevision(current, expectedRevision);
|
|
2910
|
+
if (!current || current.status === "deleted") return void 0;
|
|
2911
|
+
const saved = parseEventAutomationRow((await tx.update(juniorEventAutomations).set({
|
|
2912
|
+
namespace: parsed.trigger.namespace,
|
|
2913
|
+
identifier: parsed.trigger.identifier,
|
|
2914
|
+
title,
|
|
2915
|
+
task: parsed
|
|
2916
|
+
}).where(eq(juniorEventAutomations.id, parsed.id)).returning({ ...eventAutomationRowColumns }))[0]);
|
|
2917
|
+
await recordAutomationVersion(tx, "event", saved, current, editedBy);
|
|
2918
|
+
return saved;
|
|
2919
|
+
});
|
|
2920
|
+
}
|
|
2921
|
+
/** Mark one existing event automation deleted while retaining the row for history. */
|
|
2922
|
+
async function deleteEventAutomation(db, id) {
|
|
2923
|
+
const rows = await db.update(juniorEventAutomations).set({ status: "deleted" }).where(and(eq(juniorEventAutomations.id, id), activeEventAutomationWhere())).returning({ ...eventAutomationRowColumns });
|
|
2924
|
+
return rows[0] ? parseEventAutomationRow(rows[0]) : void 0;
|
|
2925
|
+
}
|
|
2926
|
+
/** List live event automations in one Slack workspace. */
|
|
2927
|
+
async function listEventAutomationsForTeam(db, teamId) {
|
|
2928
|
+
return (await db.select({ ...eventAutomationRowColumns }).from(juniorEventAutomations).where(and(eq(juniorEventAutomations.teamId, teamId), activeEventAutomationWhere())).orderBy(asc(juniorEventAutomations.createdAtMs), asc(juniorEventAutomations.id))).map(parseEventAutomationRow);
|
|
2929
|
+
}
|
|
2930
|
+
function viewerSlackIdentities(user) {
|
|
2931
|
+
return user.identities.filter((identity) => identity.provider === "slack" && Boolean(identity.providerTenantId) && Boolean(identity.providerSubjectId));
|
|
2932
|
+
}
|
|
2933
|
+
/** Return whether one event automation was created by a viewer-linked Slack identity. */
|
|
2934
|
+
function eventAutomationBelongsToUser(task, user) {
|
|
2935
|
+
return viewerSlackIdentities(user).some((identity) => identity.providerTenantId === task.destination.teamId && identity.providerSubjectId === task.createdBy.slackUserId);
|
|
2936
|
+
}
|
|
2937
|
+
/**
|
|
2938
|
+
* List deleted event automations created by one user, newest first.
|
|
2939
|
+
* Used only to keep historical runs after the live automation list drops the row.
|
|
2940
|
+
*/
|
|
2941
|
+
async function listDeletedEventAutomationsCreatedBy(db, user, limit) {
|
|
2942
|
+
const identities = viewerSlackIdentities(user);
|
|
2943
|
+
const ownership = or(...identities.map((identity) => and(eq(juniorEventAutomations.teamId, identity.providerTenantId), sql`${juniorEventAutomations.task}->'createdBy'->>'slackUserId' = ${identity.providerSubjectId}`)));
|
|
2944
|
+
if (!ownership) return [];
|
|
2945
|
+
return (await db.select({ ...eventAutomationRowColumns }).from(juniorEventAutomations).where(and(ownership, eq(juniorEventAutomations.status, "deleted"))).orderBy(desc(juniorEventAutomations.createdAtMs), desc(juniorEventAutomations.id)).limit(limit)).map(parseEventAutomationRow);
|
|
2946
|
+
}
|
|
2947
|
+
/**
|
|
2948
|
+
* Collect match keys from live event automations for these identifiers and event types.
|
|
2949
|
+
* Does not evaluate match values — only reports keys that filters use.
|
|
2950
|
+
*/
|
|
2951
|
+
async function collectEventAutomationMatchKeys(db, input) {
|
|
2952
|
+
const eventTypes = new Set(input.eventTypes.map((eventType) => eventType.trim()).filter(Boolean));
|
|
2953
|
+
const identifiers = [...new Set(input.identifiers.map((value) => value.trim()).filter(Boolean))];
|
|
2954
|
+
if (eventTypes.size === 0 || identifiers.length === 0) return [];
|
|
2955
|
+
const rows = await db.select({ ...eventAutomationRowColumns }).from(juniorEventAutomations).where(and(eq(juniorEventAutomations.teamId, input.teamId), eq(juniorEventAutomations.namespace, input.namespace), inArray(juniorEventAutomations.identifier, identifiers), eq(juniorEventAutomations.status, "active"))).orderBy(asc(juniorEventAutomations.createdAtMs), asc(juniorEventAutomations.id));
|
|
2956
|
+
const keys = /* @__PURE__ */ new Set();
|
|
2957
|
+
for (const row of rows) {
|
|
2958
|
+
const task = parseEventAutomationRow(row);
|
|
2959
|
+
if (!task.trigger.events.some((eventType) => eventTypes.has(eventType))) continue;
|
|
2960
|
+
for (const key of Object.keys(task.trigger.match ?? {})) keys.add(key);
|
|
2961
|
+
}
|
|
2962
|
+
return [...keys].sort();
|
|
2963
|
+
}
|
|
2964
|
+
/** Find every live task matching one normalized event. */
|
|
2965
|
+
async function findMatchingEventAutomations(db, event, teamId) {
|
|
2966
|
+
return (await db.select({ ...eventAutomationRowColumns }).from(juniorEventAutomations).where(and(eq(juniorEventAutomations.teamId, teamId), eq(juniorEventAutomations.namespace, event.namespace), eq(juniorEventAutomations.identifier, event.identifier), eq(juniorEventAutomations.status, "active"))).orderBy(asc(juniorEventAutomations.createdAtMs), asc(juniorEventAutomations.id))).map(parseEventAutomationRow).filter((task) => task.trigger.events.includes(event.eventType) && eventMatches(task.trigger.match, event.data));
|
|
2967
|
+
}
|
|
2968
|
+
/**
|
|
2969
|
+
* Change lifecycle under the same row lock as edits, without changing
|
|
2970
|
+
* credentials. A pause keeps an unresolved block reason, so resuming a paused
|
|
2971
|
+
* blocked automation returns it to blocked. Resume a blocked automation to
|
|
2972
|
+
* clear the reason.
|
|
2973
|
+
*/
|
|
2974
|
+
async function setEventAutomationStatus(db, id, status, revision) {
|
|
2975
|
+
return db.transaction(async (tx) => {
|
|
2976
|
+
const rows = await tx.select().from(juniorEventAutomations).where(eq(juniorEventAutomations.id, id)).for("update");
|
|
2977
|
+
const current = rows[0] ? parseEventAutomationRow(rows[0]) : void 0;
|
|
2978
|
+
requireAutomationRevision(current, revision);
|
|
2979
|
+
if (!current || current.status === "deleted") throw new Error("Automation no longer exists.");
|
|
2980
|
+
const next = status === "paused" ? {
|
|
2981
|
+
status,
|
|
2982
|
+
statusReason: current.statusReason
|
|
2983
|
+
} : current.status === "paused" && current.statusReason ? {
|
|
2984
|
+
status: "blocked",
|
|
2985
|
+
statusReason: current.statusReason
|
|
2986
|
+
} : {
|
|
2987
|
+
status,
|
|
2988
|
+
statusReason: void 0
|
|
2989
|
+
};
|
|
2990
|
+
await tx.update(juniorEventAutomations).set({
|
|
2991
|
+
status: next.status,
|
|
2992
|
+
statusReason: next.statusReason ?? null
|
|
2993
|
+
}).where(eq(juniorEventAutomations.id, id));
|
|
2994
|
+
const { statusReason: _statusReason, ...rest } = current;
|
|
2995
|
+
return {
|
|
2996
|
+
...rest,
|
|
2997
|
+
status: next.status,
|
|
2998
|
+
...next.statusReason ? { statusReason: next.statusReason } : void 0
|
|
2999
|
+
};
|
|
3000
|
+
});
|
|
3001
|
+
}
|
|
3002
|
+
/**
|
|
3003
|
+
* Stop an event automation after a run reports a problem that only its
|
|
3004
|
+
* creator can fix. An active automation becomes blocked. A paused automation
|
|
3005
|
+
* stays paused and keeps the reason, so resume returns it to blocked.
|
|
3006
|
+
* Deleted automations do not change. Returns true only when this call set
|
|
3007
|
+
* the automation to blocked, so the creator notice goes once and never for
|
|
3008
|
+
* an automation that the dashboard shows as paused.
|
|
3009
|
+
*/
|
|
3010
|
+
async function blockEventAutomation(db, id, reason) {
|
|
3011
|
+
return (await db.update(juniorEventAutomations).set({
|
|
3012
|
+
status: sql`case when ${juniorEventAutomations.status} = 'active' then 'blocked' else ${juniorEventAutomations.status} end`,
|
|
3013
|
+
statusReason: reason
|
|
3014
|
+
}).where(and(eq(juniorEventAutomations.id, id), or(eq(juniorEventAutomations.status, "active"), and(eq(juniorEventAutomations.status, "paused"), sql`${juniorEventAutomations.statusReason} is distinct from ${reason}`)))).returning({ status: juniorEventAutomations.status }))[0]?.status === "blocked";
|
|
3015
|
+
}
|
|
3016
|
+
//#endregion
|
|
3017
|
+
//#region src/chat/scheduled-automations/tasks.ts
|
|
3018
|
+
const SCHEDULER_KEY_PREFIX = "junior:scheduler";
|
|
3019
|
+
const retainedScheduledAutomationSchema = scheduledAutomationSchema.omit({
|
|
3020
|
+
creatorIdentityId: true,
|
|
3021
|
+
title: true
|
|
3022
|
+
}).extend({
|
|
3023
|
+
destination: slackDestinationSchema,
|
|
3024
|
+
outcomes: z.array(taskOutcomeSchema).max(5),
|
|
3025
|
+
version: z.number().optional()
|
|
3026
|
+
}).strict();
|
|
3027
|
+
/** Decode a retained scheduler task row and reject invalid routing context. */
|
|
3028
|
+
function parseScheduledAutomationRow(row) {
|
|
3029
|
+
const record = z.record(z.string(), z.unknown()).safeParse(row.record);
|
|
3030
|
+
if (!record.success) return void 0;
|
|
3031
|
+
const { creatorIdentityId: legacyCreatorIdentityId, title: legacyTitle, ...retained } = record.data;
|
|
3032
|
+
const current = retained.outcomes === void 0 && retained.destination !== void 0 ? {
|
|
3033
|
+
...retained,
|
|
3034
|
+
outcomes: [{
|
|
3035
|
+
action: "send_message",
|
|
3036
|
+
destination: retained.destination
|
|
3037
|
+
}]
|
|
3038
|
+
} : retained;
|
|
3039
|
+
const parsed = retainedScheduledAutomationSchema.safeParse(current);
|
|
3040
|
+
if (!parsed.success) return void 0;
|
|
3041
|
+
const { status, version: _version, ...retainedTask } = parsed.data;
|
|
3042
|
+
const destination = {
|
|
3043
|
+
platform: "slack",
|
|
3044
|
+
teamId: retainedTask.destination.teamId,
|
|
3045
|
+
channelId: retainedTask.destination.channelId
|
|
3046
|
+
};
|
|
3047
|
+
const task = {
|
|
3048
|
+
...retainedTask,
|
|
3049
|
+
destination,
|
|
3050
|
+
outcomes: retainedTask.outcomes.map((outcome) => ({
|
|
3051
|
+
...outcome,
|
|
3052
|
+
destination: {
|
|
3053
|
+
platform: "slack",
|
|
3054
|
+
teamId: outcome.destination.teamId,
|
|
3055
|
+
channelId: outcome.destination.channelId
|
|
3056
|
+
}
|
|
3057
|
+
}))
|
|
3058
|
+
};
|
|
3059
|
+
const fallbackIdentity = row.creatorIdentityId === null ? z.string().safeParse(legacyCreatorIdentityId) : void 0;
|
|
3060
|
+
const creatorIdentityId = row.creatorIdentityId ?? (fallbackIdentity?.success ? fallbackIdentity.data : void 0);
|
|
3061
|
+
if (creatorIdentityId === void 0) return void 0;
|
|
3062
|
+
const fallbackTitle = row.title === null ? z.string().safeParse(legacyTitle) : void 0;
|
|
3063
|
+
const title = (row.title !== null ? row.title : fallbackTitle?.success ? fallbackTitle.data : void 0)?.trim() || void 0;
|
|
3064
|
+
return {
|
|
3065
|
+
...task,
|
|
3066
|
+
creatorIdentityId,
|
|
3067
|
+
status,
|
|
3068
|
+
...title ? { title } : void 0
|
|
3069
|
+
};
|
|
3070
|
+
}
|
|
3071
|
+
/** Keep scan paths from returning current or legacy tombstones. */
|
|
3072
|
+
function isListedScheduledAutomation(task) {
|
|
3073
|
+
return task !== void 0 && task.status !== "deleted";
|
|
3074
|
+
}
|
|
3075
|
+
/** Build the stable run id for one task occurrence. */
|
|
3076
|
+
function scheduledRunId(taskId, scheduledForMs) {
|
|
3077
|
+
return `${taskId}:${scheduledForMs}`;
|
|
3078
|
+
}
|
|
3079
|
+
/** Acquire the deployed advisory lock for one scheduled automation. */
|
|
3080
|
+
function withScheduledAutomationLock(db, taskId, callback) {
|
|
3081
|
+
return db.transaction(async (tx) => {
|
|
3082
|
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${`${SCHEDULER_KEY_PREFIX}:task:${taskId}:lock`}))`);
|
|
3083
|
+
return await callback(tx);
|
|
3084
|
+
});
|
|
3085
|
+
}
|
|
3086
|
+
function requireStoredTask(task) {
|
|
3087
|
+
const parsed = scheduledAutomationSchema.safeParse(task);
|
|
3088
|
+
if (!parsed.success) throw new Error("Scheduled automation routing context is invalid.");
|
|
3089
|
+
const { title, ...current } = parsed.data;
|
|
3090
|
+
const normalizedTitle = title?.trim() || void 0;
|
|
3091
|
+
return {
|
|
3092
|
+
...current,
|
|
3093
|
+
...normalizedTitle ? { title: normalizedTitle } : void 0
|
|
3094
|
+
};
|
|
3095
|
+
}
|
|
3096
|
+
function scheduledAutomationJsonRecord(task) {
|
|
3097
|
+
const { title: _title, ...record } = task;
|
|
3098
|
+
return record;
|
|
3099
|
+
}
|
|
3100
|
+
async function upsertScheduledAutomation(db, task) {
|
|
3101
|
+
const title = task.title?.trim() || null;
|
|
3102
|
+
await db.insert(juniorSchedulerTasks).values({
|
|
3103
|
+
createdAtMs: task.createdAtMs,
|
|
3104
|
+
creatorIdentityId: task.creatorIdentityId,
|
|
3105
|
+
creatorSlackUserId: task.createdBy.slackUserId,
|
|
3106
|
+
id: task.id,
|
|
3107
|
+
nextRunAtMs: task.nextRunAtMs,
|
|
3108
|
+
record: scheduledAutomationJsonRecord(task),
|
|
3109
|
+
runNowAtMs: task.runNowAtMs,
|
|
3110
|
+
status: task.status,
|
|
3111
|
+
teamId: task.destination.teamId,
|
|
3112
|
+
title
|
|
3113
|
+
}).onConflictDoUpdate({
|
|
3114
|
+
target: juniorSchedulerTasks.id,
|
|
3115
|
+
set: {
|
|
3116
|
+
createdAtMs: sql`excluded.created_at_ms`,
|
|
3117
|
+
creatorIdentityId: sql`excluded.creator_identity_id`,
|
|
3118
|
+
creatorSlackUserId: sql`excluded.creator_slack_user_id`,
|
|
3119
|
+
nextRunAtMs: sql`excluded.next_run_at_ms`,
|
|
3120
|
+
record: sql`excluded.record`,
|
|
3121
|
+
runNowAtMs: sql`excluded.run_now_at_ms`,
|
|
3122
|
+
status: sql`excluded.status`,
|
|
3123
|
+
teamId: sql`excluded.team_id`,
|
|
3124
|
+
title: sql`excluded.title`
|
|
3125
|
+
}
|
|
3126
|
+
});
|
|
3127
|
+
}
|
|
3128
|
+
/** Read and decode one scheduled automation. */
|
|
3129
|
+
async function readScheduledAutomation(db, taskId) {
|
|
3130
|
+
const rows = await db.select({
|
|
3131
|
+
creatorIdentityId: juniorSchedulerTasks.creatorIdentityId,
|
|
3132
|
+
record: juniorSchedulerTasks.record,
|
|
3133
|
+
title: juniorSchedulerTasks.title
|
|
3134
|
+
}).from(juniorSchedulerTasks).where(eq(juniorSchedulerTasks.id, taskId)).limit(1);
|
|
3135
|
+
return rows[0] ? parseScheduledAutomationRow(rows[0]) : void 0;
|
|
3136
|
+
}
|
|
3137
|
+
async function readListedScheduledAutomations(db, teamId) {
|
|
3138
|
+
return (await db.select({
|
|
3139
|
+
creatorIdentityId: juniorSchedulerTasks.creatorIdentityId,
|
|
3140
|
+
record: juniorSchedulerTasks.record,
|
|
3141
|
+
title: juniorSchedulerTasks.title
|
|
3142
|
+
}).from(juniorSchedulerTasks).where(and(notInArray(juniorSchedulerTasks.status, ["deleted"]), teamId === void 0 ? void 0 : eq(juniorSchedulerTasks.teamId, teamId))).orderBy(asc(juniorSchedulerTasks.createdAtMs), asc(juniorSchedulerTasks.id))).map(parseScheduledAutomationRow).filter(isListedScheduledAutomation);
|
|
3143
|
+
}
|
|
3144
|
+
/** List decoded scheduled automations in stable creation order. */
|
|
3145
|
+
async function listScheduledAutomations(db) {
|
|
3146
|
+
return await readListedScheduledAutomations(db);
|
|
3147
|
+
}
|
|
3148
|
+
/** List decoded scheduled automations for one team in stable creation order. */
|
|
3149
|
+
async function listScheduledAutomationsForTeam(db, teamId) {
|
|
3150
|
+
return await readListedScheduledAutomations(db, teamId);
|
|
3151
|
+
}
|
|
3152
|
+
async function writeScheduledAutomation(db, task, current, editedBy) {
|
|
3153
|
+
if (current?.status === "blocked" && task.status === "active" && typeof task.nextRunAtMs === "number" && Number.isFinite(task.nextRunAtMs)) await db.delete(juniorSchedulerRuns).where(and(eq(juniorSchedulerRuns.id, scheduledRunId(task.id, task.nextRunAtMs)), eq(juniorSchedulerRuns.status, "blocked")));
|
|
3154
|
+
if (task.status === "deleted" && current?.status !== "deleted") await skipPendingRunsForDeletedTask(db, task);
|
|
3155
|
+
await upsertScheduledAutomation(db, task);
|
|
3156
|
+
await recordAutomationVersion(db, "scheduled", task, current, editedBy);
|
|
3157
|
+
}
|
|
3158
|
+
async function skipPendingRunsForDeletedTask(db, task) {
|
|
3159
|
+
const errorMessage = `Scheduled automation ${task.id} was deleted before the run started.`;
|
|
3160
|
+
const rows = await db.select({ record: juniorSchedulerRuns.record }).from(juniorSchedulerRuns).where(and(eq(juniorSchedulerRuns.taskId, task.id), eq(juniorSchedulerRuns.status, "pending")));
|
|
3161
|
+
for (const row of rows) {
|
|
3162
|
+
const parsed = scheduledRunSchema.safeParse(row.record);
|
|
3163
|
+
if (!parsed.success || parsed.data.status !== "pending") continue;
|
|
3164
|
+
const next = scheduledRunSchema.parse({
|
|
3165
|
+
...parsed.data,
|
|
3166
|
+
completedAtMs: task.updatedAtMs,
|
|
3167
|
+
errorMessage,
|
|
3168
|
+
status: "skipped"
|
|
3169
|
+
});
|
|
3170
|
+
await db.update(juniorSchedulerRuns).set({
|
|
3171
|
+
record: next,
|
|
3172
|
+
status: next.status
|
|
3173
|
+
}).where(and(eq(juniorSchedulerRuns.id, next.id), eq(juniorSchedulerRuns.status, "pending")));
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
/** Create a scheduled automation once under its retry-stable task lock. */
|
|
3177
|
+
async function createScheduledAutomation(db, task) {
|
|
3178
|
+
const next = requireStoredTask(task);
|
|
3179
|
+
return await withScheduledAutomationLock(db, task.id, async (tx) => {
|
|
3180
|
+
const current = await readScheduledAutomation(tx, task.id);
|
|
3181
|
+
if (current) return current;
|
|
3182
|
+
await writeScheduledAutomation(tx, next, void 0);
|
|
3183
|
+
return next;
|
|
3184
|
+
});
|
|
3185
|
+
}
|
|
3186
|
+
/**
|
|
3187
|
+
* Save a scheduled automation and clear its blocked occurrence on reactivation.
|
|
3188
|
+
* Pass `editedBy` when a person changes the definition so its version names them.
|
|
3189
|
+
*/
|
|
3190
|
+
async function saveScheduledAutomation(db, task, expectedRevision, editedBy) {
|
|
3191
|
+
const next = requireStoredTask(task);
|
|
3192
|
+
await withScheduledAutomationLock(db, task.id, async (tx) => {
|
|
3193
|
+
const current = await readScheduledAutomation(tx, task.id);
|
|
3194
|
+
if (expectedRevision !== void 0) requireAutomationRevision(current, expectedRevision);
|
|
3195
|
+
await writeScheduledAutomation(tx, next, current, editedBy);
|
|
3196
|
+
});
|
|
3197
|
+
return next;
|
|
3198
|
+
}
|
|
3199
|
+
/** Save a scheduled automation inside a task lock already held by the caller. */
|
|
3200
|
+
async function saveScheduledAutomationInLock(db, task, current) {
|
|
3201
|
+
await writeScheduledAutomation(db, requireStoredTask(task), current);
|
|
3202
|
+
}
|
|
3203
|
+
//#endregion
|
|
3204
|
+
export { persistWithRetry as $, markConversationMessage as $t, formatSlackLink as A, recordAuthenticationLinked as At, removeReactionFromMessage as B, persistConversationMessages as Bt, listAutomationVersions as C, loadConversationProjection as Ct, automationRevision as D, openConversationProjection as Dt, AutomationConflictError as E, loadTurnRoute as Et, addReactionToMessage as F, recordSubscribedReplyRoute as Ft, loadTurnCheckpoint as G, fallbackShortTitle as Gt, slackEntitySchema as H, persistConversationMessageSummaries as Ht, getSlackMessagePermalink as I, recordToolExecutionStarted as It, completeTurnRecord as J, buildConversationContext as Jt, saveTurnCheckpoint as K, resolveTaskTitle as Kt, openSlackDirectMessage as L, recordTurnRoute as Lt, normalizeSlackStatusText as M, recordAuthorizationRequested as Mt, slackMention as N, recordGuardianActionReviewed as Nt, requireAutomationRevision as O, recordAgentsInstructionsUpdated as Ot, truncateStatusText as P, recordMcpProviderConnected as Pt, recordTurnSummary as Q, isHumanConversationMessage as Qt, postSlackEphemeralMessage as R, appendConversationMessages as Rt, automationDefinition as S, loadConnectedMcpProviders as St, sameDefinitionValue as T, loadTurnProjection as Tt, slackExternalRefIdSchema as U, resolveConversationTitle as Ut, uploadFilesToConversation as V, projectConversationMessages as Vt, continuableMessages as W, settleConversationTitleWork as Wt, getTurnRecord as X, estimateConversationContextTokens as Xt, failTurnRecord as Y, createConversationMemoryService as Yt, listTurnSummaries as Z, getConversationMessageSlackTs as Zt, listDeletedEventAutomationsCreatedBy as _, trimTrailingAssistantMessages as _t, readScheduledAutomation as a, getAgentContextCompactionTriggerTokens as an, getPiMessageRole as at, saveEventAutomation as b, commitAcceptedReply as bt, scheduledRunId as c, piMessageFromHistoryItem as cn, hasRuntimeTurnContext as ct, collectEventAutomationMatchKeys as d, isContinuablePiBoundary as dt, normalizeConversationText as en, TurnSliceLimitExceededError as et, createEventAutomation as f, isToolResultError as ft, getEventAutomation as g, stripRuntimeTurnContext as gt, findMatchingEventAutomations as h, retainRuntimeTurnContext as ht, parseScheduledAutomationRow as i, estimateTextTokens as in, extractAssistantText as it, normalizeSlackReplyMarkdown as j, recordAuthorizationCompleted as jt, escapeSlackMrkdwnText as k, recordAttachmentsDelivered as kt, withScheduledAutomationLock as l, instructionTextForProjection as lt, eventAutomationBelongsToUser as m, normalizeToolNameFromResult as mt, listScheduledAutomations as n, turnHasReply as nn, buildTurnErrorResponse as nt, saveScheduledAutomation as o, getAgentContextInputLimitTokens as on, getTerminalAssistantMessages as ot, deleteEventAutomation as p, isToolResultMessage as pt, abandonTurnRecord as q, appendThreadContextMessages as qt, listScheduledAutomationsForTeam as r, upsertConversationMessage as rn, isTurnExecutionLimitExceededError as rt, saveScheduledAutomationInLock as s, historyItemFromPiMessage as sn, getUserMessageInstructionText as st, createScheduledAutomation as t, recordDeliveredAssistantMessage as tn, assertTurnToolCallLimit as tt, blockEventAutomation as u, isAssistantMessage as ut, listEventAutomationsForTeam as v, TURN_CONTEXT_TAG as vt, readAutomationVersion as w, loadProjection as wt, setEventAutomationStatus as x, commitMessages as xt, parseEventAutomationRow as y, ToolInputError as yt, postSlackMessage as z, hydrateConversationMessages as zt };
|