UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

487 lines (486 loc) 20.2 kB
import { c as normalizeOptionalString } from "./string-coerce-mnp54Vah.js"; import { M as resolveTimestampMsToIsoString, o as asDateTimestampMs } from "./number-coercion-CJQ8TR--.js"; import { i as formatErrorMessage } from "./errors-BXgSefBE.js"; import { a as isSubagentSessionKey, c as parseAgentSessionKey } from "./session-key-utils-Bx3apsJ3.js"; import { c as resolveDefaultAgentId } from "./agent-scope-config-CgCYpZfK.js"; import { D as listConfiguredAnnounceChannelIdsForConfig } from "./gateway-startup-plugin-ids-3X_G47Qt.js"; import { i as normalizeMessageChannel } from "./message-channel-normalize-BLLf0ubu.js"; import "./message-channel-BiOeMu0l.js"; import { r as normalizeCronJobPatch, t as normalizeCronJobCreate } from "./normalize-Da7MTCo5.js"; import { i as parseAbsoluteTimeMs } from "./stagger-Bbv08nIY.js"; import { i as resolveCronDeliverySessionKey, n as isInvalidCronSessionTargetIdError } from "./session-target-DNkvuLUI.js"; import { i as readCronRunLogEntriesPageAll, n as isInvalidCronRunLogJobIdError, r as readCronRunLogEntriesPage } from "./run-log-qm3LS7cj.js"; import { t as assertCronDeliveryInputNonBlankFields } from "./delivery-target-validation-rI-6HP-3.js"; import "./channel-plugin-ids-yxLDmEfb.js"; import { a as validateTargetProviderPrefix, t as resolveTargetPrefixedChannel } from "./channel-target-prefix-l64LTfQ6.js"; import { in as errorShape, rn as ErrorCodes } from "./schema-BwaBORnA.js"; import { B as validateCronRunParams, H as validateCronStatusParams, I as validateCronAddParams, L as validateCronGetParams, R as validateCronListParams, U as validateCronUpdateParams, V as validateCronRunsParams, rr as validateWakeParams, t as formatValidationErrors, z as validateCronRemoveParams } from "./src-oj0IwW6K.js"; import { n as resolveCronDeliveryPlan, t as hasExplicitCronDeliveryTarget } from "./delivery-plan-DfZrCSUq.js"; import { n as applyJobPatch, y as resolveDeliveryTarget } from "./jobs-CRtvC4Q4.js"; //#region src/cron/delivery-preview.ts /** Builds dry-run cron delivery labels for CLI/UI list surfaces. */ function formatTarget(channel, to) { if (!channel) return "last"; if (to) return `${channel}:${to}`; return channel; } function formatDeliveryDetail(params) { if (params.requestedChannel === "last" || !params.requestedChannel) { if (!params.resolved) return params.error ? `last -> no route, will fail-closed: ${params.error}` : "last -> no route, will fail-closed"; return params.sessionKey ? `resolved from last, session ${params.sessionKey}` : "resolved from last, main session"; } return params.resolved ? "explicit" : params.error ?? "unresolved"; } /** Builds the user-visible cron delivery preview for one job without sending anything. */ async function resolveCronDeliveryPreview(params) { const plan = resolveCronDeliveryPlan(params.job); if (plan.mode === "none" && !hasExplicitCronDeliveryTarget(plan)) return { label: "not requested", detail: "not requested" }; if (plan.mode === "webhook") return { label: plan.to ? `webhook:${plan.to}` : "webhook", detail: plan.to ? "webhook" : "webhook target missing" }; const requestedChannel = plan.channel ?? "last"; const agentId = params.job.agentId?.trim() || params.defaultAgentId || resolveDefaultAgentId(params.cfg); const deliverySessionKey = resolveCronDeliverySessionKey(params.job); const resolved = await resolveDeliveryTarget(params.cfg, agentId, { channel: requestedChannel, to: plan.to, threadId: plan.threadId, accountId: plan.accountId, sessionKey: deliverySessionKey }, { dryRun: true }); if (!resolved.ok) return { label: `${plan.mode} -> ${formatTarget(requestedChannel, plan.to ?? null)}`, detail: plan.mode === "none" ? `message tool target unresolved: ${resolved.error.message}` : formatDeliveryDetail({ requestedChannel, resolved: false, sessionKey: deliverySessionKey, error: resolved.error.message }) }; return { label: `${plan.mode} -> ${formatTarget(resolved.channel, resolved.to)}`, detail: formatDeliveryDetail({ requestedChannel, resolved: true, sessionKey: deliverySessionKey }) }; } /** Builds cron delivery previews keyed by job id. */ async function resolveCronDeliveryPreviews(params) { const entries = await Promise.all(params.jobs.map(async (job) => [job.id, await resolveCronDeliveryPreview({ cfg: params.cfg, defaultAgentId: params.defaultAgentId, job })])); return Object.fromEntries(entries); } //#endregion //#region src/cron/validate-timestamp.ts /** Validates user-supplied one-shot cron timestamps before scheduling. */ const ONE_MINUTE_MS = 60 * 1e3; const TEN_YEARS_MS = 10 * 365.25 * 24 * 60 * 60 * 1e3; /** * Validates one-shot cron timestamps with a small past grace window and far-future cap. */ function validateScheduleTimestamp(schedule, nowMs = Date.now()) { if (schedule.kind !== "at") return { ok: true }; const atRaw = normalizeOptionalString(schedule.at) ?? ""; const atMs = atRaw ? parseAbsoluteTimeMs(atRaw) : null; if (atMs === null || !Number.isFinite(atMs)) return { ok: false, message: `Invalid schedule.at: expected ISO-8601 timestamp (got ${schedule.at})` }; const referenceNowMs = asDateTimestampMs(nowMs) ?? asDateTimestampMs(Date.now()) ?? 0; const diffMs = atMs - referenceNowMs; if (diffMs < -6e4) { const nowDate = resolveTimestampMsToIsoString(referenceNowMs); return { ok: false, message: `schedule.at is in the past: ${resolveTimestampMsToIsoString(atMs)} (${Math.floor(-diffMs / ONE_MINUTE_MS)} minutes ago). Current time: ${nowDate}` }; } if (diffMs > TEN_YEARS_MS) return { ok: false, message: `schedule.at is too far in the future: ${resolveTimestampMsToIsoString(atMs)} (${Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1e3))} years ahead). Maximum allowed: 10 years` }; return { ok: true }; } //#endregion //#region src/gateway/server-methods/cron.ts function listConfiguredAnnounceChannelIds(cfg) { return listConfiguredAnnounceChannelIdsForConfig({ config: cfg, env: process.env }); } function assertConfiguredAnnounceChannel(params) { if (params.channel === "last") return; const configuredChannels = listConfiguredAnnounceChannelIds(params.cfg).toSorted(); const normalizedChannel = normalizeMessageChannel(params.channel); if (!normalizedChannel) { if (configuredChannels.length <= 1) return; throw new Error(`${params.field} is required when multiple channels are configured: ${configuredChannels.join(", ")}`); } if (configuredChannels.length === 0) return; if (configuredChannels.includes(normalizedChannel)) return; throw new Error(`${params.field} must be one of: ${configuredChannels.join(", ")}`); } function resolveAnnounceValidationChannel(params) { if (params.channel && params.channel !== "last") return params.channel; return resolveTargetPrefixedChannel(params.to) ?? params.channel; } function assertCompatibleAnnounceTarget(params) { if (!params.channel || params.channel === "last") return; const error = validateTargetProviderPrefix({ channel: params.channel, to: params.to }); if (error) throw new Error(`${params.field}: ${error.message}`); } function assertValidCronAnnounceDelivery(params) { if (params.delivery && (params.delivery.mode ?? "announce") === "announce") { assertCompatibleAnnounceTarget({ channel: params.delivery.channel, to: params.delivery.to, field: "delivery.channel" }); assertConfiguredAnnounceChannel({ cfg: params.cfg, channel: resolveAnnounceValidationChannel({ channel: params.delivery.channel, to: params.delivery.to }), field: "delivery.channel" }); } const failureDestination = params.delivery?.failureDestination; if (failureDestination && (failureDestination.mode ?? "announce") === "announce") { if (failureDestination.channel === void 0 && failureDestination.to === void 0 && failureDestination.accountId === void 0 && failureDestination.mode === void 0) return; assertCompatibleAnnounceTarget({ channel: failureDestination.channel, to: failureDestination.to, field: "delivery.failureDestination.channel" }); assertConfiguredAnnounceChannel({ cfg: params.cfg, channel: resolveAnnounceValidationChannel({ channel: failureDestination.channel, to: failureDestination.to }), field: "delivery.failureDestination.channel" }); } } function assertValidCronCreateDelivery(cfg, jobCreate) { assertValidCronAnnounceDelivery({ cfg, delivery: jobCreate.delivery }); } function assertValidCronUpdatePatch(params) { const nextJob = structuredClone(params.currentJob); applyJobPatch(nextJob, params.patch, { defaultAgentId: params.defaultAgentId }); if ("delivery" in params.patch) assertValidCronAnnounceDelivery({ cfg: params.cfg, delivery: nextJob.delivery }); } function resolveCronJobId(params) { return params.id ?? params.jobId; } function respondInvalidCronParams(respond, method, reason) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid ${method} params: ${reason}`)); } function respondMissingCronJobId(respond, method) { respondInvalidCronParams(respond, method, "missing id"); } function cronRunLogPageFilters(params) { return { limit: params.limit, offset: params.offset, statuses: params.statuses, status: params.status, runId: params.runId, deliveryStatuses: params.deliveryStatuses, deliveryStatus: params.deliveryStatus, query: params.query, sortDir: params.sortDir }; } function isCronInvalidRequestError(err) { const message = formatErrorMessage(err); return message.startsWith("unknown cron job id:") || message.includes("cron job is missing sessionTarget") || message.includes("invalid cron sessionTarget session id") || message.includes("main cron jobs require payload.kind=\"systemEvent\"") || message.includes("isolated/current/session cron jobs require payload.kind=\"agentTurn\"") || message.includes("has no upcoming run time and would never fire") || message.includes("sessionTarget \"main\" is only valid for the default agent") || message.includes("cron.update payload.kind=\"systemEvent\" requires text") || message.includes("cron.update payload.kind=\"agentTurn\" requires message") || message.includes("cron webhook delivery requires") || message.includes("cron completion destination webhook requires") || message.includes("cron failure destination webhook requires") || message.includes("cron channel delivery config is only supported") || message.includes("cron delivery.failureDestination is only supported"); } /** Gateway request handlers for cron jobs and cron run-log access. */ const cronHandlers = { wake: ({ params, respond, context }) => { if (!validateWakeParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid wake params: ${formatValidationErrors(validateWakeParams.errors)}`)); return; } const p = params; const sessionKey = p.sessionKey?.trim() || void 0; const agentId = p.agentId?.trim() || void 0; if (sessionKey && isSubagentSessionKey(sessionKey)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "wake sessionKey cannot target a subagent session")); return; } const sessionKeyAgentId = sessionKey ? parseAgentSessionKey(sessionKey)?.agentId?.trim().toLowerCase() : void 0; if (agentId && sessionKeyAgentId && agentId.toLowerCase() !== sessionKeyAgentId) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "wake agentId contradicts the agent that owns sessionKey; pass a single canonical wake target")); return; } respond(true, context.cron.wake({ mode: p.mode, text: p.text, ...sessionKey ? { sessionKey } : {}, ...agentId ? { agentId } : {} }), void 0); }, "cron.list": async ({ params, respond, context }) => { if (!validateCronListParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.list params: ${formatValidationErrors(validateCronListParams.errors)}`)); return; } const p = params; const page = await context.cron.listPage({ includeDisabled: p.includeDisabled, limit: p.limit, offset: p.offset, query: p.query, enabled: p.enabled, scheduleKind: p.scheduleKind, lastRunStatus: p.lastRunStatus, sortBy: p.sortBy, sortDir: p.sortDir, agentId: p.agentId }); const deliveryPreviews = await resolveCronDeliveryPreviews({ cfg: context.getRuntimeConfig(), defaultAgentId: context.cron.getDefaultAgentId(), jobs: page.jobs }); respond(true, { ...page, deliveryPreviews }, void 0); }, "cron.status": async ({ params, respond, context }) => { if (!validateCronStatusParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.status params: ${formatValidationErrors(validateCronStatusParams.errors)}`)); return; } respond(true, await context.cron.status(), void 0); }, "cron.get": async ({ params, respond, context }) => { if (!validateCronGetParams(params)) { respondInvalidCronParams(respond, "cron.get", formatValidationErrors(validateCronGetParams.errors)); return; } const jobId = resolveCronJobId(params); if (!jobId) { respondMissingCronJobId(respond, "cron.get"); return; } const job = await context.cron.readJob(jobId); if (!job) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `cron job not found: ${jobId}`)); return; } respond(true, job, void 0); }, "cron.add": async ({ params, respond, context }) => { const sessionKey = typeof params?.sessionKey === "string" ? params.sessionKey : void 0; let normalized; try { assertCronDeliveryInputNonBlankFields(params?.delivery); normalized = normalizeCronJobCreate(params, { sessionContext: { sessionKey } }) ?? params; } catch (err) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.add params: ${formatErrorMessage(err)}`)); return; } if (!validateCronAddParams(normalized)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.add params: ${formatValidationErrors(validateCronAddParams.errors)}`)); return; } const jobCreate = normalized; const cfg = context.getRuntimeConfig(); const timestampValidation = validateScheduleTimestamp(jobCreate.schedule); if (!timestampValidation.ok) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, timestampValidation.message)); return; } try { assertValidCronCreateDelivery(cfg, jobCreate); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.add params: ${formatErrorMessage(err)}`)); return; } let job; try { job = await context.cron.add(jobCreate); } catch (err) { if (!(err instanceof TypeError) && !(err instanceof RangeError) && !isCronInvalidRequestError(err)) throw err; respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.add params: ${formatErrorMessage(err)}`)); return; } context.logGateway.info("cron: job created", { jobId: job.id, schedule: jobCreate.schedule }); respond(true, job, void 0); }, "cron.update": async ({ params, respond, context }) => { let normalizedPatch; try { const rawPatch = params?.patch; assertCronDeliveryInputNonBlankFields(rawPatch && typeof rawPatch === "object" ? rawPatch.delivery : void 0); normalizedPatch = normalizeCronJobPatch(rawPatch); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.update params: ${formatErrorMessage(err)}`)); return; } const candidate = normalizedPatch && typeof params === "object" && params !== null ? { ...params, patch: normalizedPatch } : params; if (!validateCronUpdateParams(candidate)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.update params: ${formatValidationErrors(validateCronUpdateParams.errors)}`)); return; } const p = candidate; const jobId = p.id ?? p.jobId; if (!jobId) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron.update params: missing id")); return; } const patch = p.patch; const cfg = context.getRuntimeConfig(); const currentJob = await context.cron.readJob(jobId); if (!currentJob) { respondInvalidCronParams(respond, "cron.update", "id not found"); return; } if (patch.schedule) { const timestampValidation = validateScheduleTimestamp(patch.schedule); if (!timestampValidation.ok) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, timestampValidation.message)); return; } } try { assertValidCronUpdatePatch({ cfg, defaultAgentId: context.cron.getDefaultAgentId(), currentJob, patch }); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.update params: ${formatErrorMessage(err)}`)); return; } let job; try { job = await context.cron.update(jobId, patch); } catch (err) { if (!(err instanceof TypeError) && !(err instanceof RangeError) && !isCronInvalidRequestError(err)) throw err; respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.update params: ${formatErrorMessage(err)}`)); return; } context.logGateway.info("cron: job updated", { jobId }); respond(true, job, void 0); }, "cron.remove": async ({ params, respond, context }) => { if (!validateCronRemoveParams(params)) { respondInvalidCronParams(respond, "cron.remove", formatValidationErrors(validateCronRemoveParams.errors)); return; } const jobId = resolveCronJobId(params); if (!jobId) { respondMissingCronJobId(respond, "cron.remove"); return; } const result = await context.cron.remove(jobId); if (!result.removed) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron.remove params: id not found")); return; } context.logGateway.info("cron: job removed", { jobId }); respond(true, result, void 0); }, "cron.run": async ({ params, respond, context }) => { if (!validateCronRunParams(params)) { respondInvalidCronParams(respond, "cron.run", formatValidationErrors(validateCronRunParams.errors)); return; } const p = params; const jobId = resolveCronJobId(p); if (!jobId) { respondMissingCronJobId(respond, "cron.run"); return; } let result; try { result = await context.cron.enqueueRun(jobId, p.mode ?? "force"); } catch (error) { if (isInvalidCronSessionTargetIdError(error)) { respond(true, { ok: true, ran: false, reason: "invalid-spec" }, void 0); return; } if (isCronInvalidRequestError(error)) { respondInvalidCronParams(respond, "cron.run", formatErrorMessage(error)); return; } throw error; } respond(true, result, void 0); }, "cron.runs": async ({ params, respond, context }) => { if (!validateCronRunsParams(params)) { respondInvalidCronParams(respond, "cron.runs", formatValidationErrors(validateCronRunsParams.errors)); return; } const p = params; const explicitScope = p.scope; const jobId = resolveCronJobId(p); const scope = explicitScope ?? (jobId ? "job" : "all"); if (scope === "job" && !jobId) { respondMissingCronJobId(respond, "cron.runs"); return; } if (scope === "all") { const jobs = await context.cron.list({ includeDisabled: true }); const jobNameById = Object.fromEntries(jobs.filter((job) => typeof job.id === "string" && typeof job.name === "string").map((job) => [job.id, job.name])); respond(true, await readCronRunLogEntriesPageAll({ storePath: context.cronStorePath, ...cronRunLogPageFilters(p), jobNameById }), void 0); return; } try { const matchedJob = (await context.cron.list({ includeDisabled: true })).find((job) => job.id === jobId); const jobNameById = matchedJob && typeof matchedJob.name === "string" ? { [jobId]: matchedJob.name } : void 0; respond(true, await readCronRunLogEntriesPage({ storePath: context.cronStorePath, jobId, ...cronRunLogPageFilters(p), jobNameById }), void 0); } catch (err) { if (!isInvalidCronRunLogJobIdError(err)) throw err; respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron.runs params: invalid id")); } } }; //#endregion export { cronHandlers };