UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

315 lines (314 loc) • 16.3 kB
import { c as normalizeOptionalLowercaseString, l as normalizeOptionalString } from "./string-coerce-1VuiLaaR.mjs"; import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.mjs"; import { n as sha256Hex } from "./node-crypto-BG4u3UnX.mjs"; import "./crypto-digest-y6oZN-_F.mjs"; import { t as normalizeAnyChannelId } from "./registry-normalize-BvpWSO2L.mjs"; import { o as classifyOAuthRefreshFailure } from "./oauth-refresh-failure-B2gKOXl5.mjs"; import { r as SILENT_REPLY_TOKEN } from "./tokens-D4ghQG_g.mjs"; import { a as hasCanonicalCronDeliveryMode } from "./delivery-codec-DMPX9PBc.mjs"; import { i as normalizeTargetForProvider } from "./target-normalization-BfqQsKXV.mjs"; import { t as resolveTargetPrefixedChannel } from "./channel-target-prefix-He7OrqGf.mjs"; import { t as buildProviderLoginRecovery } from "./provider-login-recovery-gHCWblhB.mjs"; import { i as wrapUntrustedPromptDataBlock } from "./sanitize-for-prompt-bzCyHFCH.mjs"; import { A as cronNotificationJob, N as cronFailureDetailLines, l as isJobEnabled } from "./jobs-scheduling-CBskypsm.mjs"; import { n as resolveCronDeliveryPlan, r as resolveFailureDestination } from "./delivery-plan-CkfNyQS6.mjs"; import { randomUUID } from "node:crypto"; //#region src/cron/service/failure-repair-brief.ts const REPAIR_PAYLOAD_MAX_CHARS = 4e3; const REPAIR_ERROR_MAX_CHARS = 1e3; function buildCronFailureRepairBrief(params) { const { job } = params; const payload = job.payload; const payloadText = payload.kind === "agentTurn" ? payload.message : payload.kind === "systemEvent" ? payload.text : payload.kind === "script" ? payload.script : ""; const error = [params.errorReason ? `cause: ${params.errorReason}` : "", params.error?.trim()].filter(Boolean).join("\n"); return [ `Automation repair request from the scheduler, not a user message. Do not relay it; follow the steps below.`, `An automation (id ${job.id}), created in this conversation, failed ${params.consecutiveErrors} consecutive runs. No failure alert was sent.`, `Schedule: ${JSON.stringify(job.schedule)}. Payload kind: ${payload.kind}.`, wrapUntrustedPromptDataBlock({ label: "Automation name", text: job.name, maxChars: 200 }), wrapUntrustedPromptDataBlock({ label: "Current payload", text: payloadText, maxChars: REPAIR_PAYLOAD_MAX_CHARS, truncationMarker: " [truncated]" }), wrapUntrustedPromptDataBlock({ label: "Last error", text: error || "No error text recorded.", maxChars: REPAIR_ERROR_MAX_CHARS, truncationMarker: " [truncated]" }), "", "Diagnose the failure, then do exactly one:", `1. Transient (provider outage, network, rate limit, or temporary upstream error): change nothing and reply exactly ${SILENT_REPLY_TOKEN}.`, "2. Fixable in the workspace (for example the helper script or instructions file the payload follows): fix it, then reply with one line saying what you fixed.", "3. Otherwise: ask the user for exactly what you need to fix it.", "If the automation keeps failing, the user gets the normal failure alert." ].join("\n"); } //#endregion //#region src/cron/service/failure-alerts.ts /** Resolves and emits cron failure-alert notifications. */ const DEFAULT_FAILURE_ALERT_AFTER = 2; const DEFAULT_FAILURE_ALERT_COOLDOWN_MS = 36e5; /** Returns the last failure-notification delivery trace persisted on a cron job. */ function failureNotificationDeliveryFromJobState(job) { const status = job.state.lastFailureNotificationDeliveryStatus; if (!status || status === "not-requested") return; return { delivered: job.state.lastFailureNotificationDelivered, status, error: job.state.lastFailureNotificationDeliveryError }; } function resolveFailureAlertChannel(channel, to) { const normalized = normalizeOptionalLowercaseString(channel); if (normalized && normalized !== "last") return normalizeAnyChannelId(normalized) ?? normalized; return normalizeOptionalLowercaseString(resolveTargetPrefixedChannel(to)) ?? normalized; } function normalizeFailureAlertRecipient(channel, to) { try { return normalizeTargetForProvider(channel, to) ?? to; } catch { return to; } } function clampPositiveInt(value, fallback) { if (typeof value !== "number" || !Number.isFinite(value)) return fallback; const floored = Math.floor(value); return floored >= 1 ? floored : fallback; } function clampNonNegativeInt(value, fallback) { if (typeof value !== "number" || !Number.isFinite(value)) return fallback; const floored = Math.floor(value); return floored >= 0 ? floored : fallback; } /** Resolves effective failure-alert policy from job config, delivery defaults, and global cron config. */ function resolveFailureAlert(state, job) { const prepared = state.preparedFailureAlert; if (prepared) { if (prepared.jobId !== job.id) throw new Error("cron: prepared failure-alert policy does not match the job"); return prepared.value; } const globalConfig = state.deps.cronConfig?.failureAlert; const jobConfig = job.failureAlert === false ? void 0 : job.failureAlert; if (job.failureAlert === false) return null; if (!jobConfig && globalConfig?.enabled === false) return null; const hasJobRoute = Boolean(jobConfig && (jobConfig.channel !== void 0 || jobConfig.to !== void 0 || jobConfig.accountId !== void 0 || jobConfig.mode !== void 0)); const alternateRoute = resolveFailureDestination(job, globalConfig, hasJobRoute ? jobConfig : void 0); const primaryRoute = hasCanonicalCronDeliveryMode(job.delivery) ? resolveCronDeliveryPlan(job) : void 0; const primaryAnnounceRoute = primaryRoute?.mode === "announce" && primaryRoute.requested ? primaryRoute : void 0; const explicitlyConfigured = jobConfig !== void 0 || globalConfig !== void 0; if (!alternateRoute && !primaryAnnounceRoute && !explicitlyConfigured) return null; const configuredMode = jobConfig?.mode ?? (jobConfig?.channel ? "announce" : void 0) ?? globalConfig?.mode; const route = alternateRoute ?? (configuredMode === "webhook" && explicitlyConfigured ? { mode: "webhook", to: normalizeOptionalString(jobConfig?.to ?? globalConfig?.to), accountId: normalizeOptionalString(jobConfig?.accountId ?? globalConfig?.accountId) } : primaryAnnounceRoute); const mode = (route?.mode ?? configuredMode) === "webhook" ? "webhook" : "announce"; const primaryChannel = primaryAnnounceRoute ? resolveFailureAlertChannel(primaryAnnounceRoute.channel, primaryAnnounceRoute.to) ?? "last" : void 0; const hasAnnounceRouteSelector = jobConfig?.channel !== void 0 || jobConfig?.to !== void 0 || job.delivery?.failureDestination?.channel !== void 0 || job.delivery?.failureDestination?.to !== void 0 || globalConfig?.channel !== void 0 || globalConfig?.to !== void 0; const channel = mode === "announce" && !hasAnnounceRouteSelector && primaryChannel ? primaryChannel : resolveFailureAlertChannel(route?.channel, route?.to) ?? "last"; const routeUsesPrimaryChannel = mode === "announce" && primaryAnnounceRoute !== void 0 && channel === primaryChannel; const to = normalizeOptionalString(route?.to) ?? (routeUsesPrimaryChannel ? primaryAnnounceRoute?.to : void 0); const primaryRecipientMatches = primaryAnnounceRoute !== void 0 && mode === "announce" && channel === primaryChannel && (to === primaryAnnounceRoute.to || to !== void 0 && primaryAnnounceRoute.to !== void 0 && normalizeFailureAlertRecipient(channel, to) === normalizeFailureAlertRecipient(channel, primaryAnnounceRoute.to)); const accountId = normalizeOptionalString(route?.accountId) ?? (primaryRecipientMatches ? primaryAnnounceRoute?.accountId : void 0); const primaryRouteMatches = primaryRecipientMatches && accountId === primaryAnnounceRoute?.accountId && (alternateRoute === null || !job.delivery?.failureDestination || primaryAnnounceRoute?.threadId == null || jobConfig?.to !== void 0); return { after: clampPositiveInt(jobConfig?.after ?? globalConfig?.after, DEFAULT_FAILURE_ALERT_AFTER), cooldownMs: clampNonNegativeInt(jobConfig?.cooldownMs ?? globalConfig?.cooldownMs, DEFAULT_FAILURE_ALERT_COOLDOWN_MS), channel, to, mode, accountId, threadId: primaryRouteMatches ? primaryAnnounceRoute.threadId : void 0, includeSkipped: jobConfig?.includeSkipped ?? globalConfig?.includeSkipped ?? false, alternateRoute: alternateRoute !== null && !primaryRouteMatches }; } function buildFailureAlertPayload(params) { const safeJobName = params.job.name || params.job.id; const errorReason = params.status === "error" ? params.errorReason : void 0; const statusVerb = params.status === "skipped" ? "skipped" : "failed"; const detailLabel = params.status === "skipped" ? "Skip reason" : "Last error"; const detailLines = params.route.mode === "webhook" ? [...errorReason ? [`Cause: ${errorReason}`] : [], `${detailLabel}: ${truncateUtf16Safe(params.error?.trim() || "unknown reason", 200)}`] : cronFailureDetailLines(errorReason, params.failureNotificationDetail); const text = [ `Automation "${safeJobName}" ${statusVerb} ${params.consecutiveErrors} times`, ...params.repairRequested ? ["An automatic repair was requested in its owner conversation; it is still failing."] : [], ...detailLines ].join("\n"); const oauthRefreshFailure = params.error ? classifyOAuthRefreshFailure(params.error) : null; const providerLoginRecovery = params.status === "error" && (errorReason === "auth" || errorReason === "auth_permanent") ? buildProviderLoginRecovery({ provider: normalizeOptionalString(oauthRefreshFailure?.provider), oauthReason: oauthRefreshFailure?.reason }) : void 0; return { text: providerLoginRecovery ? `${text}\n${providerLoginRecovery.hint}` : text, ...providerLoginRecovery ? { presentation: providerLoginRecovery.presentation } : {} }; } function startFailureNotification(job) { job.state.lastFailureNotificationId = randomUUID(); job.state.lastFailureNotificationDelivered = void 0; job.state.lastFailureNotificationDeliveryStatus = "unknown"; job.state.lastFailureNotificationDeliveryError = void 0; } function startFailureAlertCycle(job, incident, now) { startFailureNotification(job); job.state.lastFailureAlertAtMs = now; const current = job.state.failureAlertIncident; job.state.failureAlertIncident = { ...incident, scope: current?.scope === "run" ? "run" : incident.scope, ...current?.repair ? { repair: { atMs: current.repair.atMs, alerted: true } } : {} }; } function requestFailureNotification(state, job, alertConfig, incident) { if (job.state.failureAlertIncident?.signature === incident.signature) return false; const now = state.deps.nowMs(); const lastAlert = job.state.lastFailureAlertAtMs; if (typeof lastAlert === "number" && lastAlert <= now && now - lastAlert < Math.max(0, alertConfig.cooldownMs)) return false; startFailureAlertCycle(job, incident, now); return true; } function failureRecoveryScope(detail) { return detail?.kind === "script-failure" && detail.source === "trigger" ? "trigger" : "run"; } function recordUnresolvedFailure(job, detail) { const scope = failureRecoveryScope(detail); const incident = job.state.failureAlertIncident ??= { scope }; if (scope === "run") incident.scope = "run"; } function failureIncident(params) { const cause = params.errorReason ?? params.failureNotificationDetail ?? params.error?.trim(); const scope = failureRecoveryScope(params.failureNotificationDetail); return { signature: sha256Hex(JSON.stringify([ params.status, scope, cause, params.route.mode, params.route.channel, params.route.to, params.route.accountId, params.route.threadId ])), scope }; } /** * Emits one alert per incident when threshold, best-effort, and cooldown policy allow it. * For a job with an owner conversation that will run again, the first chat alert of a * failure streak becomes a repair request in that conversation; the next failure of that * streak alerts, naming it, and the streak is never repaired twice. */ function maybeEmitFailureAlert(state, params) { recordUnresolvedFailure(params.job, params.failureNotificationDetail); const alertConfig = params.alertConfig; if (!alertConfig || params.consecutiveCount < alertConfig.after) return; if (params.job.delivery?.bestEffort === true && !params.job.failureAlert) return; const incident = failureIncident({ ...params, route: alertConfig }); const now = state.deps.nowMs(); const repair = params.job.state.failureAlertIncident?.repair; if (repair && !repair.alerted) startFailureAlertCycle(params.job, incident, now); else if (!requestFailureNotification(state, params.job, alertConfig, incident)) return; const job = cronNotificationJob(params.job); if (!repair && alertConfig.mode === "announce" && params.status === "error" && params.job.owner?.sessionKey?.trim() && params.job.payload.kind !== "command" && params.job.schedule.kind !== "on-exit" && params.job.schedule.kind !== "stream" && isJobEnabled(params.job)) { const opened = params.job.state.failureAlertIncident ?? incident; params.job.state.failureAlertIncident = { ...opened, repair: { atMs: now } }; params.job.state.lastFailureNotificationDeliveryStatus = "not-requested"; params.deferredNotifications.push({ kind: "failure-repair", job, text: buildCronFailureRepairBrief({ job: params.job, consecutiveErrors: params.consecutiveCount, error: params.error, errorReason: params.errorReason }) }); return; } params.deferredNotifications.push({ kind: "failure-alert", job, payload: buildFailureAlertPayload({ job, error: params.error, errorReason: params.errorReason, failureNotificationDetail: params.failureNotificationDetail, consecutiveErrors: params.consecutiveCount, route: alertConfig, status: params.status, repairRequested: repair !== void 0 }), runAtMs: params.runAtMs, route: alertConfig }); } /** * Resolves the incident after execution succeeds. Recovery is visible in run history and * job state only: a working automation just works, so no notification is sent. Clearing * the incident and cooldown lets the next failure alert again. */ function resolveFailureIncident(job, opts) { const incident = job.state.failureAlertIncident; if (!incident || opts?.triggerOnly && incident.scope !== "trigger") return; delete job.state.failureAlertIncident; job.state.lastFailureAlertAtMs = void 0; } /** Finalizes execution or required-delivery alerts after scheduling policy settles. */ function finalizeCronFailureNotifications(state, params) { if (params.result.status === "ok" && params.completionStatus === "succeeded") { resolveFailureIncident(params.job); return; } recordUnresolvedFailure(params.job, params.result.failureNotificationDetail); if (params.replay) return; if (params.result.status === "error" && !params.autoDisableNotificationOwnsFailure) maybeEmitFailureAlert(state, { job: params.job, alertConfig: params.alertConfig, status: "error", error: params.result.error, errorReason: params.job.state.lastErrorReason, failureNotificationDetail: params.result.failureNotificationDetail, runAtMs: params.result.startedAt, consecutiveCount: params.job.state.consecutiveErrors ?? 0, deferredNotifications: params.deferredNotifications }); else if (params.result.status === "ok" && params.completionStatus === "failed" && params.job.state.lastDeliveryStatus === "not-delivered" && params.alertConfig?.alternateRoute) { if (!requestFailureNotification(state, params.job, params.alertConfig, failureIncident({ status: "delivery", error: params.job.state.lastDeliveryError, errorReason: params.job.state.lastErrorReason, route: params.alertConfig }))) return; const job = cronNotificationJob(params.job); const route = params.alertConfig; const detailLines = route.mode === "webhook" ? [`Last error: ${truncateUtf16Safe(params.job.state.lastDeliveryError?.trim() || "unknown reason", 200)}`] : cronFailureDetailLines(params.job.state.lastErrorReason); const payload = { text: [`Automation "${job.name || job.id}" delivery failed`, ...detailLines].join("\n") }; params.deferredNotifications.push({ kind: "failure-alert", job, payload, runAtMs: params.result.startedAt, route }); } } //#endregion export { resolveFailureIncident as a, resolveFailureAlert as i, finalizeCronFailureNotifications as n, maybeEmitFailureAlert as r, failureNotificationDeliveryFromJobState as t };