@sentry/junior 0.266.0 → 0.267.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/{actor-tlxzqaRe.js → actor-DZSokpwj.js} +8 -1
- package/dist/{adapter-eRFA_naf.js → adapter-BybMEezj.js} +2 -2
- package/dist/{admin-tILpP51p.js → admin-DOs1_EiW.js} +1 -1
- package/dist/{agent-runner-BtYK7bht.js → agent-runner-DgNATN4D.js} +872 -226
- package/dist/api/schema.d.ts +198 -23
- package/dist/api/schema.js +5 -4
- package/dist/api.d.ts +6 -1
- package/dist/api.js +130 -1205
- package/dist/{app-Csbi9dW7.d.ts → app-DAB3fzyp.d.ts} +21 -3
- package/dist/app.d.ts +2 -2
- package/dist/app.js +1610 -353
- package/dist/{assistant-reply-UyzAfZQo.js → assistant-reply-CT7VcXQe.js} +1 -1
- package/dist/{automation-BKpY9LPa.js → automation-CmeFBSEh.js} +42 -14
- package/dist/{briefs-DgfWMU7e.js → briefs-CWKjka7v.js} +4 -4
- package/dist/{catalog-runtime-DAJjAbSx.js → catalog-runtime-cMlAI5LY.js} +1 -1
- package/dist/cli/chat.js +15 -15
- package/dist/cli/check.js +3 -3
- package/dist/cli/init.js +0 -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-B0YZfxNy.js → client-BjgbMawj.js} +2 -2
- package/dist/{config-C0UwYqDK.js → config-CdB6yJj5.js} +32 -10
- package/dist/{connections-Bwu3VORi.js → connections-RE_65jzK.js} +13 -12
- package/dist/{conversation-DF4rUcjb.js → conversation-DSo310Ps.js} +8 -1
- package/dist/{conversation-privacy-CY7JVjxn.js → conversation-privacy-BzgJC5k0.js} +1 -1
- package/dist/{credential-sync-VGAMUTGp.js → credential-sync-DhEGEpgq.js} +15 -15
- package/dist/{credentialed-enF5Bf3y.js → credentialed-CIGwoLU2.js} +2905 -1024
- package/dist/{db-BnRqQ7G5.d.ts → db-Bg-zC3r5.d.ts} +1 -1
- package/dist/{executor-TnWbvL09.js → executor-VYrx_K4w.js} +7 -4
- package/dist/{images-BKQqSi5W.js → images-CecjXAAz.js} +3 -3
- package/dist/instrumentation.js +6 -2
- package/dist/{job-queue-DBpHmKnd.js → job-queue-C0BZSa7r.js} +128 -3
- package/dist/{web-input-B-bMjD1V.js → list-1XQP-o6K.js} +1374 -71
- package/dist/{logging-Ce0mXZ8J.js → logging-DuZ1A4ab.js} +11 -3
- package/dist/{message-VZ3DFpGb.js → message-NKSbbnA6.js} +4 -4
- package/dist/nitro.d.ts +1 -1
- package/dist/nitro.js +3 -3
- package/dist/{oauth-callback-server-CpqWLjVY.js → oauth-callback-server-qAHLPtml.js} +2 -2
- package/dist/{prompt-BcCGlmg2.js → prompt-BW9jFsqZ.js} +1 -1
- package/dist/{registry-1BEIPdbT.js → registry-FQutO926.js} +2 -2
- package/dist/{resolve-BSHHkEGE.js → resolve-Bk4yeuHy.js} +3 -3
- package/dist/{result-CwOpzqdh.js → result-B9e9ATOU.js} +9 -9
- package/dist/{runner-CEaX04kz.js → runner-C3S2ObH-.js} +11 -11
- package/dist/{sandbox-egress-signals-BwtGHwqa.js → sandbox-egress-signals-DqpGzzMB.js} +1 -1
- package/dist/{schema-RsaBHxRH.js → schema-X7Fj4fv1.js} +3 -2
- package/dist/{service-account-Dg4lIbHm.js → service-account-BQasl-qq.js} +3 -3
- package/dist/{service-account-6wC76eBZ.js → service-account-Do30HUNv.js} +3 -3
- package/dist/{session-stop-tZOw9AJD.js → session-stop-DkOjt46_.js} +45 -128
- package/dist/{skills-DzyGN7wd.js → skills-CQHrwfOa.js} +2 -2
- package/dist/{spawn-6rokq7l_.js → spawn-BHuqN14f.js} +2 -2
- package/dist/{store-DcKkzrdt.js → store-6EetVP-m.js} +3 -3
- package/dist/{store-CG0VJyye.d.ts → store-B8CABbmR.d.ts} +10 -2
- package/dist/store-DvNZIsCr.js +295 -0
- package/dist/{task-queue-Bg4RS2Gy.js → task-queue-B0DLgd2P.js} +10 -5
- package/dist/{task-runner-8N5LIkSg.js → task-runner-B-PD_JOj.js} +25 -13
- package/dist/{tasks-DDT-J3Rf.js → tasks-0ilmuCux.js} +1438 -44
- package/dist/{thread-state-cprEaxgl.js → thread-state-DRmdJ2QN.js} +5 -5
- package/dist/{turn-failure-response-C8sMdsdX.js → turn-failure-response-G11cs2gE.js} +9 -9
- package/dist/{turn-lifecycle-DaBVoz7h.js → turn-lifecycle-OzLIwvi1.js} +1 -1
- package/dist/{turn-session-routing-a9CHX-w6.js → turn-session-routing-Dyut9l7Y.js} +1 -1
- package/dist/user-input-request-BDFBdF8r.js +116 -0
- package/dist/{validation-C2lllinq.js → validation-CxEmt1id.js} +1 -1
- package/dist/{version-oaLfxxnf.js → version-Bapedn4A.js} +1 -1
- package/dist/version.js +1 -1
- package/dist/{viewer-BpE6Z3qf.js → viewer-DrN3oC2X.js} +21 -10
- package/dist/{work-VX1SHWha.js → work-DUQoNcqP.js} +10 -10
- package/dist/{worker-hook-y_Y.js → worker-D1l4Ugk0.js} +30 -19
- package/migrations/0064_any_resource_event_triggers.sql +2 -0
- package/migrations/meta/0064_snapshot.json +3505 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +20 -5
- package/dist/store-FALy-JQb.js +0 -1424
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import { a as completeText, f as unwrapCurrentInstruction, p as escapeXml, s as resolveGatewayModel, y as isProviderRetryError } from "./client-
|
|
2
|
-
import { n as botConfig } from "./config-
|
|
3
|
-
import {
|
|
4
|
-
import { c as toStoredSlackActor,
|
|
5
|
-
import {
|
|
1
|
+
import { a as completeText, f as unwrapCurrentInstruction, p as escapeXml, s as resolveGatewayModel, y as isProviderRetryError } from "./client-BjgbMawj.js";
|
|
2
|
+
import { i as getChatConfig, n as botConfig } from "./config-CdB6yJj5.js";
|
|
3
|
+
import { B as isRecord, H as toOptionalString, V as toOptionalNumber, b as setSpanAttributes, h as logWarn, o as getActiveTraceContext, p as logException, t as buildTurnFailureResponse } from "./logging-DuZ1A4ab.js";
|
|
4
|
+
import { c as toStoredSlackActor, p as parseSlackMessageTs, s as parseStoredSlackActor } from "./actor-DZSokpwj.js";
|
|
5
|
+
import { B as parseDestination, C as bindProviderConversation, H as sameDestination, T as addAgentTurnUsage, f as getConversationStore, g as withConversationEventLock, h as createSqlConversationEventStore, m as getSqlExecutor, p as getDb, u as getConversationEventStore, x as startLocationThread } from "./viewer-DrN3oC2X.js";
|
|
6
6
|
import { d as juniorDestinations } from "./agent-invocations-CxKldAeB.js";
|
|
7
|
-
import { a as juniorAutomationRuns, l as juniorLocationConfigurations, o as juniorAutomationVersions, s as juniorAutomations } from "./executor-
|
|
7
|
+
import { a as juniorAutomationRuns, l as juniorLocationConfigurations, o as juniorAutomationVersions, s as juniorAutomations } from "./executor-VYrx_K4w.js";
|
|
8
8
|
import { d as contextProvenance, f as instructionActors, n as agentHistoryItemSchema, p as instructionProvenanceFor } from "./history-C84mlAwO.js";
|
|
9
|
-
import { f as credentialContextForActor, h as credentialSubjectSchema } from "./registry-
|
|
10
|
-
import { n as pluginCatalogRuntime } from "./catalog-runtime-
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { A as eventAutomationSchema, E as scheduledAutomationSchema, S as automationVersionSchema } from "./automation-
|
|
9
|
+
import { f as credentialContextForActor, h as credentialSubjectSchema } from "./registry-FQutO926.js";
|
|
10
|
+
import { n as pluginCatalogRuntime } from "./catalog-runtime-cMlAI5LY.js";
|
|
11
|
+
import { H as startOAuthFlow, Nn as createPluginLogger, St as resolveSandboxEgressProviderForHost, ar as createPluginCache, cn as sleep$1, cr as agentsInstructionsUpdatedEvent, dn as JUNIOR_THREAD_STATE_TTL_MS, gn as ToolInputError, lr as authenticationLinkedEvent, p as getPlugins, sr as JUNIOR_NATIVE_EVENT_NAMESPACE, t as executeCredentialedEgressRequest, ur as authenticationUnlinkedEvent, vt as parseSandboxEgressAuthRequiredSignal, z as formatProviderLabel } from "./credentialed-CIGwoLU2.js";
|
|
12
|
+
import { a as MUTATION_LOCK_TTL_MS, c as withLock, i as getStateAdapter, r as getDefaultRedisStateAdapterFor, s as fenceLock } from "./adapter-BybMEezj.js";
|
|
13
|
+
import { L as messageCardRef } from "./conversation-DSo310Ps.js";
|
|
14
|
+
import { A as eventAutomationSchema, E as scheduledAutomationSchema, S as automationVersionSchema } from "./automation-CmeFBSEh.js";
|
|
15
15
|
import { isDeepStrictEqual } from "node:util";
|
|
16
16
|
import { z } from "zod";
|
|
17
17
|
import { createHash, randomUUID } from "node:crypto";
|
|
18
|
-
import { and, asc, desc, eq, inArray, ne, or, sql } from "drizzle-orm";
|
|
19
|
-
import { actorSchema, eventMatches, sourceSchema } from "@sentry/junior-plugin-api";
|
|
18
|
+
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
|
19
|
+
import { actorSchema, anyResourceMatchFields, describeEventTrigger, destinationSchema, eventMatchSchema, eventMatches, eventTriggerChoices, eventTriggerProblems, normalizeEventIdentifier, normalizeEventMatch, sourceSchema } from "@sentry/junior-plugin-api";
|
|
20
20
|
// src/chat/configuration/defaults.ts
|
|
21
21
|
let installDefaults = {};
|
|
22
22
|
function cloneDefaults(defaults) {
|
|
@@ -1038,7 +1038,7 @@ async function persistWithRetry(persist) {
|
|
|
1038
1038
|
return;
|
|
1039
1039
|
} catch (error) {
|
|
1040
1040
|
lastError = error;
|
|
1041
|
-
if (attempt < PERSIST_ATTEMPTS) await sleep(attempt * 100);
|
|
1041
|
+
if (attempt < PERSIST_ATTEMPTS) await sleep$1(attempt * 100);
|
|
1042
1042
|
}
|
|
1043
1043
|
throw lastError;
|
|
1044
1044
|
}
|
|
@@ -1105,6 +1105,1161 @@ function buildTurnLimitResponse(eventId) {
|
|
|
1105
1105
|
function buildTurnErrorResponse(error, eventId) {
|
|
1106
1106
|
return isTurnExecutionLimitExceededError(error) ? buildTurnLimitResponse(eventId) : buildTurnFailureResponse(eventId);
|
|
1107
1107
|
}
|
|
1108
|
+
// src/chat/task-execution/state.ts
|
|
1109
|
+
/**
|
|
1110
|
+
* Expiring conversation mailbox and execution index store.
|
|
1111
|
+
*
|
|
1112
|
+
* The conversation record owns pending inbound work, execution status, and the
|
|
1113
|
+
* lease. `conversation:active` feeds heartbeat recovery and contains every
|
|
1114
|
+
* non-idle conversation until the lease/status path makes it idle.
|
|
1115
|
+
* `conversation:by-activity` supports legacy SQL import only; SQL owns durable
|
|
1116
|
+
* conversation metadata in configured runtime.
|
|
1117
|
+
*/
|
|
1118
|
+
const CONVERSATION_PREFIX = "junior:conversation:v2";
|
|
1119
|
+
const CONVERSATION_SCHEMA_VERSION = 2;
|
|
1120
|
+
const CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH = 1e4;
|
|
1121
|
+
const CONVERSATION_INDEX_LOCK_TTL_MS = 1e4;
|
|
1122
|
+
const CONVERSATION_INDEX_LOCK_WAIT_MS = 2e3;
|
|
1123
|
+
const CONVERSATION_INDEX_LOCK_RETRY_MS = 25;
|
|
1124
|
+
const CONVERSATION_MUTATION_LOCK_TTL_MS = 1e4;
|
|
1125
|
+
const CONVERSATION_MUTATION_WAIT_MS = 1e4;
|
|
1126
|
+
const CONVERSATION_MUTATION_RETRY_MS = 25;
|
|
1127
|
+
var InvalidConversationRecordError = class extends Error {
|
|
1128
|
+
constructor(conversationId) {
|
|
1129
|
+
super(`Conversation record is invalid for ${conversationId}`);
|
|
1130
|
+
this.name = "InvalidConversationRecordError";
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
/** Return whether an error means a stored conversation record failed validation. */
|
|
1134
|
+
function isInvalidConversationRecordError(error) {
|
|
1135
|
+
return error instanceof InvalidConversationRecordError;
|
|
1136
|
+
}
|
|
1137
|
+
var ConversationMutationFencedError = class extends Error {
|
|
1138
|
+
constructor(conversationId) {
|
|
1139
|
+
super(`Conversation mutation lock was lost before write for ${conversationId}`);
|
|
1140
|
+
this.name = "ConversationMutationFencedError";
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
const CONVERSATION_BY_ACTIVITY_INDEX_KEY = `${CONVERSATION_PREFIX}:by-activity`;
|
|
1144
|
+
const CONVERSATION_ACTIVE_INDEX_KEY = `${CONVERSATION_PREFIX}:active`;
|
|
1145
|
+
const CONVERSATION_WORK_LEASE_TTL_MS = 9e4;
|
|
1146
|
+
const CONVERSATION_WORK_CHECK_IN_INTERVAL_MS = 15e3;
|
|
1147
|
+
const CONVERSATION_WORK_STALE_ENQUEUE_MS = 6e4;
|
|
1148
|
+
const inboundMessageSourceSchema = z.enum([
|
|
1149
|
+
"api",
|
|
1150
|
+
"internal",
|
|
1151
|
+
"local",
|
|
1152
|
+
"plugin",
|
|
1153
|
+
"event",
|
|
1154
|
+
"scheduler",
|
|
1155
|
+
"slack",
|
|
1156
|
+
"web"
|
|
1157
|
+
]);
|
|
1158
|
+
const agentInputSchema = z.object({
|
|
1159
|
+
attachments: z.array(z.unknown()).optional(),
|
|
1160
|
+
authorId: z.string().min(1).optional(),
|
|
1161
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
1162
|
+
text: z.string()
|
|
1163
|
+
}).strict().refine((input) => input.text.trim() || input.attachments?.length, { message: "agent input requires text or attachments" });
|
|
1164
|
+
/** Durable delivery modes for pending inbound mailbox work. */
|
|
1165
|
+
const inboundMessageDeliverySchema = z.enum(["defer", "interrupt"]);
|
|
1166
|
+
/** Sentry trace propagation headers. A malformed value is dropped, not rejected. */
|
|
1167
|
+
const traceContextSchema = z.object({
|
|
1168
|
+
baggage: z.string().optional(),
|
|
1169
|
+
sentryTrace: z.string().min(1)
|
|
1170
|
+
}).optional().catch(void 0);
|
|
1171
|
+
/** Redis mailbox shape kept for deployed workers that still require this field. */
|
|
1172
|
+
const storedInboundMessageSchema = z.object({
|
|
1173
|
+
attemptCount: z.number().finite().optional(),
|
|
1174
|
+
conversationId: z.string().refine((value) => value.trim().length > 0),
|
|
1175
|
+
createdAtMs: z.number().finite(),
|
|
1176
|
+
delivery: inboundMessageDeliverySchema,
|
|
1177
|
+
destination: destinationSchema.optional(),
|
|
1178
|
+
inboundMessageId: z.string().refine((value) => value.trim().length > 0),
|
|
1179
|
+
injectedAtMs: z.number().finite().optional(),
|
|
1180
|
+
input: agentInputSchema,
|
|
1181
|
+
receivedAtMs: z.number().finite(),
|
|
1182
|
+
source: inboundMessageSourceSchema,
|
|
1183
|
+
traceContext: traceContextSchema
|
|
1184
|
+
}).strict().extend({ publishExternally: z.boolean() });
|
|
1185
|
+
function conversationKey(conversationId) {
|
|
1186
|
+
return `${CONVERSATION_PREFIX}:${conversationId}`;
|
|
1187
|
+
}
|
|
1188
|
+
function conversationStopKey(conversationId) {
|
|
1189
|
+
return `${CONVERSATION_PREFIX}:stop:${conversationId}`;
|
|
1190
|
+
}
|
|
1191
|
+
function indexLockKey(indexKey) {
|
|
1192
|
+
return `${indexKey}:lock`;
|
|
1193
|
+
}
|
|
1194
|
+
function mutationLockKey(conversationId) {
|
|
1195
|
+
return `${CONVERSATION_PREFIX}:mutation:${conversationId}`;
|
|
1196
|
+
}
|
|
1197
|
+
function now() {
|
|
1198
|
+
return Date.now();
|
|
1199
|
+
}
|
|
1200
|
+
function compareMessages(left, right) {
|
|
1201
|
+
return left.createdAtMs - right.createdAtMs || left.receivedAtMs - right.receivedAtMs || left.inboundMessageId.localeCompare(right.inboundMessageId);
|
|
1202
|
+
}
|
|
1203
|
+
function inputHasAttachments(input) {
|
|
1204
|
+
return Array.isArray(input.attachments) && input.attachments.length > 0;
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Upgrade a still-pending payload when a duplicate source event carries file
|
|
1208
|
+
* attachments the stored copy lacks (Slack `app_mention`/`message` twins),
|
|
1209
|
+
* keeping the stored ordering timestamps and delivery attempts.
|
|
1210
|
+
*/
|
|
1211
|
+
function upgradedPendingMessage(stored, duplicate) {
|
|
1212
|
+
const promotesInterrupt = stored.delivery === "defer" && duplicate.delivery === "interrupt";
|
|
1213
|
+
const delivery = promotesInterrupt ? "interrupt" : stored.delivery;
|
|
1214
|
+
const input = promotesInterrupt ? {
|
|
1215
|
+
...duplicate.input,
|
|
1216
|
+
...inputHasAttachments(duplicate.input) || !inputHasAttachments(stored.input) ? void 0 : { attachments: stored.input.attachments }
|
|
1217
|
+
} : inputHasAttachments(duplicate.input) && !inputHasAttachments(stored.input) ? duplicate.input : stored.input;
|
|
1218
|
+
if (input === stored.input && delivery === stored.delivery) return stored;
|
|
1219
|
+
return {
|
|
1220
|
+
...stored,
|
|
1221
|
+
delivery,
|
|
1222
|
+
input
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
function compareIndexDescending(left, right) {
|
|
1226
|
+
return right.score - left.score || right.conversationId.localeCompare(left.conversationId);
|
|
1227
|
+
}
|
|
1228
|
+
function compareIndexAscending(left, right) {
|
|
1229
|
+
return left.score - right.score || left.conversationId.localeCompare(right.conversationId);
|
|
1230
|
+
}
|
|
1231
|
+
function uniqueStrings(values) {
|
|
1232
|
+
return [...new Set(values)];
|
|
1233
|
+
}
|
|
1234
|
+
function normalizeSource(value) {
|
|
1235
|
+
const parsed = inboundMessageSourceSchema.safeParse(value);
|
|
1236
|
+
return parsed.success ? parsed.data : void 0;
|
|
1237
|
+
}
|
|
1238
|
+
function normalizeExecutionStatus(value) {
|
|
1239
|
+
if (value === "paused") return value;
|
|
1240
|
+
if (value === "failed" || value === "idle" || value === "pending" || value === "running") return value;
|
|
1241
|
+
}
|
|
1242
|
+
function normalizeMessage(value) {
|
|
1243
|
+
const parsed = storedInboundMessageSchema.safeParse(value);
|
|
1244
|
+
if (!parsed.success) return;
|
|
1245
|
+
const { publishExternally: _publishExternally, traceContext, ...message } = parsed.data;
|
|
1246
|
+
return traceContext ? {
|
|
1247
|
+
...message,
|
|
1248
|
+
traceContext
|
|
1249
|
+
} : message;
|
|
1250
|
+
}
|
|
1251
|
+
function storeMessage(message, publishExternally) {
|
|
1252
|
+
return {
|
|
1253
|
+
...message,
|
|
1254
|
+
publishExternally
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
/** Whether this is the final attempt before an unacked message is dead-lettered. */
|
|
1258
|
+
function isFinalAttempt(message) {
|
|
1259
|
+
return (message.attemptCount ?? 0) >= 4;
|
|
1260
|
+
}
|
|
1261
|
+
function normalizeActor(value) {
|
|
1262
|
+
return parseStoredSlackActor(value);
|
|
1263
|
+
}
|
|
1264
|
+
function normalizeLease(value) {
|
|
1265
|
+
if (!isRecord(value)) return;
|
|
1266
|
+
const token = toOptionalString(value.token);
|
|
1267
|
+
const acquiredAtMs = toOptionalNumber(value.acquiredAtMs);
|
|
1268
|
+
const lastCheckInAtMs = toOptionalNumber(value.lastCheckInAtMs);
|
|
1269
|
+
const expiresAtMs = toOptionalNumber(value.expiresAtMs);
|
|
1270
|
+
if (!token || typeof acquiredAtMs !== "number" || typeof lastCheckInAtMs !== "number" || typeof expiresAtMs !== "number") return;
|
|
1271
|
+
return {
|
|
1272
|
+
token,
|
|
1273
|
+
acquiredAtMs,
|
|
1274
|
+
lastCheckInAtMs,
|
|
1275
|
+
expiresAtMs
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
function normalizeStop(value) {
|
|
1279
|
+
if (!isRecord(value)) return;
|
|
1280
|
+
const inboundMessageIds = Array.isArray(value.inboundMessageIds) ? uniqueStrings(value.inboundMessageIds.filter((id) => typeof id === "string")) : [];
|
|
1281
|
+
const runId = toOptionalString(value.runId);
|
|
1282
|
+
return runId ? {
|
|
1283
|
+
inboundMessageIds,
|
|
1284
|
+
runId
|
|
1285
|
+
} : void 0;
|
|
1286
|
+
}
|
|
1287
|
+
/** Decode execution state and repair idle records that still own work. */
|
|
1288
|
+
function normalizeExecution(conversationId, value) {
|
|
1289
|
+
if (!isRecord(value)) return;
|
|
1290
|
+
const status = normalizeExecutionStatus(value.status);
|
|
1291
|
+
if (!status) return;
|
|
1292
|
+
const pendingMessages = [];
|
|
1293
|
+
if (Array.isArray(value.pendingMessages)) {
|
|
1294
|
+
for (const rawMessage of value.pendingMessages) {
|
|
1295
|
+
const message = normalizeMessage(rawMessage);
|
|
1296
|
+
if (!message || message.conversationId !== conversationId) return;
|
|
1297
|
+
if (message.injectedAtMs !== void 0) continue;
|
|
1298
|
+
pendingMessages.push(message);
|
|
1299
|
+
}
|
|
1300
|
+
pendingMessages.sort(compareMessages);
|
|
1301
|
+
}
|
|
1302
|
+
const inboundMessageIds = Array.isArray(value.inboundMessageIds) ? uniqueStrings(value.inboundMessageIds.map((id) => typeof id === "string" ? id : void 0).filter((id) => Boolean(id))) : [];
|
|
1303
|
+
const lease = normalizeLease(value.lease);
|
|
1304
|
+
const runId = toOptionalString(value.runId);
|
|
1305
|
+
return {
|
|
1306
|
+
status: status === "idle" && lease ? "running" : status === "idle" && pendingMessages.length > 0 ? "pending" : status,
|
|
1307
|
+
inboundMessageIds: uniqueStrings([...inboundMessageIds, ...pendingMessages.map((message) => message.inboundMessageId)]),
|
|
1308
|
+
pendingCount: pendingMessages.length,
|
|
1309
|
+
pendingMessages,
|
|
1310
|
+
lease,
|
|
1311
|
+
lastCheckpointAtMs: toOptionalNumber(value.lastCheckpointAtMs),
|
|
1312
|
+
lastEnqueuedAtMs: toOptionalNumber(value.lastEnqueuedAtMs),
|
|
1313
|
+
lastProgressAtMs: toOptionalNumber(value.lastProgressAtMs),
|
|
1314
|
+
retryCount: toOptionalNumber(value.retryCount),
|
|
1315
|
+
runId,
|
|
1316
|
+
updatedAtMs: toOptionalNumber(value.updatedAtMs)
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
/** Decode current conversation records. */
|
|
1320
|
+
function normalizeConversation(conversationId, value) {
|
|
1321
|
+
if (!isRecord(value) || value.schemaVersion !== CONVERSATION_SCHEMA_VERSION) return;
|
|
1322
|
+
const storedConversationId = toOptionalString(value.conversationId);
|
|
1323
|
+
const createdAtMs = toOptionalNumber(value.createdAtMs);
|
|
1324
|
+
const lastActivityAtMs = toOptionalNumber(value.lastActivityAtMs);
|
|
1325
|
+
const updatedAtMs = toOptionalNumber(value.updatedAtMs);
|
|
1326
|
+
const execution = normalizeExecution(conversationId, value.execution);
|
|
1327
|
+
const destination = value.destination === void 0 ? void 0 : parseDestination(value.destination);
|
|
1328
|
+
if (storedConversationId !== conversationId || typeof createdAtMs !== "number" || typeof lastActivityAtMs !== "number" || typeof updatedAtMs !== "number" || !execution || value.destination !== void 0 && !destination) return;
|
|
1329
|
+
if (execution.pendingMessages.length > 0 && execution.pendingMessages.some((message) => {
|
|
1330
|
+
if (!message.destination) return false;
|
|
1331
|
+
return !destination || !sameDestination(message.destination, destination);
|
|
1332
|
+
})) return;
|
|
1333
|
+
return {
|
|
1334
|
+
schemaVersion: CONVERSATION_SCHEMA_VERSION,
|
|
1335
|
+
conversationId,
|
|
1336
|
+
createdAtMs,
|
|
1337
|
+
lastActivityAtMs,
|
|
1338
|
+
updatedAtMs,
|
|
1339
|
+
execution,
|
|
1340
|
+
...destination ? { destination } : void 0,
|
|
1341
|
+
...toOptionalString(value.title) ? { title: toOptionalString(value.title) } : void 0,
|
|
1342
|
+
...toOptionalString(value.channelName) ? { channelName: toOptionalString(value.channelName) } : void 0,
|
|
1343
|
+
...normalizeActor(value.actor) ? { actor: normalizeActor(value.actor) } : void 0,
|
|
1344
|
+
...normalizeSource(value.source) ? { source: normalizeSource(value.source) } : void 0
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
function emptyConversation(args) {
|
|
1348
|
+
return {
|
|
1349
|
+
schemaVersion: CONVERSATION_SCHEMA_VERSION,
|
|
1350
|
+
conversationId: args.conversationId,
|
|
1351
|
+
createdAtMs: args.nowMs,
|
|
1352
|
+
lastActivityAtMs: args.nowMs,
|
|
1353
|
+
updatedAtMs: args.nowMs,
|
|
1354
|
+
...args.destination ? { destination: args.destination } : void 0,
|
|
1355
|
+
...args.source ? { source: args.source } : void 0,
|
|
1356
|
+
execution: {
|
|
1357
|
+
status: "idle",
|
|
1358
|
+
inboundMessageIds: [],
|
|
1359
|
+
pendingCount: 0,
|
|
1360
|
+
pendingMessages: [],
|
|
1361
|
+
updatedAtMs: args.nowMs
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
function isLeaseActive(lease, nowMs) {
|
|
1366
|
+
return Boolean(lease && lease.expiresAtMs > nowMs);
|
|
1367
|
+
}
|
|
1368
|
+
function pendingMessages(conversation) {
|
|
1369
|
+
return [...conversation.execution.pendingMessages].sort(compareMessages);
|
|
1370
|
+
}
|
|
1371
|
+
function isRunnableStatus(status) {
|
|
1372
|
+
return status !== "failed" && status !== "idle";
|
|
1373
|
+
}
|
|
1374
|
+
function hasRunnableWork(conversation) {
|
|
1375
|
+
return isRunnableStatus(conversation.execution.status) || pendingMessages(conversation).length > 0;
|
|
1376
|
+
}
|
|
1377
|
+
function executionWithPendingMessages(execution, pending) {
|
|
1378
|
+
const pendingMessages = [...pending].sort(compareMessages);
|
|
1379
|
+
const status = execution.status === "idle" && execution.lease ? "running" : execution.status === "idle" && pendingMessages.length > 0 ? "pending" : execution.status;
|
|
1380
|
+
return {
|
|
1381
|
+
...execution,
|
|
1382
|
+
status,
|
|
1383
|
+
inboundMessageIds: uniqueStrings([...execution.inboundMessageIds, ...pendingMessages.map((message) => message.inboundMessageId)]),
|
|
1384
|
+
pendingMessages,
|
|
1385
|
+
pendingCount: pendingMessages.length
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
function withExecutionUpdate(conversation, execution, nowMs) {
|
|
1389
|
+
return {
|
|
1390
|
+
...conversation,
|
|
1391
|
+
updatedAtMs: nowMs,
|
|
1392
|
+
execution: {
|
|
1393
|
+
...executionWithPendingMessages(execution, execution.pendingMessages),
|
|
1394
|
+
updatedAtMs: nowMs
|
|
1395
|
+
}
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
async function getConnectedState(stateAdapter) {
|
|
1399
|
+
const state = stateAdapter ?? getStateAdapter();
|
|
1400
|
+
await state.connect();
|
|
1401
|
+
return state;
|
|
1402
|
+
}
|
|
1403
|
+
async function sleep(ms) {
|
|
1404
|
+
await new Promise((resolve) => {
|
|
1405
|
+
setTimeout(resolve, ms).unref?.();
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
async function withIndexLock(state, indexKey, callback) {
|
|
1409
|
+
const startedAtMs = now();
|
|
1410
|
+
let lock;
|
|
1411
|
+
while (true) {
|
|
1412
|
+
lock = await state.acquireLock(indexLockKey(indexKey), CONVERSATION_INDEX_LOCK_TTL_MS);
|
|
1413
|
+
if (lock) break;
|
|
1414
|
+
if (now() - startedAtMs >= CONVERSATION_INDEX_LOCK_WAIT_MS) throw new Error(`Could not acquire conversation index lock for ${indexKey}`);
|
|
1415
|
+
await sleep(CONVERSATION_INDEX_LOCK_RETRY_MS);
|
|
1416
|
+
}
|
|
1417
|
+
try {
|
|
1418
|
+
return await callback();
|
|
1419
|
+
} finally {
|
|
1420
|
+
await state.releaseLock(lock);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
function normalizeIndexEntry(value) {
|
|
1424
|
+
if (!isRecord(value)) return;
|
|
1425
|
+
const conversationId = toOptionalString(value.conversationId);
|
|
1426
|
+
const score = toOptionalNumber(value.score);
|
|
1427
|
+
if (!conversationId || typeof score !== "number") return;
|
|
1428
|
+
return {
|
|
1429
|
+
conversationId,
|
|
1430
|
+
score
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
function uniqueIndexEntries(value) {
|
|
1434
|
+
if (!Array.isArray(value)) return [];
|
|
1435
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1436
|
+
for (const item of value) {
|
|
1437
|
+
const entry = normalizeIndexEntry(item);
|
|
1438
|
+
if (!entry) continue;
|
|
1439
|
+
const existing = entries.get(entry.conversationId);
|
|
1440
|
+
if (!existing || entry.score > existing.score) entries.set(entry.conversationId, entry);
|
|
1441
|
+
}
|
|
1442
|
+
return [...entries.values()];
|
|
1443
|
+
}
|
|
1444
|
+
function retainedIndexEntries(indexKey, entries) {
|
|
1445
|
+
if (indexKey === "junior:conversation:v2:by-activity") return entries.sort(compareIndexDescending).slice(0, CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH);
|
|
1446
|
+
if (indexKey === "junior:conversation:v2:active") return entries.sort(compareIndexAscending);
|
|
1447
|
+
throw new Error(`Unknown conversation index ${indexKey}`);
|
|
1448
|
+
}
|
|
1449
|
+
function redisIndexKey(indexKey) {
|
|
1450
|
+
const prefix = getChatConfig().state.keyPrefix;
|
|
1451
|
+
return [...prefix ? [prefix] : [], indexKey].join(":");
|
|
1452
|
+
}
|
|
1453
|
+
function parseRedisIndexEntries(values) {
|
|
1454
|
+
if (!Array.isArray(values)) return [];
|
|
1455
|
+
const entries = [];
|
|
1456
|
+
for (let index = 0; index < values.length; index += 2) {
|
|
1457
|
+
const conversationId = toOptionalString(values[index]);
|
|
1458
|
+
const score = typeof values[index + 1] === "number" ? values[index + 1] : Number(values[index + 1]);
|
|
1459
|
+
if (!conversationId || !Number.isFinite(score)) continue;
|
|
1460
|
+
entries.push({
|
|
1461
|
+
conversationId,
|
|
1462
|
+
score
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
return entries;
|
|
1466
|
+
}
|
|
1467
|
+
function redisConversationIndexStore(client) {
|
|
1468
|
+
const upsertBoundedActivityScript = `
|
|
1469
|
+
redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2])
|
|
1470
|
+
redis.call("PEXPIRE", KEYS[1], ARGV[3])
|
|
1471
|
+
local extra = redis.call("ZCARD", KEYS[1]) - tonumber(ARGV[4])
|
|
1472
|
+
if extra > 0 then
|
|
1473
|
+
redis.call("ZREMRANGEBYRANK", KEYS[1], 0, extra - 1)
|
|
1474
|
+
end
|
|
1475
|
+
return 1
|
|
1476
|
+
`;
|
|
1477
|
+
return {
|
|
1478
|
+
async list(args) {
|
|
1479
|
+
const key = redisIndexKey(args.indexKey);
|
|
1480
|
+
const limit = args.limit;
|
|
1481
|
+
const offset = Math.max(0, args.offset ?? 0);
|
|
1482
|
+
if (limit === 0) return [];
|
|
1483
|
+
return parseRedisIndexEntries(args.scoreMax !== void 0 ? await client.sendCommand([
|
|
1484
|
+
"ZRANGEBYSCORE",
|
|
1485
|
+
key,
|
|
1486
|
+
"-inf",
|
|
1487
|
+
String(args.scoreMax),
|
|
1488
|
+
"WITHSCORES",
|
|
1489
|
+
...limit !== void 0 || offset > 0 ? [
|
|
1490
|
+
"LIMIT",
|
|
1491
|
+
String(offset),
|
|
1492
|
+
String(limit ?? 1e9)
|
|
1493
|
+
] : []
|
|
1494
|
+
]) : await client.sendCommand([
|
|
1495
|
+
args.order === "asc" ? "ZRANGE" : "ZREVRANGE",
|
|
1496
|
+
key,
|
|
1497
|
+
String(offset),
|
|
1498
|
+
String(limit === void 0 ? -1 : offset + Math.max(0, limit - 1)),
|
|
1499
|
+
"WITHSCORES"
|
|
1500
|
+
]));
|
|
1501
|
+
},
|
|
1502
|
+
async remove(args) {
|
|
1503
|
+
await client.sendCommand([
|
|
1504
|
+
"ZREM",
|
|
1505
|
+
redisIndexKey(args.indexKey),
|
|
1506
|
+
args.conversationId
|
|
1507
|
+
]);
|
|
1508
|
+
},
|
|
1509
|
+
async upsert(args) {
|
|
1510
|
+
const key = redisIndexKey(args.indexKey);
|
|
1511
|
+
if (args.indexKey === "junior:conversation:v2:by-activity") {
|
|
1512
|
+
await client.sendCommand([
|
|
1513
|
+
"EVAL",
|
|
1514
|
+
upsertBoundedActivityScript,
|
|
1515
|
+
"1",
|
|
1516
|
+
key,
|
|
1517
|
+
String(args.score),
|
|
1518
|
+
args.conversationId,
|
|
1519
|
+
String(JUNIOR_THREAD_STATE_TTL_MS),
|
|
1520
|
+
String(CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH)
|
|
1521
|
+
]);
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
if (args.indexKey === "junior:conversation:v2:active") {
|
|
1525
|
+
await client.sendCommand([
|
|
1526
|
+
"ZADD",
|
|
1527
|
+
key,
|
|
1528
|
+
String(args.score),
|
|
1529
|
+
args.conversationId
|
|
1530
|
+
]);
|
|
1531
|
+
await client.sendCommand([
|
|
1532
|
+
"PEXPIRE",
|
|
1533
|
+
key,
|
|
1534
|
+
String(JUNIOR_THREAD_STATE_TTL_MS)
|
|
1535
|
+
]);
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
throw new Error(`Unknown conversation index ${args.indexKey}`);
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
function emulatedConversationIndexStore(state) {
|
|
1543
|
+
const readIndex = async (indexKey) => uniqueIndexEntries(await state.get(indexKey));
|
|
1544
|
+
const writeIndex = async (indexKey, entries) => {
|
|
1545
|
+
await state.set(indexKey, entries, JUNIOR_THREAD_STATE_TTL_MS);
|
|
1546
|
+
};
|
|
1547
|
+
return {
|
|
1548
|
+
async list(args) {
|
|
1549
|
+
const entries = (await readIndex(args.indexKey)).filter((entry) => args.scoreMax === void 0 ? true : entry.score <= args.scoreMax).sort(args.order === "asc" ? compareIndexAscending : compareIndexDescending);
|
|
1550
|
+
const offset = Math.max(0, args.offset ?? 0);
|
|
1551
|
+
return entries.slice(offset, args.limit === void 0 ? entries.length : offset + args.limit);
|
|
1552
|
+
},
|
|
1553
|
+
async remove(args) {
|
|
1554
|
+
await withIndexLock(state, args.indexKey, async () => {
|
|
1555
|
+
const entries = await readIndex(args.indexKey);
|
|
1556
|
+
const next = entries.filter((entry) => entry.conversationId !== args.conversationId);
|
|
1557
|
+
if (next.length === entries.length) return;
|
|
1558
|
+
await writeIndex(args.indexKey, next);
|
|
1559
|
+
});
|
|
1560
|
+
},
|
|
1561
|
+
async upsert(args) {
|
|
1562
|
+
await withIndexLock(state, args.indexKey, async () => {
|
|
1563
|
+
const withoutCurrent = (await readIndex(args.indexKey)).filter((entry) => entry.conversationId !== args.conversationId);
|
|
1564
|
+
const next = retainedIndexEntries(args.indexKey, [...withoutCurrent, {
|
|
1565
|
+
conversationId: args.conversationId,
|
|
1566
|
+
score: args.score
|
|
1567
|
+
}]);
|
|
1568
|
+
await writeIndex(args.indexKey, next);
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
async function getConversationIndexStore(state) {
|
|
1574
|
+
const redisStateAdapter = await getDefaultRedisStateAdapterFor(state);
|
|
1575
|
+
if (redisStateAdapter) return redisConversationIndexStore(redisStateAdapter.getClient());
|
|
1576
|
+
return emulatedConversationIndexStore(state);
|
|
1577
|
+
}
|
|
1578
|
+
async function upsertIndexEntry(args) {
|
|
1579
|
+
await (await getConversationIndexStore(args.state)).upsert({
|
|
1580
|
+
conversationId: args.conversationId,
|
|
1581
|
+
indexKey: args.indexKey,
|
|
1582
|
+
score: args.score
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
async function removeIndexEntry(args) {
|
|
1586
|
+
await (await getConversationIndexStore(args.state)).remove({
|
|
1587
|
+
conversationId: args.conversationId,
|
|
1588
|
+
indexKey: args.indexKey
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
async function acquireMutationLock(state, conversationId) {
|
|
1592
|
+
const startedAtMs = now();
|
|
1593
|
+
while (true) {
|
|
1594
|
+
const lock = await state.acquireLock(mutationLockKey(conversationId), CONVERSATION_MUTATION_LOCK_TTL_MS);
|
|
1595
|
+
if (lock) return lock;
|
|
1596
|
+
if (now() - startedAtMs >= CONVERSATION_MUTATION_WAIT_MS) throw new Error(`Could not acquire conversation mutation lock for ${conversationId}`);
|
|
1597
|
+
await sleep(CONVERSATION_MUTATION_RETRY_MS);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
async function withConversationMutation(args, callback) {
|
|
1601
|
+
const state = await getConnectedState(args.state);
|
|
1602
|
+
const lock = await acquireMutationLock(state, args.conversationId);
|
|
1603
|
+
try {
|
|
1604
|
+
return await callback(state, lock);
|
|
1605
|
+
} finally {
|
|
1606
|
+
await state.releaseLock(lock);
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
async function readConversation(state, conversationId) {
|
|
1610
|
+
const raw = await state.get(conversationKey(conversationId));
|
|
1611
|
+
if (raw == null) return;
|
|
1612
|
+
const conversation = normalizeConversation(conversationId, raw);
|
|
1613
|
+
if (!conversation) throw new InvalidConversationRecordError(conversationId);
|
|
1614
|
+
return conversation;
|
|
1615
|
+
}
|
|
1616
|
+
/**
|
|
1617
|
+
* Persist a conversation and refresh its reporting and active-recovery indexes.
|
|
1618
|
+
*
|
|
1619
|
+
* Conversation writes are lock-serialized blind whole-record writes, so the
|
|
1620
|
+
* mutation lock is re-validated (and extended) immediately before the write:
|
|
1621
|
+
* a stalled mutator that lost its lock must fail loudly instead of clobbering
|
|
1622
|
+
* a newer record with a stale copy.
|
|
1623
|
+
*/
|
|
1624
|
+
async function writeConversation(state, lock, conversation) {
|
|
1625
|
+
const execution = executionWithPendingMessages(conversation.execution, conversation.execution.pendingMessages);
|
|
1626
|
+
const next = {
|
|
1627
|
+
...conversation,
|
|
1628
|
+
execution
|
|
1629
|
+
};
|
|
1630
|
+
await fenceConversationMutation(state, lock, next.conversationId);
|
|
1631
|
+
const hasSlackDestination = next.destination?.platform === "slack";
|
|
1632
|
+
const stored = {
|
|
1633
|
+
...next,
|
|
1634
|
+
execution: {
|
|
1635
|
+
...next.execution,
|
|
1636
|
+
pendingMessages: next.execution.pendingMessages.map((message) => storeMessage(message, hasSlackDestination && (message.source === "slack" || message.source === "event" || message.source === "plugin" || message.source === "scheduler")))
|
|
1637
|
+
}
|
|
1638
|
+
};
|
|
1639
|
+
await state.set(conversationKey(next.conversationId), stored, JUNIOR_THREAD_STATE_TTL_MS);
|
|
1640
|
+
await upsertIndexEntry({
|
|
1641
|
+
state,
|
|
1642
|
+
indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY,
|
|
1643
|
+
conversationId: next.conversationId,
|
|
1644
|
+
score: next.lastActivityAtMs
|
|
1645
|
+
});
|
|
1646
|
+
if (!hasRunnableWork(next)) {
|
|
1647
|
+
await removeIndexEntry({
|
|
1648
|
+
state,
|
|
1649
|
+
indexKey: CONVERSATION_ACTIVE_INDEX_KEY,
|
|
1650
|
+
conversationId: next.conversationId
|
|
1651
|
+
});
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
await upsertIndexEntry({
|
|
1655
|
+
state,
|
|
1656
|
+
indexKey: CONVERSATION_ACTIVE_INDEX_KEY,
|
|
1657
|
+
conversationId: next.conversationId,
|
|
1658
|
+
score: next.execution.updatedAtMs ?? next.updatedAtMs
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
function assertSameOptionalConversationDestination(args) {
|
|
1662
|
+
if (!args.next || !args.current) return;
|
|
1663
|
+
if (sameDestination(args.current, args.next)) return;
|
|
1664
|
+
throw new Error(`Conversation destination changed for ${args.conversationId}`);
|
|
1665
|
+
}
|
|
1666
|
+
function conversationWorkState(conversation) {
|
|
1667
|
+
const lease = conversation.execution.lease;
|
|
1668
|
+
return {
|
|
1669
|
+
...conversation,
|
|
1670
|
+
lastEnqueuedAtMs: conversation.execution.lastEnqueuedAtMs,
|
|
1671
|
+
...lease ? { lease: {
|
|
1672
|
+
acquiredAtMs: lease.acquiredAtMs,
|
|
1673
|
+
lastCheckInAtMs: lease.lastCheckInAtMs,
|
|
1674
|
+
leaseExpiresAtMs: lease.expiresAtMs,
|
|
1675
|
+
leaseToken: lease.token
|
|
1676
|
+
} } : void 0,
|
|
1677
|
+
messages: pendingMessages(conversation),
|
|
1678
|
+
needsRun: hasRunnableWork(conversation)
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
/** Return a persisted conversation record, if one exists. */
|
|
1682
|
+
async function getConversation(args) {
|
|
1683
|
+
return await readConversation(await getConnectedState(args.state), args.conversationId);
|
|
1684
|
+
}
|
|
1685
|
+
/** Return a persisted conversation work record, if one exists. */
|
|
1686
|
+
async function getConversationWorkState(args) {
|
|
1687
|
+
const conversation = await getConversation(args);
|
|
1688
|
+
return conversation ? conversationWorkState(conversation) : void 0;
|
|
1689
|
+
}
|
|
1690
|
+
/** Return whether a conversation has pending or resumable execution work. */
|
|
1691
|
+
function hasRunnableConversationWork(conversation) {
|
|
1692
|
+
return hasRunnableWork(conversation);
|
|
1693
|
+
}
|
|
1694
|
+
async function appendInboundMessageWithAdmission(args) {
|
|
1695
|
+
const nowMs = args.nowMs ?? now();
|
|
1696
|
+
return await withConversationMutation({
|
|
1697
|
+
conversationId: args.message.conversationId,
|
|
1698
|
+
state: args.state
|
|
1699
|
+
}, async (state, lock) => {
|
|
1700
|
+
const existing = await readConversation(state, args.message.conversationId);
|
|
1701
|
+
const current = existing ?? emptyConversation({
|
|
1702
|
+
conversationId: args.message.conversationId,
|
|
1703
|
+
destination: args.message.destination,
|
|
1704
|
+
nowMs,
|
|
1705
|
+
source: args.message.source
|
|
1706
|
+
});
|
|
1707
|
+
if (existing) assertSameOptionalConversationDestination({
|
|
1708
|
+
conversationId: args.message.conversationId,
|
|
1709
|
+
current: current.destination,
|
|
1710
|
+
next: args.message.destination
|
|
1711
|
+
});
|
|
1712
|
+
const existingPending = current.execution.pendingMessages.some((message) => message.inboundMessageId === args.message.inboundMessageId);
|
|
1713
|
+
if (current.execution.inboundMessageIds.includes(args.message.inboundMessageId)) {
|
|
1714
|
+
if (!existingPending) return { status: "duplicate" };
|
|
1715
|
+
const nextStatus = current.execution.status === "idle" ? "pending" : current.execution.status;
|
|
1716
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
1717
|
+
...current.execution,
|
|
1718
|
+
status: nextStatus,
|
|
1719
|
+
pendingMessages: current.execution.pendingMessages.map((message) => message.inboundMessageId === args.message.inboundMessageId ? upgradedPendingMessage(message, args.message) : message)
|
|
1720
|
+
}, nowMs));
|
|
1721
|
+
return { status: "duplicate" };
|
|
1722
|
+
}
|
|
1723
|
+
if (args.exclusive && hasRunnableWork(current)) return { status: "active" };
|
|
1724
|
+
let status;
|
|
1725
|
+
if (current.execution.lease) status = current.execution.status === "running" ? "running" : "paused";
|
|
1726
|
+
else if (current.execution.status === "paused" && args.message.delivery === "defer") status = "paused";
|
|
1727
|
+
else status = "pending";
|
|
1728
|
+
await writeConversation(state, lock, withExecutionUpdate({
|
|
1729
|
+
...current,
|
|
1730
|
+
destination: current.destination ?? args.message.destination,
|
|
1731
|
+
source: current.source ?? args.message.source,
|
|
1732
|
+
lastActivityAtMs: nowMs
|
|
1733
|
+
}, {
|
|
1734
|
+
...current.execution,
|
|
1735
|
+
status,
|
|
1736
|
+
inboundMessageIds: [...current.execution.inboundMessageIds, args.message.inboundMessageId],
|
|
1737
|
+
pendingMessages: [...current.execution.pendingMessages, args.message].sort(compareMessages)
|
|
1738
|
+
}, nowMs));
|
|
1739
|
+
return { status: "appended" };
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
/** Persist one inbound message idempotently in its conversation mailbox. */
|
|
1743
|
+
async function appendInboundMessage(args) {
|
|
1744
|
+
const result = await appendInboundMessageWithAdmission({
|
|
1745
|
+
...args,
|
|
1746
|
+
exclusive: false
|
|
1747
|
+
});
|
|
1748
|
+
if (result.status === "active") throw new Error("Non-exclusive mailbox append returned active");
|
|
1749
|
+
return result;
|
|
1750
|
+
}
|
|
1751
|
+
/** Append only when the Conversation has no other runnable work. */
|
|
1752
|
+
async function appendExclusiveInboundMessage(args) {
|
|
1753
|
+
return await appendInboundMessageWithAdmission({
|
|
1754
|
+
...args,
|
|
1755
|
+
exclusive: true
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
/** Mark a conversation runnable when there is no new mailbox message. */
|
|
1759
|
+
async function requestConversationWork(args) {
|
|
1760
|
+
const nowMs = args.nowMs ?? now();
|
|
1761
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
1762
|
+
const existing = await readConversation(state, args.conversationId);
|
|
1763
|
+
if (existing) assertSameOptionalConversationDestination({
|
|
1764
|
+
conversationId: args.conversationId,
|
|
1765
|
+
current: existing.destination,
|
|
1766
|
+
next: args.destination
|
|
1767
|
+
});
|
|
1768
|
+
const current = existing ?? emptyConversation({
|
|
1769
|
+
conversationId: args.conversationId,
|
|
1770
|
+
destination: args.destination,
|
|
1771
|
+
nowMs
|
|
1772
|
+
});
|
|
1773
|
+
const status = current.execution.lease ? "paused" : "pending";
|
|
1774
|
+
await writeConversation(state, lock, withExecutionUpdate({
|
|
1775
|
+
...current,
|
|
1776
|
+
destination: current.destination ?? args.destination
|
|
1777
|
+
}, {
|
|
1778
|
+
...current.execution,
|
|
1779
|
+
status
|
|
1780
|
+
}, nowMs));
|
|
1781
|
+
return { status: existing === void 0 ? "created" : "updated" };
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
/** Record that a wake-up nudge was accepted for the conversation. */
|
|
1785
|
+
async function markConversationWorkEnqueued(args) {
|
|
1786
|
+
const nowMs = args.nowMs ?? now();
|
|
1787
|
+
await withConversationMutation(args, async (state, lock) => {
|
|
1788
|
+
const current = await readConversation(state, args.conversationId);
|
|
1789
|
+
if (!current) return;
|
|
1790
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
1791
|
+
...current.execution,
|
|
1792
|
+
lastEnqueuedAtMs: nowMs
|
|
1793
|
+
}, nowMs));
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
/** Clear a wake marker after its queue delivery finds no runnable work. */
|
|
1797
|
+
async function clearConsumedConversationWake(args) {
|
|
1798
|
+
const nowMs = args.nowMs ?? now();
|
|
1799
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
1800
|
+
const current = await readConversation(state, args.conversationId);
|
|
1801
|
+
if (!current || hasRunnableWork(current) || current.execution.lastEnqueuedAtMs === void 0) return false;
|
|
1802
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
1803
|
+
...current.execution,
|
|
1804
|
+
lastEnqueuedAtMs: void 0
|
|
1805
|
+
}, nowMs));
|
|
1806
|
+
return true;
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
/** Try to acquire the durable execution lease for one conversation. */
|
|
1810
|
+
async function startConversationWork(args) {
|
|
1811
|
+
const nowMs = args.nowMs ?? now();
|
|
1812
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
1813
|
+
const current = await readConversation(state, args.conversationId);
|
|
1814
|
+
if (!current) return { status: "no_work" };
|
|
1815
|
+
if (isLeaseActive(current.execution.lease, nowMs)) return {
|
|
1816
|
+
status: "active",
|
|
1817
|
+
leaseExpiresAtMs: current.execution.lease.expiresAtMs
|
|
1818
|
+
};
|
|
1819
|
+
if (!hasRunnableWork(current)) return { status: "no_work" };
|
|
1820
|
+
const lease = {
|
|
1821
|
+
token: randomUUID(),
|
|
1822
|
+
acquiredAtMs: nowMs,
|
|
1823
|
+
lastCheckInAtMs: nowMs,
|
|
1824
|
+
expiresAtMs: nowMs + CONVERSATION_WORK_LEASE_TTL_MS
|
|
1825
|
+
};
|
|
1826
|
+
const startsNewRun = current.execution.runId === void 0;
|
|
1827
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
1828
|
+
...current.execution,
|
|
1829
|
+
lease,
|
|
1830
|
+
status: current.execution.status === "paused" ? "paused" : "running",
|
|
1831
|
+
runId: current.execution.runId ?? randomUUID(),
|
|
1832
|
+
lastEnqueuedAtMs: void 0,
|
|
1833
|
+
retryCount: startsNewRun ? 0 : current.execution.retryCount
|
|
1834
|
+
}, nowMs));
|
|
1835
|
+
return {
|
|
1836
|
+
status: "acquired",
|
|
1837
|
+
leaseToken: lease.token,
|
|
1838
|
+
leaseExpiresAtMs: lease.expiresAtMs
|
|
1839
|
+
};
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
/** Extend the durable execution lease when the worker checks in. */
|
|
1843
|
+
async function checkInConversationWork(args) {
|
|
1844
|
+
const nowMs = args.nowMs ?? now();
|
|
1845
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
1846
|
+
const current = await readConversation(state, args.conversationId);
|
|
1847
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) return false;
|
|
1848
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
1849
|
+
...current.execution,
|
|
1850
|
+
lease: {
|
|
1851
|
+
...current.execution.lease,
|
|
1852
|
+
lastCheckInAtMs: nowMs,
|
|
1853
|
+
expiresAtMs: nowMs + CONVERSATION_WORK_LEASE_TTL_MS
|
|
1854
|
+
}
|
|
1855
|
+
}, nowMs));
|
|
1856
|
+
return true;
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* Resolve pending mailbox entries after the caller accepts responsibility.
|
|
1861
|
+
*
|
|
1862
|
+
* Returning ids acknowledges only that subset; returning nothing acknowledges
|
|
1863
|
+
* every pending entry passed to the handler.
|
|
1864
|
+
*/
|
|
1865
|
+
async function drainConversationMailbox(args) {
|
|
1866
|
+
const nowMs = args.nowMs ?? now();
|
|
1867
|
+
const pending = await withConversationMutation(args, async (state) => {
|
|
1868
|
+
const current = await readConversation(state, args.conversationId);
|
|
1869
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) throw new Error(`Conversation lease is not held for ${args.conversationId}`);
|
|
1870
|
+
return pendingMessages(current);
|
|
1871
|
+
});
|
|
1872
|
+
if (pending.length === 0) return {
|
|
1873
|
+
changed: false,
|
|
1874
|
+
messages: []
|
|
1875
|
+
};
|
|
1876
|
+
const result = await args.handle(pending);
|
|
1877
|
+
const pendingIds = new Set(pending.map((message) => message.inboundMessageId));
|
|
1878
|
+
const acknowledgedIds = new Set(result ?? pending.map((message) => message.inboundMessageId));
|
|
1879
|
+
for (const inboundMessageId of acknowledgedIds) if (!pendingIds.has(inboundMessageId)) throw new Error(`Conversation mailbox drain result is not pending for ${args.conversationId}`);
|
|
1880
|
+
if (acknowledgedIds.size === 0) return {
|
|
1881
|
+
changed: false,
|
|
1882
|
+
messages: []
|
|
1883
|
+
};
|
|
1884
|
+
await withConversationMutation(args, async (state, lock) => {
|
|
1885
|
+
const current = await readConversation(state, args.conversationId);
|
|
1886
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) throw new Error(`Conversation lease is not held for ${args.conversationId}`);
|
|
1887
|
+
const pendingMessages = current.execution.pendingMessages.filter((message) => !acknowledgedIds.has(message.inboundMessageId));
|
|
1888
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
1889
|
+
...current.execution,
|
|
1890
|
+
status: current.execution.status === "pending" && pendingMessages.length === 0 ? "running" : current.execution.status,
|
|
1891
|
+
lastProgressAtMs: nowMs,
|
|
1892
|
+
retryCount: 0,
|
|
1893
|
+
pendingMessages
|
|
1894
|
+
}, nowMs));
|
|
1895
|
+
});
|
|
1896
|
+
return {
|
|
1897
|
+
changed: true,
|
|
1898
|
+
messages: pending.filter((message) => acknowledgedIds.has(message.inboundMessageId))
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
/** Acknowledge leased mailbox entries after the handler accepts responsibility. */
|
|
1902
|
+
async function ackMessages(args) {
|
|
1903
|
+
const nowMs = args.nowMs ?? now();
|
|
1904
|
+
const inboundMessageIds = new Set(args.inboundMessageIds);
|
|
1905
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
1906
|
+
const current = await readConversation(state, args.conversationId);
|
|
1907
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) return false;
|
|
1908
|
+
if (inboundMessageIds.size === 0) return true;
|
|
1909
|
+
const pendingMessages = current.execution.pendingMessages.filter((message) => !inboundMessageIds.has(message.inboundMessageId));
|
|
1910
|
+
if (pendingMessages.length === current.execution.pendingMessages.length) return true;
|
|
1911
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
1912
|
+
...current.execution,
|
|
1913
|
+
lastProgressAtMs: nowMs,
|
|
1914
|
+
retryCount: 0,
|
|
1915
|
+
pendingMessages
|
|
1916
|
+
}, nowMs));
|
|
1917
|
+
return true;
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
function isHumanFacingMessage(message) {
|
|
1921
|
+
return message.source === "web" || message.source === "slack";
|
|
1922
|
+
}
|
|
1923
|
+
async function readConversationStop(state, conversationId) {
|
|
1924
|
+
return normalizeStop(await state.get(conversationStopKey(conversationId)));
|
|
1925
|
+
}
|
|
1926
|
+
async function fenceConversationMutation(state, lock, conversationId) {
|
|
1927
|
+
if (!await state.extendLock(lock, CONVERSATION_MUTATION_LOCK_TTL_MS)) throw new ConversationMutationFencedError(conversationId);
|
|
1928
|
+
}
|
|
1929
|
+
/** Return whether one Conversation run has a durable stop request. */
|
|
1930
|
+
async function hasConversationStop(args) {
|
|
1931
|
+
return (await readConversationStop(await getConnectedState(args.state), args.conversationId))?.runId === args.runId;
|
|
1932
|
+
}
|
|
1933
|
+
/** Persist a stop request for the current run without process affinity. */
|
|
1934
|
+
async function stopConversationWork(args) {
|
|
1935
|
+
const nowMs = args.nowMs ?? now();
|
|
1936
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
1937
|
+
const current = await readConversation(state, args.conversationId);
|
|
1938
|
+
if (!current || !hasRunnableWork(current)) return { status: "no_work" };
|
|
1939
|
+
const runId = current.execution.runId ?? randomUUID();
|
|
1940
|
+
const pendingMessages = current.execution.pendingMessages.filter(isHumanFacingMessage);
|
|
1941
|
+
const inboundMessageIds = pendingMessages.map((message) => message.inboundMessageId);
|
|
1942
|
+
if (current.execution.runId === void 0) await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
1943
|
+
...current.execution,
|
|
1944
|
+
runId
|
|
1945
|
+
}, nowMs));
|
|
1946
|
+
await fenceConversationMutation(state, lock, args.conversationId);
|
|
1947
|
+
await state.set(conversationStopKey(args.conversationId), {
|
|
1948
|
+
inboundMessageIds,
|
|
1949
|
+
runId
|
|
1950
|
+
}, JUNIOR_THREAD_STATE_TTL_MS);
|
|
1951
|
+
return {
|
|
1952
|
+
runId,
|
|
1953
|
+
status: "requested",
|
|
1954
|
+
pendingMessages
|
|
1955
|
+
};
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
/** Clear one observed stop request and discard older human-facing mailbox work. */
|
|
1959
|
+
async function completeConversationStop(args) {
|
|
1960
|
+
const nowMs = args.nowMs ?? now();
|
|
1961
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
1962
|
+
const current = await readConversation(state, args.conversationId);
|
|
1963
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) return {
|
|
1964
|
+
status: "lost_lease",
|
|
1965
|
+
removedInboundMessageIds: []
|
|
1966
|
+
};
|
|
1967
|
+
const stop = await readConversationStop(state, args.conversationId);
|
|
1968
|
+
if (!stop || stop.runId !== args.runId) return {
|
|
1969
|
+
status: "none",
|
|
1970
|
+
removedInboundMessageIds: []
|
|
1971
|
+
};
|
|
1972
|
+
const stoppedInboundMessageIds = new Set(stop.inboundMessageIds);
|
|
1973
|
+
const removedInboundMessageIds = [];
|
|
1974
|
+
const pendingMessages = current.execution.pendingMessages.filter((message) => {
|
|
1975
|
+
const shouldStop = stoppedInboundMessageIds.has(message.inboundMessageId);
|
|
1976
|
+
if (shouldStop) removedInboundMessageIds.push(message.inboundMessageId);
|
|
1977
|
+
return !shouldStop;
|
|
1978
|
+
});
|
|
1979
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
1980
|
+
...current.execution,
|
|
1981
|
+
lastEnqueuedAtMs: pendingMessages.length === 0 ? void 0 : current.execution.lastEnqueuedAtMs,
|
|
1982
|
+
pendingMessages
|
|
1983
|
+
}, nowMs));
|
|
1984
|
+
await fenceConversationMutation(state, lock, args.conversationId);
|
|
1985
|
+
await state.delete(conversationStopKey(args.conversationId));
|
|
1986
|
+
return {
|
|
1987
|
+
status: "cleared",
|
|
1988
|
+
removedInboundMessageIds
|
|
1989
|
+
};
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
/** Cancel human-facing pending mailbox rows without requiring a worker lease. */
|
|
1993
|
+
async function cancelHumanFacingPendingMessages(args) {
|
|
1994
|
+
const nowMs = args.nowMs ?? now();
|
|
1995
|
+
const requestedIds = args.inboundMessageIds === void 0 ? void 0 : new Set(args.inboundMessageIds);
|
|
1996
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
1997
|
+
const current = await readConversation(state, args.conversationId);
|
|
1998
|
+
if (!current) return {
|
|
1999
|
+
cancelledInboundMessageIds: [],
|
|
2000
|
+
cancelledMessages: []
|
|
2001
|
+
};
|
|
2002
|
+
const cancelledInboundMessageIds = [];
|
|
2003
|
+
const cancelledMessages = [];
|
|
2004
|
+
const pendingMessages = [];
|
|
2005
|
+
for (const message of current.execution.pendingMessages) {
|
|
2006
|
+
const isRequested = requestedIds === void 0 || requestedIds.has(message.inboundMessageId);
|
|
2007
|
+
const isInSnapshot = args.receivedBeforeMs === void 0 || message.receivedAtMs <= args.receivedBeforeMs;
|
|
2008
|
+
if (isHumanFacingMessage(message) && isRequested && isInSnapshot) {
|
|
2009
|
+
cancelledInboundMessageIds.push(message.inboundMessageId);
|
|
2010
|
+
cancelledMessages.push(message);
|
|
2011
|
+
continue;
|
|
2012
|
+
}
|
|
2013
|
+
pendingMessages.push(message);
|
|
2014
|
+
}
|
|
2015
|
+
if (cancelledInboundMessageIds.length === 0) return {
|
|
2016
|
+
cancelledInboundMessageIds,
|
|
2017
|
+
cancelledMessages
|
|
2018
|
+
};
|
|
2019
|
+
const becomesIdle = current.execution.status === "pending" && pendingMessages.length === 0;
|
|
2020
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
2021
|
+
...current.execution,
|
|
2022
|
+
lastEnqueuedAtMs: pendingMessages.length === 0 ? void 0 : current.execution.lastEnqueuedAtMs,
|
|
2023
|
+
pendingMessages,
|
|
2024
|
+
retryCount: becomesIdle ? 0 : current.execution.retryCount,
|
|
2025
|
+
runId: becomesIdle ? void 0 : current.execution.runId,
|
|
2026
|
+
status: becomesIdle ? "idle" : current.execution.status
|
|
2027
|
+
}, nowMs));
|
|
2028
|
+
return {
|
|
2029
|
+
cancelledInboundMessageIds,
|
|
2030
|
+
cancelledMessages
|
|
2031
|
+
};
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Mark one author's queued web messages as interrupts without a worker lease.
|
|
2036
|
+
*
|
|
2037
|
+
* The active Turn drains them at its next model boundary when it belongs to the
|
|
2038
|
+
* same author. Otherwise they start the next Turn ahead of deferred input.
|
|
2039
|
+
*/
|
|
2040
|
+
async function steerWebPendingMessages(args) {
|
|
2041
|
+
const nowMs = args.nowMs ?? now();
|
|
2042
|
+
const requestedIds = new Set(args.inboundMessageIds);
|
|
2043
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2044
|
+
const current = await readConversation(state, args.conversationId);
|
|
2045
|
+
if (!current) return { steeredInboundMessageIds: [] };
|
|
2046
|
+
const steeredInboundMessageIds = [];
|
|
2047
|
+
const pendingMessages = current.execution.pendingMessages.map((message) => {
|
|
2048
|
+
if (message.source !== "web" || message.delivery !== "defer" || message.input.authorId !== args.authorId || !requestedIds.has(message.inboundMessageId)) return message;
|
|
2049
|
+
steeredInboundMessageIds.push(message.inboundMessageId);
|
|
2050
|
+
return {
|
|
2051
|
+
...message,
|
|
2052
|
+
delivery: "interrupt"
|
|
2053
|
+
};
|
|
2054
|
+
});
|
|
2055
|
+
if (steeredInboundMessageIds.length === 0) return { steeredInboundMessageIds };
|
|
2056
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
2057
|
+
...current.execution,
|
|
2058
|
+
pendingMessages
|
|
2059
|
+
}, nowMs));
|
|
2060
|
+
return { steeredInboundMessageIds };
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
/** Mark the leased conversation as needing another queue-delivered slice. */
|
|
2064
|
+
async function requestAnotherSlice(args) {
|
|
2065
|
+
const nowMs = args.nowMs ?? now();
|
|
2066
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2067
|
+
const current = await readConversation(state, args.conversationId);
|
|
2068
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) return false;
|
|
2069
|
+
assertSameOptionalConversationDestination({
|
|
2070
|
+
conversationId: args.conversationId,
|
|
2071
|
+
current: current.destination,
|
|
2072
|
+
next: args.destination
|
|
2073
|
+
});
|
|
2074
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
2075
|
+
...current.execution,
|
|
2076
|
+
status: "paused"
|
|
2077
|
+
}, nowMs));
|
|
2078
|
+
return true;
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
/** Begin a requested turn resume under the worker's existing lease. */
|
|
2082
|
+
async function beginConversationResume(args) {
|
|
2083
|
+
const nowMs = args.nowMs ?? now();
|
|
2084
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2085
|
+
const current = await readConversation(state, args.conversationId);
|
|
2086
|
+
if (!current || current.execution.lease?.token !== args.leaseToken || current.execution.status !== "paused") return false;
|
|
2087
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
2088
|
+
...current.execution,
|
|
2089
|
+
status: "running"
|
|
2090
|
+
}, nowMs));
|
|
2091
|
+
return true;
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
/** Release the durable execution lease without changing completion state. */
|
|
2095
|
+
async function releaseConversationWork(args) {
|
|
2096
|
+
const nowMs = args.nowMs ?? now();
|
|
2097
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2098
|
+
const current = await readConversation(state, args.conversationId);
|
|
2099
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) return false;
|
|
2100
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
2101
|
+
...current.execution,
|
|
2102
|
+
lease: void 0,
|
|
2103
|
+
status: current.execution.status === "running" ? "pending" : current.execution.status
|
|
2104
|
+
}, nowMs));
|
|
2105
|
+
return true;
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
/** Count one failed execution attempt and stop the conversation when retries are exhausted. */
|
|
2109
|
+
async function recordConversationRetry(args) {
|
|
2110
|
+
const nowMs = args.nowMs ?? now();
|
|
2111
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2112
|
+
const current = await readConversation(state, args.conversationId);
|
|
2113
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) return "lost_lease";
|
|
2114
|
+
const count = (current.execution.retryCount ?? 0) + 1;
|
|
2115
|
+
const stopped = count >= 5;
|
|
2116
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
2117
|
+
...current.execution,
|
|
2118
|
+
lastEnqueuedAtMs: void 0,
|
|
2119
|
+
lease: stopped ? void 0 : current.execution.lease,
|
|
2120
|
+
retryCount: count,
|
|
2121
|
+
pendingMessages: stopped ? [] : current.execution.pendingMessages,
|
|
2122
|
+
runId: stopped ? void 0 : current.execution.runId,
|
|
2123
|
+
status: stopped ? "failed" : "paused"
|
|
2124
|
+
}, nowMs));
|
|
2125
|
+
return stopped ? "stopped" : "recorded";
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2128
|
+
/** Finish a leased conversation and report whether runnable work remains. */
|
|
2129
|
+
async function completeConversationWork(args) {
|
|
2130
|
+
const nowMs = args.nowMs ?? now();
|
|
2131
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2132
|
+
const current = await readConversation(state, args.conversationId);
|
|
2133
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) return "lost_lease";
|
|
2134
|
+
const hasPending = pendingMessages(current).length > 0;
|
|
2135
|
+
const stopped = args.resumeIfStopped === true && (await readConversationStop(state, args.conversationId))?.runId === current.execution.runId;
|
|
2136
|
+
const needsRun = current.execution.status === "paused" || stopped;
|
|
2137
|
+
const runnable = needsRun || hasPending;
|
|
2138
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
2139
|
+
...current.execution,
|
|
2140
|
+
lease: void 0,
|
|
2141
|
+
status: needsRun ? "paused" : hasPending ? "pending" : "idle",
|
|
2142
|
+
lastProgressAtMs: args.madeProgress === false ? current.execution.lastProgressAtMs : nowMs,
|
|
2143
|
+
retryCount: runnable || args.madeProgress === false ? current.execution.retryCount : 0,
|
|
2144
|
+
runId: runnable ? current.execution.runId : void 0
|
|
2145
|
+
}, nowMs));
|
|
2146
|
+
return runnable ? "pending" : "completed";
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* Record one failed execution attempt for pending messages, dead-lettering
|
|
2151
|
+
* the conversation when its retry budget or the legacy message delivery limit
|
|
2152
|
+
* is exhausted.
|
|
2153
|
+
*
|
|
2154
|
+
* Retries are counted only when the run made no durable progress: if any
|
|
2155
|
+
* attempted message left the mailbox, the remaining pending entries may be
|
|
2156
|
+
* deliberate deferrals and are left untouched. Consumed message ids stay in
|
|
2157
|
+
* `inboundMessageIds` so source retries remain duplicates.
|
|
2158
|
+
*/
|
|
2159
|
+
async function recordAttemptFailure(args) {
|
|
2160
|
+
const nowMs = args.nowMs ?? now();
|
|
2161
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2162
|
+
const current = await readConversation(state, args.conversationId);
|
|
2163
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) return {
|
|
2164
|
+
status: "lost_lease",
|
|
2165
|
+
pendingCount: 0,
|
|
2166
|
+
deadLetteredMessages: [],
|
|
2167
|
+
retryCount: 0
|
|
2168
|
+
};
|
|
2169
|
+
const pendingIds = new Set(current.execution.pendingMessages.map((message) => message.inboundMessageId));
|
|
2170
|
+
if (args.inboundMessageIds.length === 0 || args.inboundMessageIds.some((id) => !pendingIds.has(id))) return {
|
|
2171
|
+
status: "skipped",
|
|
2172
|
+
pendingCount: current.execution.pendingMessages.length,
|
|
2173
|
+
deadLetteredMessages: [],
|
|
2174
|
+
retryCount: current.execution.retryCount ?? 0
|
|
2175
|
+
};
|
|
2176
|
+
const retryCount = (current.execution.retryCount ?? 0) + 1;
|
|
2177
|
+
const retriesExhausted = retryCount >= 5;
|
|
2178
|
+
const attemptedIds = new Set(args.inboundMessageIds);
|
|
2179
|
+
const deadLetteredMessages = [];
|
|
2180
|
+
const pendingMessages = [];
|
|
2181
|
+
for (const message of current.execution.pendingMessages) {
|
|
2182
|
+
if (!attemptedIds.has(message.inboundMessageId)) {
|
|
2183
|
+
if (retriesExhausted) deadLetteredMessages.push(message);
|
|
2184
|
+
else pendingMessages.push(message);
|
|
2185
|
+
continue;
|
|
2186
|
+
}
|
|
2187
|
+
const attempted = {
|
|
2188
|
+
...message,
|
|
2189
|
+
attemptCount: (message.attemptCount ?? 0) + 1
|
|
2190
|
+
};
|
|
2191
|
+
if (retriesExhausted || attempted.attemptCount >= 5) {
|
|
2192
|
+
deadLetteredMessages.push(attempted);
|
|
2193
|
+
continue;
|
|
2194
|
+
}
|
|
2195
|
+
pendingMessages.push(attempted);
|
|
2196
|
+
}
|
|
2197
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
2198
|
+
...current.execution,
|
|
2199
|
+
retryCount,
|
|
2200
|
+
pendingMessages
|
|
2201
|
+
}, nowMs));
|
|
2202
|
+
return {
|
|
2203
|
+
status: "recorded",
|
|
2204
|
+
pendingCount: pendingMessages.length,
|
|
2205
|
+
deadLetteredMessages,
|
|
2206
|
+
retryCount
|
|
2207
|
+
};
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
/** Record a terminal failure completion for a leased conversation. */
|
|
2211
|
+
async function deadLetterAttempt(args) {
|
|
2212
|
+
const nowMs = args.nowMs ?? now();
|
|
2213
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2214
|
+
const current = await readConversation(state, args.conversationId);
|
|
2215
|
+
if (!current || current.execution.lease?.token !== args.leaseToken) return "lost_lease";
|
|
2216
|
+
const runnable = pendingMessages(current).length > 0;
|
|
2217
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
2218
|
+
...current.execution,
|
|
2219
|
+
lease: void 0,
|
|
2220
|
+
status: runnable ? "pending" : "failed",
|
|
2221
|
+
runId: runnable ? current.execution.runId : void 0
|
|
2222
|
+
}, nowMs));
|
|
2223
|
+
return runnable ? "pending" : "failed";
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
/** Recover an expired lease, consuming the retry budget under the mutation lock. */
|
|
2227
|
+
async function clearExpiredConversationLease(args) {
|
|
2228
|
+
const nowMs = args.nowMs ?? now();
|
|
2229
|
+
return await withConversationMutation(args, async (state, lock) => {
|
|
2230
|
+
const current = await readConversation(state, args.conversationId);
|
|
2231
|
+
if (!current?.execution.lease || current.execution.lease.expiresAtMs > nowMs) return "not_expired";
|
|
2232
|
+
const retryCount = (current.execution.retryCount ?? 0) + 1;
|
|
2233
|
+
const stopped = retryCount >= 5;
|
|
2234
|
+
await writeConversation(state, lock, withExecutionUpdate(current, {
|
|
2235
|
+
...current.execution,
|
|
2236
|
+
lastEnqueuedAtMs: void 0,
|
|
2237
|
+
lease: void 0,
|
|
2238
|
+
retryCount,
|
|
2239
|
+
pendingMessages: stopped ? [] : current.execution.pendingMessages,
|
|
2240
|
+
runId: stopped ? void 0 : current.execution.runId,
|
|
2241
|
+
status: stopped ? "failed" : "paused"
|
|
2242
|
+
}, nowMs));
|
|
2243
|
+
return stopped ? "stopped" : "requeued";
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
/** Remove one conversation from the active index after it is missing or idle. */
|
|
2247
|
+
async function removeActiveConversation(args) {
|
|
2248
|
+
await removeIndexEntry({
|
|
2249
|
+
state: await getConnectedState(args.state),
|
|
2250
|
+
indexKey: CONVERSATION_ACTIVE_INDEX_KEY,
|
|
2251
|
+
conversationId: args.conversationId
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
/** List active conversation ids by oldest execution update first. */
|
|
2255
|
+
async function listActiveConversationIds(args = {}) {
|
|
2256
|
+
return (await (await getConversationIndexStore(await getConnectedState(args.state))).list({
|
|
2257
|
+
indexKey: CONVERSATION_ACTIVE_INDEX_KEY,
|
|
2258
|
+
limit: args.limit,
|
|
2259
|
+
order: "asc",
|
|
2260
|
+
scoreMax: args.staleBeforeMs
|
|
2261
|
+
})).map((entry) => entry.conversationId);
|
|
2262
|
+
}
|
|
1108
2263
|
// src/chat/task-execution/turn-cursor-keys.ts
|
|
1109
2264
|
/** Redis namespace for the current turn cursor format. */
|
|
1110
2265
|
const TURN_CURSOR_PREFIX = "junior:turn_cursor:v2";
|
|
@@ -1216,7 +2371,7 @@ const storedTurnRecordSchema = z.object({
|
|
|
1216
2371
|
startedAtMs: nonNegativeNumberSchema,
|
|
1217
2372
|
state: turnStatusSchema,
|
|
1218
2373
|
surface: turnSurfaceSchema.optional(),
|
|
1219
|
-
|
|
2374
|
+
traceContext: traceContextSchema,
|
|
1220
2375
|
updatedAtMs: nonNegativeNumberSchema,
|
|
1221
2376
|
committedSeq: seqCursorSchema,
|
|
1222
2377
|
historyVersion: z.number().int().nonnegative().optional(),
|
|
@@ -1331,7 +2486,7 @@ function materializeTurnRecord(stored, piProjection, turnStartMessageIndex, runt
|
|
|
1331
2486
|
resultMessageId: stored.resultMessageId,
|
|
1332
2487
|
resumedFromSliceId: stored.resumedFromSliceId,
|
|
1333
2488
|
surface: stored.surface,
|
|
1334
|
-
|
|
2489
|
+
traceContext: stored.traceContext,
|
|
1335
2490
|
turnStartMessageIndex
|
|
1336
2491
|
})
|
|
1337
2492
|
};
|
|
@@ -1404,7 +2559,7 @@ function buildStoredRecord(args) {
|
|
|
1404
2559
|
resultMessageId: args.resultMessageId,
|
|
1405
2560
|
resumedFromSliceId: args.resumedFromSliceId,
|
|
1406
2561
|
surface: args.surface,
|
|
1407
|
-
|
|
2562
|
+
traceContext: args.traceContext,
|
|
1408
2563
|
turnStartSeq: args.turnStartSeq
|
|
1409
2564
|
})
|
|
1410
2565
|
};
|
|
@@ -1482,7 +2637,7 @@ async function updateTurnState(args) {
|
|
|
1482
2637
|
resultMessageId: args.resultMessageId ?? args.existing.resultMessageId,
|
|
1483
2638
|
resumedFromSliceId: args.existing.resumedFromSliceId,
|
|
1484
2639
|
surface: args.existing.surface,
|
|
1485
|
-
|
|
2640
|
+
traceContext: args.existing.traceContext,
|
|
1486
2641
|
turnStartSeq: parsed.turnStartSeq
|
|
1487
2642
|
})
|
|
1488
2643
|
})
|
|
@@ -1559,7 +2714,7 @@ async function upsertTurnRecordLocked(args, fence) {
|
|
|
1559
2714
|
resumedFromSliceId: args.resumedFromSliceId,
|
|
1560
2715
|
startedAtMs: existingRecord?.startedAtMs,
|
|
1561
2716
|
surface: args.surface ?? existingRecord?.surface,
|
|
1562
|
-
|
|
2717
|
+
traceContext: args.traceContext ?? existingRecord?.traceContext,
|
|
1563
2718
|
turnStartSeq
|
|
1564
2719
|
})
|
|
1565
2720
|
})
|
|
@@ -1728,7 +2883,7 @@ function sharedWrite(args, latest) {
|
|
|
1728
2883
|
dispatchId: args.dispatchId ?? latest?.dispatchId,
|
|
1729
2884
|
source: args.source,
|
|
1730
2885
|
surface: args.surface ?? latest?.surface,
|
|
1731
|
-
|
|
2886
|
+
traceContext: latest?.traceContext ?? getActiveTraceContext(),
|
|
1732
2887
|
turnStartMessageIndex: args.turnStartMessageIndex ?? latest?.turnStartMessageIndex,
|
|
1733
2888
|
cumulativeToolCallCount: args.cumulativeToolCallCount ?? latest?.cumulativeToolCallCount,
|
|
1734
2889
|
turnLimitWrappedUp: args.turnLimitWrappedUp ?? latest?.turnLimitWrappedUp,
|
|
@@ -3265,6 +4420,138 @@ async function recordGuardianActionReviewed(args) {
|
|
|
3265
4420
|
createdAtMs: Date.now()
|
|
3266
4421
|
}]);
|
|
3267
4422
|
}
|
|
4423
|
+
// src/chat/events/catalog.ts
|
|
4424
|
+
/** A source with nothing enabled, for a namespace that the catalog lacks. */
|
|
4425
|
+
const NO_SOURCE = {
|
|
4426
|
+
resourceTypes: [],
|
|
4427
|
+
events: {},
|
|
4428
|
+
anyResourceFields: {}
|
|
4429
|
+
};
|
|
4430
|
+
/**
|
|
4431
|
+
* Build one catalog entry. It merges the filters of every resource type here,
|
|
4432
|
+
* once, so trigger checks for each event copy and each row do not merge them
|
|
4433
|
+
* again.
|
|
4434
|
+
*/
|
|
4435
|
+
function eventCatalogEntry(registration) {
|
|
4436
|
+
return {
|
|
4437
|
+
...registration,
|
|
4438
|
+
anyResourceFields: anyResourceMatchFields(registration.resourceTypes)
|
|
4439
|
+
};
|
|
4440
|
+
}
|
|
4441
|
+
/** Core-owned event namespace. Not a plugin publisher. */
|
|
4442
|
+
const CORE_EVENT_NAMESPACE = "junior";
|
|
4443
|
+
/** True when the catalog has any enabled registration. */
|
|
4444
|
+
function hasEventCatalogEntries(catalog) {
|
|
4445
|
+
return Object.keys(catalog).length > 0;
|
|
4446
|
+
}
|
|
4447
|
+
/**
|
|
4448
|
+
* Catalog entries that durable event automations may select.
|
|
4449
|
+
*
|
|
4450
|
+
* Resource types with `automation: false` stay out of create/update schemas
|
|
4451
|
+
* and trigger availability. A namespace with no such resource type left is
|
|
4452
|
+
* removed.
|
|
4453
|
+
*/
|
|
4454
|
+
function automationEventCatalog(catalog) {
|
|
4455
|
+
return Object.fromEntries(Object.entries(catalog).flatMap(([namespace, registration]) => {
|
|
4456
|
+
const resourceTypes = registration.resourceTypes.filter((resourceType) => registration.limits?.[resourceType.type]?.automation !== false);
|
|
4457
|
+
if (resourceTypes.length === 0) return [];
|
|
4458
|
+
if (resourceTypes.length === registration.resourceTypes.length) return [[namespace, registration]];
|
|
4459
|
+
const supported = new Set(resourceTypes.flatMap((resourceType) => resourceType.supportedEvents));
|
|
4460
|
+
return [[namespace, eventCatalogEntry({
|
|
4461
|
+
...registration,
|
|
4462
|
+
resourceTypes,
|
|
4463
|
+
events: Object.fromEntries(Object.entries(registration.events).filter(([eventType]) => supported.has(eventType)))
|
|
4464
|
+
})]];
|
|
4465
|
+
}));
|
|
4466
|
+
}
|
|
4467
|
+
/** Return why watchEvents cannot watch one resource type, if it cannot. */
|
|
4468
|
+
function eventWatchUnavailable(catalog, namespace, resourceType) {
|
|
4469
|
+
const limits = catalog[namespace]?.limits?.[resourceType];
|
|
4470
|
+
return limits?.watch === false ? limits.watchUnavailableMessage : void 0;
|
|
4471
|
+
}
|
|
4472
|
+
function enumSchema(values, unavailableMessage) {
|
|
4473
|
+
if (values.length === 0) return z.string().refine(() => false, unavailableMessage);
|
|
4474
|
+
return z.enum(values);
|
|
4475
|
+
}
|
|
4476
|
+
/** Build the model-facing namespace enum from enabled event plugins. */
|
|
4477
|
+
function eventNamespaceSchema(catalog) {
|
|
4478
|
+
return enumSchema(Object.keys(catalog).sort(), "No event namespaces are enabled.");
|
|
4479
|
+
}
|
|
4480
|
+
/** Build the model-facing event enum from enabled plugin registrations. */
|
|
4481
|
+
function registeredEventTypeSchema(catalog) {
|
|
4482
|
+
return enumSchema([...new Set(Object.values(catalog).flatMap((registration) => registration.resourceTypes.flatMap((resourceType) => resourceType.supportedEvents)))].sort(), "No event types are enabled.");
|
|
4483
|
+
}
|
|
4484
|
+
/** Build the model-facing resource type enum from enabled plugin registrations. */
|
|
4485
|
+
function registeredResourceTypeSchema(catalog) {
|
|
4486
|
+
return enumSchema([...new Set(Object.values(catalog).flatMap((registration) => registration.resourceTypes.map((resourceType) => resourceType.type)))].sort(), "No resource types are enabled.");
|
|
4487
|
+
}
|
|
4488
|
+
/**
|
|
4489
|
+
* Return app guidance for one event of a namespace. The source declares it
|
|
4490
|
+
* once for the event, so every resource type and a trigger with no resource
|
|
4491
|
+
* type get the same guidance.
|
|
4492
|
+
*/
|
|
4493
|
+
function eventGuidance(catalog, namespace, eventType) {
|
|
4494
|
+
return catalog[namespace]?.events[eventType]?.guidance;
|
|
4495
|
+
}
|
|
4496
|
+
/** Build the match object the tools accept for one resource type. */
|
|
4497
|
+
function registeredEventMatchSchema() {
|
|
4498
|
+
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.");
|
|
4499
|
+
}
|
|
4500
|
+
/**
|
|
4501
|
+
* List why a selection cannot work with the enabled plugins. Use it for new
|
|
4502
|
+
* selections and for saved triggers, which can go stale after a plugin renames
|
|
4503
|
+
* or removes a resource type, event, or filter.
|
|
4504
|
+
*/
|
|
4505
|
+
function registeredEventTriggerProblems(catalog, selection, options) {
|
|
4506
|
+
return eventTriggerProblems(catalog[selection.namespace] ?? NO_SOURCE, selection, options);
|
|
4507
|
+
}
|
|
4508
|
+
/**
|
|
4509
|
+
* Describe a selection with the enabled plugins, for the dashboard and Slack.
|
|
4510
|
+
* Its problems are the same as `registeredEventTriggerProblems`.
|
|
4511
|
+
*/
|
|
4512
|
+
function describeRegisteredEventTrigger(catalog, selection) {
|
|
4513
|
+
return describeEventTrigger(catalog[selection.namespace] ?? NO_SOURCE, selection);
|
|
4514
|
+
}
|
|
4515
|
+
function uniqueMatchValues(match) {
|
|
4516
|
+
if (!match || Object.keys(match).length === 0) return void 0;
|
|
4517
|
+
return Object.fromEntries(Object.entries(match).map(([key, value]) => [key, Array.isArray(value) ? [...new Set(value)] : value]));
|
|
4518
|
+
}
|
|
4519
|
+
/**
|
|
4520
|
+
* Reject an event automation trigger or a Watch that cannot work, with every
|
|
4521
|
+
* problem in one message for the agent. Return its match as it is saved:
|
|
4522
|
+
* each text value in the case that its plugin declares, with no repeated
|
|
4523
|
+
* list values.
|
|
4524
|
+
*/
|
|
4525
|
+
function requireValidEventTrigger(catalog, selection, target) {
|
|
4526
|
+
const fields = eventTriggerChoices(catalog[selection.namespace] ?? NO_SOURCE, selection)?.fields ?? {};
|
|
4527
|
+
const normalized = {
|
|
4528
|
+
...selection,
|
|
4529
|
+
match: normalizeEventMatch(fields, selection.match)
|
|
4530
|
+
};
|
|
4531
|
+
const problems = registeredEventTriggerProblems(catalog, normalized, { inputKeys: true });
|
|
4532
|
+
if (problems.length > 0) {
|
|
4533
|
+
const source = catalog[selection.namespace]?.displayName ?? selection.namespace;
|
|
4534
|
+
const resource = selection.resourceType ? `${source} ${selection.resourceType.replaceAll("_", " ")}` : source;
|
|
4535
|
+
throw new ToolInputError([target === "watch" ? `This ${resource} watch cannot be created.` : `This ${resource} trigger cannot be saved.`, ...problems.map((problem) => problem.message)].join(" "));
|
|
4536
|
+
}
|
|
4537
|
+
return uniqueMatchValues(normalized.match);
|
|
4538
|
+
}
|
|
4539
|
+
/**
|
|
4540
|
+
* Normalize one selector with the convention declared by its plugin.
|
|
4541
|
+
*
|
|
4542
|
+
* Reject a selector that does not fit its resource type. Resource types can
|
|
4543
|
+
* share event types, so only the identifier shows which one a selector names.
|
|
4544
|
+
*/
|
|
4545
|
+
function requireEventIdentifier(catalog, input) {
|
|
4546
|
+
const registration = catalog[input.namespace];
|
|
4547
|
+
const identifier = normalizeEventIdentifier(registration, input.identifier);
|
|
4548
|
+
const resourceTypes = registration?.resourceTypes ?? [];
|
|
4549
|
+
const fits = (resourceType) => resourceType.identifier !== void 0 && new RegExp(resourceType.identifier.pattern).test(identifier);
|
|
4550
|
+
const resourceType = resourceTypes.find((candidate) => candidate.type === input.resourceType);
|
|
4551
|
+
if (!resourceType?.identifier || fits(resourceType)) return identifier;
|
|
4552
|
+
const fitting = resourceTypes.filter(fits).map((candidate) => candidate.type);
|
|
4553
|
+
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(" "));
|
|
4554
|
+
}
|
|
3268
4555
|
// src/chat/automations/revision.ts
|
|
3269
4556
|
/** Storage revisions prevent stale edits from replacing newer values. */
|
|
3270
4557
|
var AutomationConflictError = class extends ToolInputError {
|
|
@@ -3407,6 +4694,14 @@ function eventAutomationRunLimitReason() {
|
|
|
3407
4694
|
const window = describeRunWindow(botConfig.eventAutomationRunWindowMs);
|
|
3408
4695
|
return `This automation reached its limit of ${botConfig.maxEventAutomationRuns} runs per ${window}. Check that its trigger is not too broad, then resume it.`;
|
|
3409
4696
|
}
|
|
4697
|
+
function hasRunKey(entries, runKey) {
|
|
4698
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
4699
|
+
const entry = entries[index];
|
|
4700
|
+
if ("reset" in entry) return false;
|
|
4701
|
+
if (entry.id === runKey) return true;
|
|
4702
|
+
}
|
|
4703
|
+
return false;
|
|
4704
|
+
}
|
|
3410
4705
|
/**
|
|
3411
4706
|
* Count one run of an Event automation, or refuse when its window is full.
|
|
3412
4707
|
*
|
|
@@ -3415,22 +4710,29 @@ function eventAutomationRunLimitReason() {
|
|
|
3415
4710
|
*/
|
|
3416
4711
|
async function admitEventAutomationRun(args) {
|
|
3417
4712
|
const state = args.state ?? getStateAdapter();
|
|
3418
|
-
await
|
|
4713
|
+
await state.connect();
|
|
4714
|
+
const readEntries = async () => {
|
|
4715
|
+
const values = await state.getList(runListKey(args.automationId));
|
|
4716
|
+
return {
|
|
4717
|
+
entries: values.flatMap((value) => {
|
|
4718
|
+
const entry = runEntrySchema.safeParse(value);
|
|
4719
|
+
return entry.success ? [entry.data] : [];
|
|
4720
|
+
}),
|
|
4721
|
+
length: values.length
|
|
4722
|
+
};
|
|
4723
|
+
};
|
|
4724
|
+
if (!hasRunKey((await readEntries()).entries, args.runKey)) await appendRunEntry(state, args.automationId, {
|
|
3419
4725
|
atMs: args.nowMs,
|
|
3420
4726
|
id: args.runKey
|
|
3421
4727
|
});
|
|
3422
4728
|
const windowStartMs = args.nowMs - botConfig.eventAutomationRunWindowMs;
|
|
3423
|
-
const
|
|
3424
|
-
const entries = values.flatMap((value) => {
|
|
3425
|
-
const entry = runEntrySchema.safeParse(value);
|
|
3426
|
-
return entry.success ? [entry.data] : [];
|
|
3427
|
-
});
|
|
4729
|
+
const { entries, length } = await readEntries();
|
|
3428
4730
|
let lastReset = -1;
|
|
3429
4731
|
entries.forEach((entry, index) => {
|
|
3430
4732
|
if ("reset" in entry) lastReset = index;
|
|
3431
4733
|
});
|
|
3432
4734
|
const oldest = entries[0];
|
|
3433
|
-
const full = lastReset === -1 &&
|
|
4735
|
+
const full = lastReset === -1 && length >= runListMaxLength() && oldest !== void 0 && "atMs" in oldest && oldest.atMs > windowStartMs;
|
|
3434
4736
|
const runKeys = /* @__PURE__ */ new Set();
|
|
3435
4737
|
for (const entry of entries.slice(lastReset + 1)) {
|
|
3436
4738
|
if ("reset" in entry) continue;
|
|
@@ -3461,7 +4763,7 @@ function eventAutomationColumns(task) {
|
|
|
3461
4763
|
channelId: record.destination.channelId,
|
|
3462
4764
|
creatorIdentityId,
|
|
3463
4765
|
creatorSlackUserId: record.createdBy.slackUserId,
|
|
3464
|
-
identifier: record.trigger.identifier,
|
|
4766
|
+
identifier: record.trigger.identifier ?? null,
|
|
3465
4767
|
instruction: record.task.text,
|
|
3466
4768
|
namespace: record.trigger.namespace,
|
|
3467
4769
|
record,
|
|
@@ -3469,14 +4771,32 @@ function eventAutomationColumns(task) {
|
|
|
3469
4771
|
title: title?.trim() || null
|
|
3470
4772
|
};
|
|
3471
4773
|
}
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
const task = eventAutomationSchema.parse({
|
|
4774
|
+
function eventAutomationRowInput(row) {
|
|
4775
|
+
return {
|
|
3475
4776
|
...row.record,
|
|
3476
4777
|
createdAtMs: row.createdAtMs,
|
|
3477
4778
|
creatorIdentityId: row.creatorIdentityId,
|
|
3478
4779
|
id: row.id
|
|
4780
|
+
};
|
|
4781
|
+
}
|
|
4782
|
+
/** Decode an event Automation row with its SQL-backed fields. */
|
|
4783
|
+
function parseEventAutomationRow(row) {
|
|
4784
|
+
return withRowFields(row, eventAutomationSchema.parse(eventAutomationRowInput(row)));
|
|
4785
|
+
}
|
|
4786
|
+
/**
|
|
4787
|
+
* Decode a row for Event matching. Log and skip a row that does not parse,
|
|
4788
|
+
* so one bad row cannot stop matching for its whole namespace.
|
|
4789
|
+
*/
|
|
4790
|
+
function readMatchableEventAutomationRow(row) {
|
|
4791
|
+
const parsed = eventAutomationSchema.safeParse(eventAutomationRowInput(row));
|
|
4792
|
+
if (parsed.success) return withRowFields(row, parsed.data);
|
|
4793
|
+
logWarn("event_automation.row.invalid", {
|
|
4794
|
+
"app.task.id": row.id,
|
|
4795
|
+
"app.task.type": "event",
|
|
4796
|
+
"error.message": parsed.error.message
|
|
3479
4797
|
});
|
|
4798
|
+
}
|
|
4799
|
+
function withRowFields(row, task) {
|
|
3480
4800
|
const title = row.title?.trim();
|
|
3481
4801
|
return {
|
|
3482
4802
|
...task,
|
|
@@ -3562,25 +4882,99 @@ async function listDeletedEventAutomationsCreatedBy(db, user, limit) {
|
|
|
3562
4882
|
return (await db.select().from(juniorAutomations).where(and(inArray(juniorAutomations.creatorIdentityId, identityIds), eq(juniorAutomations.kind, "event"), eq(juniorAutomations.status, "deleted"))).orderBy(desc(juniorAutomations.createdAtMs), desc(juniorAutomations.id)).limit(limit)).map(parseEventAutomationRow);
|
|
3563
4883
|
}
|
|
3564
4884
|
/**
|
|
3565
|
-
*
|
|
3566
|
-
*
|
|
4885
|
+
* Select live rows whose identifier is one of these, and rows with no
|
|
4886
|
+
* identifier. A row with no identifier runs for every resource of its
|
|
4887
|
+
* namespace, so it gets every copy of an Event.
|
|
4888
|
+
*/
|
|
4889
|
+
function eventIdentifierWhere(identifiers) {
|
|
4890
|
+
return or(isNull(juniorAutomations.identifier), ...identifiers.length > 0 ? [inArray(juniorAutomations.identifier, [...identifiers])] : []);
|
|
4891
|
+
}
|
|
4892
|
+
/** SQL for rows whose trigger selects any of these event types. */
|
|
4893
|
+
function eventTypesWhere(eventTypes) {
|
|
4894
|
+
return or(...eventTypes.map((eventType) => sql`${juniorAutomations.record}->'trigger'->'events' @> ${JSON.stringify([eventType])}::jsonb`));
|
|
4895
|
+
}
|
|
4896
|
+
/**
|
|
4897
|
+
* Return why a row must not run for an Event with one of these identifiers,
|
|
4898
|
+
* or nothing when it may.
|
|
4899
|
+
*
|
|
4900
|
+
* The identifier column and the saved trigger must agree. A row with no
|
|
4901
|
+
* identifier must also have a trigger that the enabled `catalog` accepts. A
|
|
4902
|
+
* plugin can later require a filter or stop letting an event run for every
|
|
4903
|
+
* resource, and the row must not stay broad.
|
|
4904
|
+
*/
|
|
4905
|
+
function eventAutomationGateProblem(row, task, identifiers, catalog) {
|
|
4906
|
+
const identifier = task.trigger.identifier;
|
|
4907
|
+
if (identifier !== void 0) return identifier === row.identifier && identifiers.includes(identifier) ? void 0 : "The saved trigger has a different identifier from its row.";
|
|
4908
|
+
if (row.identifier !== null) return "The saved trigger has no identifier, but its row has one.";
|
|
4909
|
+
const problems = registeredEventTriggerProblems(catalog, task.trigger);
|
|
4910
|
+
return problems.length > 0 ? problems.map((problem) => problem.message).join(" ") : void 0;
|
|
4911
|
+
}
|
|
4912
|
+
/**
|
|
4913
|
+
* Read the rows that can match an Event: live rows of one team and
|
|
4914
|
+
* namespace whose identifier is one of these, or that have no identifier,
|
|
4915
|
+
* and that select one of these event types. Skip and log a row that does
|
|
4916
|
+
* not parse or that the gate rejects.
|
|
4917
|
+
*/
|
|
4918
|
+
async function readGatedEventAutomations(db, input) {
|
|
4919
|
+
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));
|
|
4920
|
+
const plugins = automationEventCatalog(input.catalog);
|
|
4921
|
+
return rows.flatMap((row) => {
|
|
4922
|
+
const task = readMatchableEventAutomationRow(row);
|
|
4923
|
+
if (!task) return [];
|
|
4924
|
+
const problem = eventAutomationGateProblem(row, task, input.identifiers, plugins);
|
|
4925
|
+
if (problem === void 0) return [task];
|
|
4926
|
+
logWarn("event_automation.gate.rejected", {
|
|
4927
|
+
"app.task.id": task.id,
|
|
4928
|
+
"app.task.type": "event",
|
|
4929
|
+
"app.event_automation.skip_reason": problem
|
|
4930
|
+
});
|
|
4931
|
+
return [];
|
|
4932
|
+
});
|
|
4933
|
+
}
|
|
4934
|
+
/** The filters of a match whose keys are in this data. */
|
|
4935
|
+
function knownMatch(match, data) {
|
|
4936
|
+
if (!match) return void 0;
|
|
4937
|
+
return Object.fromEntries(Object.entries(match).filter(([key]) => data[key] !== void 0));
|
|
4938
|
+
}
|
|
4939
|
+
/**
|
|
4940
|
+
* Collect match keys from live event automations for these identifiers and
|
|
4941
|
+
* event types, and from automations with no identifier. Does not evaluate
|
|
4942
|
+
* match values, except for automations with no identifier: one whose
|
|
4943
|
+
* filters on the keys in `data` fail adds no keys. So a broad trigger for
|
|
4944
|
+
* one repository does not make every repository load its lazy data.
|
|
3567
4945
|
*/
|
|
3568
4946
|
async function collectEventAutomationMatchKeys(db, input) {
|
|
3569
|
-
const eventTypes = new Set(input.eventTypes.map((eventType) => eventType.trim()).filter(Boolean));
|
|
4947
|
+
const eventTypes = [...new Set(input.eventTypes.map((eventType) => eventType.trim()).filter(Boolean))];
|
|
3570
4948
|
const identifiers = [...new Set(input.identifiers.map((value) => value.trim()).filter(Boolean))];
|
|
3571
|
-
if (eventTypes.
|
|
3572
|
-
const
|
|
4949
|
+
if (eventTypes.length === 0) return [];
|
|
4950
|
+
const tasks = await readGatedEventAutomations(db, {
|
|
4951
|
+
catalog: input.catalog,
|
|
4952
|
+
eventTypes,
|
|
4953
|
+
identifiers,
|
|
4954
|
+
namespace: input.namespace,
|
|
4955
|
+
teamId: input.teamId
|
|
4956
|
+
});
|
|
3573
4957
|
const keys = /* @__PURE__ */ new Set();
|
|
3574
|
-
for (const
|
|
3575
|
-
|
|
3576
|
-
if (!task.trigger.events.some((eventType) => eventTypes.has(eventType))) continue;
|
|
4958
|
+
for (const task of tasks) {
|
|
4959
|
+
if (task.trigger.identifier === void 0 && input.data && !eventMatches(knownMatch(task.trigger.match, input.data), input.data)) continue;
|
|
3577
4960
|
for (const key of Object.keys(task.trigger.match ?? {})) keys.add(key);
|
|
3578
4961
|
}
|
|
3579
4962
|
return [...keys].sort();
|
|
3580
4963
|
}
|
|
3581
|
-
/**
|
|
3582
|
-
|
|
3583
|
-
|
|
4964
|
+
/**
|
|
4965
|
+
* Find every live task matching one normalized event: tasks for its exact
|
|
4966
|
+
* identifier, and tasks with no identifier whose filters match. The gate
|
|
4967
|
+
* skips a row whose trigger and identifier column disagree, and a row with
|
|
4968
|
+
* no identifier whose trigger the enabled `catalog` rejects.
|
|
4969
|
+
*/
|
|
4970
|
+
async function findMatchingEventAutomations(db, event, teamId, catalog) {
|
|
4971
|
+
return (await readGatedEventAutomations(db, {
|
|
4972
|
+
catalog,
|
|
4973
|
+
eventTypes: [event.eventType],
|
|
4974
|
+
identifiers: [event.identifier],
|
|
4975
|
+
namespace: event.namespace,
|
|
4976
|
+
teamId
|
|
4977
|
+
})).filter((task) => task.trigger.events.includes(event.eventType) && eventMatches(task.trigger.match, event.data));
|
|
3584
4978
|
}
|
|
3585
4979
|
/**
|
|
3586
4980
|
* Change lifecycle under the same row lock as edits, without changing
|
|
@@ -3753,4 +5147,4 @@ async function saveScheduledAutomation(db, task, expectedRevision, editedBy) {
|
|
|
3753
5147
|
async function saveScheduledAutomationInLock(db, task, current) {
|
|
3754
5148
|
await writeScheduledAutomation(db, requireStoredTask(task), current);
|
|
3755
5149
|
}
|
|
3756
|
-
export {
|
|
5150
|
+
export { recordAgentsInstructionsUpdated as $, buildDeterministicTurnId as $n, appendExclusiveInboundMessage as $t, CORE_EVENT_NAMESPACE as A, TurnToolCallLimitExceededError as An, PluginCredentialFailureError as At, registeredEventTypeSchema as B, instructionTextForProjection as Bn, wasPendingAuthStopped as Bt, automationVersionOf as C, requestConversationWork as Cn, isHumanConversationMessage as Ct, AutomationConflictError as D, TURN_LIMIT_DISABLED_REASON as Dn, turnHasReply as Dt, sameDefinitionValue as E, stopConversationWork as En, recordDeliveredAssistantMessage as Et, eventNamespaceSchema as F, extractAssistantText as Fn, AuthorizationPauseError as Ft, commitMessages as G, normalizeToolNameFromResult as Gn, completeTurnRecord as Gt, requireEventIdentifier as H, isContinuablePiBoundary as Hn, loadTurnCheckpoint as Ht, eventWatchUnavailable as I, getPiMessageRole as In, abandonReplacedPendingAuth as It, loadLastModelProfile as J, trimTrailingAssistantMessages as Jn, listTurnSummaries as Jt, loadConnectedMcpProviders as K, retainRuntimeTurnContext as Kn, failTurnRecord as Kt, hasEventCatalogEntries as L, getTerminalAssistantMessages as Ln, canReusePendingAuthLink as Lt, describeRegisteredEventTrigger as M, createTurnLimitGate as Mn, authorizationId as Mt, eventCatalogEntry as N, isTurnExecutionLimitExceededError as Nn, AuthPausePersistenceError as Nt, automationRevision as O, TurnBudgetExceededError as On, upsertConversationMessage as Ot, eventGuidance as P, persistWithRetry as Pn, AuthorizationFlowDisabledError as Pt, openConversationProjection as Q, buildDeterministicAssistantMessageId as Qn, ackMessages as Qt, registeredEventMatchSchema as R, getUserMessageInstructionText as Rn, clearPendingAuth as Rt, automationDefinition as S, requestAnotherSlice as Sn, getConversationMessageSlackTs as St, readAutomationVersion as T, steerWebPendingMessages as Tn, normalizeConversationText as Tt, requireValidEventTrigger as U, isToolResultError as Un, saveTurnCheckpoint as Ut, registeredResourceTypeSchema as V, isAssistantMessage as Vn, continuableMessages as Vt, commitAcceptedReply as W, isToolResultMessage as Wn, abandonTurnRecord as Wt, loadTurnProjection as X, SESSION_CONTEXT_SECTION_TAG as Xn, CONVERSATION_WORK_CHECK_IN_INTERVAL_MS as Xt, loadProjection as Y, REQUEST_CONTEXT_SECTION_TAG as Yn, recordTurnSummary as Yt, loadTurnRoute as Z, TURN_CONTEXT_TAG as Zn, CONVERSATION_WORK_STALE_ENQUEUE_MS as Zt, parseEventAutomationRow as _, markConversationWorkEnqueued as _n, resolveTaskTitle as _t, saveScheduledAutomation as a, clearExpiredConversationLease as an, getAgentContextCompactionTriggerTokens as ar, recordMcpProviderConnected as at, admitEventAutomationRun as b, releaseConversationWork as bn, createConversationMemoryService as bt, withScheduledAutomationLock as c, deadLetterAttempt as cn, isExperimentalFeatureEnabled as cr, recordTurnRoute as ct, createEventAutomation as d, getConversationWorkState as dn, configValueSchema as dr, persistConversationMessages as dt, appendInboundMessage as en, SubscribedReplyReason as er, recordAttachmentsDelivered as et, deleteEventAutomation as f, hasConversationStop as fn, historyItemFromPiMessage as fr, projectConversationMessages as ft, listEventAutomationsForTeam as g, listActiveConversationIds as gn, fallbackShortTitle as gt, listDeletedEventAutomationsCreatedBy as h, isInvalidConversationRecordError as hn, setConfigDefaults as hr, settleConversationTitleWork as ht, readScheduledAutomation as i, clearConsumedConversationWake as in, estimateTextTokens as ir, recordGuardianActionReviewed as it, automationEventCatalog as j, buildTurnErrorResponse as jn, createPluginAuthOrchestration as jt, requireAutomationRevision as k, TurnSliceLimitExceededError as kn, createPluginEgress as kt, blockEventAutomation as l, drainConversationMailbox as ln, setExperimentalFeatures as lr, appendConversationMessages as lt, getEventAutomation as m, isFinalAttempt as mn, getConfigDefaults as mr, resolveConversationTitle as mt, listScheduledAutomationsForTeam as n, cancelHumanFacingPendingMessages as nn, getSubscribedReplyPreflightDecision as nr, recordAuthorizationCompleted as nt, saveScheduledAutomationInLock as o, completeConversationStop as on, getAgentContextInputLimitTokens as or, recordSubscribedReplyRoute as ot, findMatchingEventAutomations as p, hasRunnableConversationWork as pn, piMessageFromHistoryItem as pr, persistConversationMessageSummaries as pt, loadConversationProjection as q, stripRuntimeTurnContext as qn, getTurnRecord as qt, parseScheduledAutomationRow as r, checkInConversationWork as rn, getThreadStopDecision as rr, recordAuthorizationRequested as rt, scheduledRunId as s, completeConversationWork as sn, getExperimentalFeatures as sr, recordToolExecutionStarted as st, createScheduledAutomation as t, beginConversationResume as tn, decideSubscribedThreadReply as tr, recordAuthenticationLinked as tt, collectEventAutomationMatchKeys as u, getConversation as un, createDurableLocationConfigurationService as ur, hydrateConversationMessages as ut, saveEventAutomation as v, recordAttemptFailure as vn, appendThreadContextMessages as vt, listAutomationVersions as w, startConversationWork as wn, markConversationMessage as wt, eventAutomationRunLimitReason as x, removeActiveConversation as xn, estimateConversationContextTokens as xt, setEventAutomationStatus as y, recordConversationRetry as yn, buildConversationContext as yt, registeredEventTriggerProblems as z, hasRuntimeTurnContext as zn, getConversationPendingAuth as zt };
|