UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

447 lines (446 loc) 20.4 kB
import { f as asSafeIntegerInRange, x as parseStrictFiniteNumber } from "./number-coercion-CLj0HTDM.js"; import "./src-vebZIeLe.js"; import { c as isRecord } from "./record-coerce-DItp3I4t.js"; import { t as safeParseJson } from "./json-coercion-AulM0PZ6.js"; import { l as normalizeOptionalString } from "./string-coerce-CIXf7egm.js"; import { n as normalizeAgentId } from "./agent-id-CeT3w4ap.js"; import { O as parseAgentSessionKey } from "./session-key-BnWWjqNc.js"; import { a as getNodeSqliteKysely, c as sqliteStringSet, r as executeSqliteQuerySync } from "./kysely-sync-COmh4HWh.js"; import { vt as normalizeSqliteNumber } from "./openclaw-state-db-BRTnL-D8.js"; import { a as sha256Hex } from "./crypto-digest-C4hqTb_e.js"; import { n as normalizeCronJobInput } from "./normalize-Dd-LvsuI.js"; import { a as normalizeCronToolsAllowExecTarget, l as restoreCronPinnedExecGrant, o as normalizeCronToolsAllowExecTargetRequirement, u as stripCronPinnedExecGrant } from "./scheduled-tool-policy-WcH3cpLk.js"; import { t as coerceFiniteScheduleNumber } from "./schedule-number-dcwHraee.js"; import { n as normalizeCronStaggerMs } from "./types-DQrueG6d.js"; import { n as getInvalidPersistedCronJobReason } from "./persisted-shape-BvaoExR0.js"; import { t as parseCronPacingBounds } from "./pacing-D3hd6hdq.js"; //#region src/cron/normalize-job-identity.ts /** Repairs legacy cron job identity fields into the canonical id shape. */ /** Normalizes mutable cron job rows from old `jobId` storage into the canonical `id` field. */ function normalizeCronJobIdentityFields(raw) { const rawId = normalizeOptionalString(raw.id) ?? ""; const legacyJobId = normalizeOptionalString(raw.jobId) ?? ""; const hadJobIdKey = "jobId" in raw; const normalizedId = rawId || legacyJobId; const idChanged = Boolean(normalizedId && raw.id !== normalizedId); if (idChanged) raw.id = normalizedId; if (hadJobIdKey) delete raw.jobId; return { mutated: idChanged || hadJobIdKey, legacyJobIdIssue: hadJobIdKey }; } //#endregion //#region src/cron/schedule-identity.ts /** Builds stable identities for cron scheduling inputs. */ function readScheduleTime(record, key) { return coerceFiniteScheduleNumber(record[key]); } function readScheduleInteger(record, key) { const parsed = parseStrictFiniteNumber(record[key]); return asSafeIntegerInRange(parsed, { min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }); } function schedulePayloadFromRecord(schedule) { const rawKind = normalizeOptionalString(schedule.kind)?.toLowerCase(); const expr = normalizeOptionalString(schedule.expr); const at = normalizeOptionalString(schedule.at); const everyMs = readScheduleTime(schedule, "everyMs"); const anchorMs = readScheduleTime(schedule, "anchorMs"); const tz = normalizeOptionalString(schedule.tz); const staggerMs = normalizeCronStaggerMs(schedule.staggerMs); const kind = rawKind === "at" || rawKind === "every" || rawKind === "cron" || rawKind === "on-exit" || rawKind === "stream" ? rawKind : at ? "at" : everyMs !== void 0 ? "every" : expr ? "cron" : void 0; if (kind === "at") return at ? { kind: "at", at } : void 0; if (kind === "every" && everyMs !== void 0) return { kind: "every", everyMs, anchorMs }; if (kind === "cron" && expr) return { kind: "cron", expr, tz, staggerMs }; if (kind === "on-exit") { const command = normalizeOptionalString(schedule.command); return command ? { kind: "on-exit", command, cwd: normalizeOptionalString(schedule.cwd) } : void 0; } if (kind === "stream") { const command = schedule.command; if (!Array.isArray(command) || command.length === 0 || command.some((entry) => typeof entry !== "string" || entry.length === 0)) return; const mode = normalizeOptionalString(schedule.mode); return { kind: "stream", command: [...command], cwd: normalizeOptionalString(schedule.cwd), mode: mode === "line" || mode === "match" ? mode : void 0, match: typeof schedule.match === "string" ? schedule.match : void 0, batchMs: readScheduleInteger(schedule, "batchMs"), maxBatchBytes: readScheduleInteger(schedule, "maxBatchBytes") }; } } function resolvePacingPayload(job) { if (job.pacing === void 0 || job.pacing === null) return; if (typeof job.pacing !== "object" || Array.isArray(job.pacing)) return null; const pacing = job.pacing; const min = normalizeOptionalString(pacing.min); const max = normalizeOptionalString(pacing.max); try { return parseCronPacingBounds({ min, max }); } catch { return null; } } /** Builds a stable scheduling identity for deciding whether stored timer state is still valid. */ function tryCronScheduleIdentity(job) { const schedule = job.schedule && typeof job.schedule === "object" && !Array.isArray(job.schedule) ? schedulePayloadFromRecord(job.schedule) : void 0; const pacing = resolvePacingPayload(job); if (!schedule || pacing === null) return; return JSON.stringify({ version: 2, enabled: typeof job.enabled === "boolean" ? job.enabled : true, schedule, pacing, hasTrigger: job.trigger !== void 0 && job.trigger !== null }); } /** Compares two cron jobs by the normalized inputs that affect next-run computation. */ function cronSchedulingInputsEqual(previous, next) { const previousIdentity = tryCronScheduleIdentity(previous); const nextIdentity = tryCronScheduleIdentity(next); return previousIdentity !== void 0 && previousIdentity === nextIdentity; } //#endregion //#region src/cron/store/delivery-codec.ts /** JSON codec for cron delivery configuration and explicit destination clears. */ const FAILURE_DESTINATION_FIELDS = [ "channel", "to", "accountId", "mode" ]; /** Encodes explicitly undefined failure overrides as durable JSON null values. */ function deliveryToJson(delivery) { const failureDestination = delivery.failureDestination; if (!failureDestination) return { ...delivery }; return { ...delivery, failureDestination: Object.fromEntries(FAILURE_DESTINATION_FIELDS.filter((field) => Object.hasOwn(failureDestination, field)).map((field) => [field, failureDestination[field] ?? null])) }; } /** Restores JSON null overrides as present-but-undefined runtime properties. */ function deliveryFromJson(value) { if (!isRecord(value) || value.mode !== "none" && value.mode !== "announce" && value.mode !== "webhook") return; const failureDestination = value.failureDestination; if (!isRecord(failureDestination)) return value; return { ...value, failureDestination: Object.fromEntries(FAILURE_DESTINATION_FIELDS.filter((field) => Object.hasOwn(failureDestination, field)).map((field) => [field, failureDestination[field] ?? void 0])) }; } //#endregion //#region src/cron/store/scalar-codec.ts function tryParseJsonObject(raw) { const parsed = safeParseJson(raw); return isRecord(parsed) ? parsed : void 0; } //#endregion //#region src/cron/store/schema.ts /** Creates the Kysely facade scoped to cron_jobs for synchronous SQLite access. */ function getCronStoreKysely(db) { return getNodeSqliteKysely(db); } //#endregion //#region src/cron/store/row-codec.ts function stripJobRuntimeFields(job) { const { runtimeAuthority: _runtimeAuthority, runtimeAuthorityRecoveryRequired: _runtimeAuthorityRecoveryRequired, state: _state, updatedAtMs: _updatedAtMs, ...rest } = job; const payload = isRecord(rest.payload) ? rest.payload : void 0; const toolsAllow = Array.isArray(payload?.toolsAllow) ? payload.toolsAllow.filter((tool) => typeof tool === "string") : void 0; const storedToolsAllow = stripCronPinnedExecGrant({ toolsAllow, requirement: rest.toolsAllowExecTargetRequirement }); return { ...rest, ...payload && storedToolsAllow ? { payload: { ...payload, toolsAllow: storedToolsAllow } } : {}, ...rest.delivery ? { delivery: deliveryToJson(rest.delivery) } : {}, state: {} }; } function serializeCronJobState(state) { return JSON.stringify({ ...state, ...state.lastRunStatus === void 0 && state.lastStatus !== void 0 ? { lastRunStatus: state.lastStatus } : {} }); } function bindCronJobRow(storeKey, job, sortOrder) { return { store_key: storeKey, job_id: job.id, declaration_key: job.declarationKey ?? null, owner_agent_id: job.owner?.agentId ?? null, name: job.name, description: job.description ?? null, enabled: job.enabled ? 1 : 0, updated_at: job.updatedAtMs, agent_id: job.agentId ?? null, payload_kind: job.payload.kind, job_json: JSON.stringify(stripJobRuntimeFields(job)), state_json: serializeCronJobState(job.state ?? {}), runtime_updated_at_ms: job.updatedAtMs, schedule_identity: tryCronScheduleIdentity({ ...job }) ?? null, sort_order: sortOrder }; } function normalizeCronJobForSqlite(job) { const raw = { ...structuredClone(job) }; const hadDeleteAfterRun = Object.hasOwn(raw, "deleteAfterRun"); normalizeCronJobIdentityFields(raw); const normalized = normalizeCronJobInput(raw, { applyDefaults: true }); if (!normalized || getInvalidPersistedCronJobReason(normalized)) return null; if (!hadDeleteAfterRun) delete normalized.deleteAfterRun; const createdAtMs = typeof normalized.createdAtMs === "number" && Number.isFinite(normalized.createdAtMs) ? normalized.createdAtMs : Date.now(); const updatedAtMs = typeof normalized.updatedAtMs === "number" && Number.isFinite(normalized.updatedAtMs) ? normalized.updatedAtMs : createdAtMs; return { ...normalized, createdAtMs, updatedAtMs, state: isRecord(normalized.state) ? normalized.state : {} }; } function countUnpersistableCronJobs(store) { return store.jobs.reduce((count, job) => count + (normalizeCronJobForSqlite(job) ? 0 : 1), 0); } /** Fails before replacing SQLite rows when any config job cannot round-trip. */ function assertCronStoreCanPersist(store) { const invalidJobs = countUnpersistableCronJobs(store); if (invalidJobs > 0) throw new Error(`Cannot persist cron store with ${invalidJobs} invalid job(s)`); } function decodeCronJobConfig(jobJson) { const delivery = deliveryFromJson(jobJson.delivery); return delivery ? { ...jobJson, delivery } : jobJson; } function rowToCronJob(row, jobJson) { const state = tryParseJsonObject(row.state_json); if (!state || getInvalidPersistedCronJobReason(jobJson)) return null; const toolsAllowExecTarget = normalizeCronToolsAllowExecTarget(jobJson.toolsAllowExecTarget); const toolsAllowExecTargetRequirement = normalizeCronToolsAllowExecTargetRequirement(jobJson.toolsAllowExecTargetRequirement); const createdAtMs = typeof jobJson.createdAtMs === "number" && Number.isFinite(jobJson.createdAtMs) ? jobJson.createdAtMs : Date.now(); const { notify: _legacyNotify, toolsAllowExecTarget: _rawToolsAllowExecTarget, toolsAllowExecTargetRequirement: _rawToolsAllowExecTargetRequirement, ...runtimeConfig } = decodeCronJobConfig(jobJson); const payload = isRecord(runtimeConfig.payload) ? runtimeConfig.payload : void 0; const toolsAllow = Array.isArray(payload?.toolsAllow) ? payload.toolsAllow.filter((tool) => typeof tool === "string") : void 0; const runtimeToolsAllow = restoreCronPinnedExecGrant({ toolsAllow, requirement: toolsAllowExecTargetRequirement, execTarget: toolsAllowExecTarget }); if (payload && runtimeToolsAllow) runtimeConfig.payload = { ...payload, toolsAllow: runtimeToolsAllow }; if (isRecord(runtimeConfig.delivery) && runtimeConfig.delivery.mode === void 0) runtimeConfig.delivery = deliveryFromJson({ ...runtimeConfig.delivery, mode: "announce" }); return { ...runtimeConfig, id: row.job_id, ...toolsAllowExecTarget ? { toolsAllowExecTarget } : {}, ...toolsAllowExecTargetRequirement ? { toolsAllowExecTargetRequirement } : {}, createdAtMs, updatedAtMs: normalizeSqliteNumber(row.runtime_updated_at_ms) ?? normalizeSqliteNumber(row.updated_at) ?? createdAtMs, state }; } /** Projects a live job through the same normalization/codecs used by SQLite persistence. */ function projectCronJobThroughStorageCodec(job) { const normalized = normalizeCronJobForSqlite(job); if (!normalized) throw new Error(`cannot project invalid cron job ${job.id}`); const row = bindCronJobRow("config-revision", normalized, 0); const projected = rowToCronJob(row, tryParseJsonObject(row.job_json) ?? {}); if (!projected) throw new Error(`cannot project cron job ${job.id} through storage codecs`); return projected; } /** Loads cron rows in config order with deterministic fallbacks for old rows. */ function loadCronRows(db, storeKey, jobIds) { let query = getCronStoreKysely(db).selectFrom("cron_jobs").selectAll().where("store_key", "=", storeKey).orderBy("sort_order", "asc").orderBy("updated_at", "asc").orderBy("job_id", "asc"); if (jobIds) { const ids = [...jobIds]; query = ids.length === 1 ? query.where("job_id", "=", ids[0]) : query.where("job_id", "in", sqliteStringSet(ids)); } const rows = executeSqliteQuerySync(db, query).rows; return jobIds ? rows.filter((row) => jobIds.has(row.job_id)) : rows; } /** Fingerprints raw definition rows without mutating their config order. */ function fingerprintCronJobRows(rows) { const ordered = rows.map(({ job_id, job_json, sort_order }) => ({ idBytes: Buffer.from(job_id), definition: { job_id, job_json, sort_order } })).toSorted((left, right) => Buffer.compare(left.idBytes, right.idBytes)); return sha256Hex(JSON.stringify(ordered.map(({ definition }) => definition))); } /** Reads only definition JSON and order while excluding runtime-owned state. */ function readCronJobsFingerprint(db, storeKey) { const rows = executeSqliteQuerySync(db, getCronStoreKysely(db).selectFrom("cron_jobs").select([ "job_id", "job_json", "sort_order" ]).where("store_key", "=", storeKey)).rows; return fingerprintCronJobRows(rows); } /** Materializes retired ownership within the caller's write transaction. */ function materializeCronRowAgentOwners(db, storeKey, legacyDefaultAgentId) { const agentId = normalizeAgentId(legacyDefaultAgentId); let rewritten = 0; for (const row of loadCronRows(db, storeKey)) { const jobJson = tryParseJsonObject(row.job_json); const jsonSessionAgentId = parseAgentSessionKey(normalizeOptionalString(jobJson?.sessionKey))?.agentId; if (normalizeOptionalString(row.agent_id) || normalizeOptionalString(jobJson?.agentId) || jsonSessionAgentId) continue; if (jobJson) jobJson.agentId = agentId; executeSqliteQuerySync(db, getCronStoreKysely(db).updateTable("cron_jobs").set({ agent_id: agentId, ...jobJson ? { job_json: JSON.stringify(jobJson) } : {} }).where("store_key", "=", storeKey).where("job_id", "=", row.job_id)); rewritten += 1; } return rewritten; } /** Removes one owned job family from obsolete store partitions. */ function deleteStaleCronJobFamilyRows(db, activeStoreKey, family) { const staleRows = executeSqliteQuerySync(db, getCronStoreKysely(db).selectFrom("cron_jobs").select([ "store_key", "job_id", "declaration_key", "name", "description" ]).where("store_key", "!=", activeStoreKey)).rows.filter((row) => row.declaration_key === family.declarationKey || row.name === family.name && row.description?.includes(family.ownerPluginTag) === true); for (const row of staleRows) { executeSqliteQuerySync(db, getCronStoreKysely(db).deleteFrom("cron_job_scratch").where("store_key", "=", row.store_key).where("job_id", "=", row.job_id)); executeSqliteQuerySync(db, getCronStoreKysely(db).deleteFrom("cron_jobs").where("store_key", "=", row.store_key).where("job_id", "=", row.job_id)); } return staleRows.length; } function replaceCronRows(db, storeKey, store, opts) { const existingRows = executeSqliteQuerySync(db, getCronStoreKysely(db).selectFrom("cron_jobs").select(["job_id", "job_json"]).where("store_key", "=", storeKey)).rows; const normalizedJobs = []; for (const [index, job] of store.jobs.entries()) normalizedJobs.push(upsertCronJobRow(db, storeKey, job, index, opts)); const nextJobIds = new Set(normalizedJobs.map((job) => job.id)); const existingJobIds = /* @__PURE__ */ new Set(); const legacyAuthorityJobIds = /* @__PURE__ */ new Set(); for (const row of existingRows) { existingJobIds.add(row.job_id); const storedJob = tryParseJsonObject(row.job_json); if (storedJob && (Object.hasOwn(storedJob, "runtimeAuthority") || Object.hasOwn(storedJob, "runtimeAuthorityRecoveryRequired"))) legacyAuthorityJobIds.add(row.job_id); if (nextJobIds.has(row.job_id)) continue; executeSqliteQuerySync(db, getCronStoreKysely(db).deleteFrom("cron_jobs").where("store_key", "=", storeKey).where("job_id", "=", row.job_id)); } return { existingJobIds, jobs: normalizedJobs, legacyAuthorityJobIds }; } /** Upserts one persisted cron row without rewriting unrelated jobs in its store partition. */ function upsertCronJobRow(db, storeKey, job, sortOrder, opts) { const normalized = normalizeCronJobForSqlite(job); if (!normalized) throw new Error(`Cannot persist invalid cron job ${job.id}`); const values = bindCronJobRow(storeKey, normalized, sortOrder); const { state_json: _stateJson, runtime_updated_at_ms: _runtimeUpdatedAtMs, ...definitionValues } = values; executeSqliteQuerySync(db, getCronStoreKysely(db).insertInto("cron_jobs").values(values).onConflict((conflict) => conflict.columns(["store_key", "job_id"]).doUpdateSet(opts?.preserveRuntimeState ? definitionValues : values))); return normalized; } function deleteCronJobRowInDatabase(db, storeKey, jobId) { executeSqliteQuerySync(db, getCronStoreKysely(db).deleteFrom("cron_job_scratch").where("store_key", "=", storeKey).where("job_id", "=", jobId)); executeSqliteQuerySync(db, getCronStoreKysely(db).deleteFrom("cron_jobs").where("store_key", "=", storeKey).where("job_id", "=", jobId)); } /** Updates only mutable runtime columns without rewriting full job config JSON. */ function updateCronRuntimeRows(db, storeKey, store) { for (const job of store.jobs) executeSqliteQuerySync(db, getCronStoreKysely(db).updateTable("cron_jobs").set({ state_json: serializeCronJobState(job.state ?? {}), runtime_updated_at_ms: job.updatedAtMs, schedule_identity: tryCronScheduleIdentity({ ...job }) }).where("store_key", "=", storeKey).where("job_id", "=", job.id)); } /** Reconstructs loaded cron store data and config-runtime sidecars from SQLite rows. */ function loadedCronStoreFromRows(rows) { const jobs = []; const configJobs = []; const configJobIndexes = []; const configJobRuntimeEntries = []; const invalidConfigRows = []; for (const [index, row] of rows.entries()) { const parsedJobJson = tryParseJsonObject(row.job_json); const parsedStateJson = tryParseJsonObject(row.state_json); if (!parsedJobJson || !parsedStateJson) { invalidConfigRows.push({ sourceIndex: index, reason: parsedJobJson ? "invalid-state" : "invalid-payload", ...parsedJobJson ? { job: decodeCronJobConfig(parsedJobJson) } : {}, raw: { jobId: row.job_id, jobJson: row.job_json, stateJson: row.state_json } }); continue; } const job = rowToCronJob(row, parsedJobJson); const configJob = decodeCronJobConfig(parsedJobJson); const runtimeEntry = { updatedAtMs: normalizeSqliteNumber(row.runtime_updated_at_ms) ?? normalizeSqliteNumber(row.updated_at), scheduleIdentity: row.schedule_identity ?? void 0, state: parsedStateJson }; if (!job) { invalidConfigRows.push({ sourceIndex: index, reason: getInvalidPersistedCronJobReason(configJob) ?? "invalid-payload", job: configJob, ...runtimeEntry.state ? { state: runtimeEntry.state } : {}, ...runtimeEntry.updatedAtMs !== void 0 ? { updatedAtMs: runtimeEntry.updatedAtMs } : {}, ...runtimeEntry.scheduleIdentity !== void 0 ? { scheduleIdentity: runtimeEntry.scheduleIdentity } : {} }); continue; } jobs.push(job); configJobs.push(configJob); configJobIndexes.push(index); configJobRuntimeEntries.push(runtimeEntry); } return { store: { version: 1, jobs }, configJobs, configJobIndexes, configJobRuntimeEntries, invalidConfigRows }; } //#endregion export { loadCronRows as a, projectCronJobThroughStorageCodec as c, updateCronRuntimeRows as d, upsertCronJobRow as f, normalizeCronJobIdentityFields as g, tryCronScheduleIdentity as h, fingerprintCronJobRows as i, readCronJobsFingerprint as l, cronSchedulingInputsEqual as m, deleteCronJobRowInDatabase as n, loadedCronStoreFromRows as o, getCronStoreKysely as p, deleteStaleCronJobFamilyRows as r, materializeCronRowAgentOwners as s, assertCronStoreCanPersist as t, replaceCronRows as u };