openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
540 lines (539 loc) • 20.5 kB
JavaScript
import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.js";
import { m as shortenHomePath } from "./utils-P__uGsPB.js";
import { t as formatCliCommand } from "./command-format-C7YfyMTd.js";
import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js";
import { t as parseDurationMs } from "./parse-duration-CuuCHKpt.js";
import { r as executeSqliteQuerySync } from "./kysely-sync-COmh4HWh.js";
import { i as openOpenClawStateDatabase, s as runOpenClawStateWriteTransaction } from "./openclaw-state-db-BRTnL-D8.js";
import "./session-accessor-YsytfDtG.js";
import { d as patchSessionEntryCore } from "./session-accessor.sqlite-entry-CWk3jL7s.js";
import { a as loadCronRows, f as upsertCronJobRow, m as cronSchedulingInputsEqual, o as loadedCronStoreFromRows, p as getCronStoreKysely, t as assertCronStoreCanPersist } from "./row-codec-Bv7_IRNO.js";
import { f as resolveCronJobsStorePathFromConfig } from "./store-K9I-tbar.js";
import { t as cronStoreKey } from "./key-BBZ40bDq.js";
import { i as resolveHeartbeatIntervalMs, n as resolveHeartbeatAgents } from "./heartbeat-config-DYNUPVum.js";
import { t as note } from "./note-DqHk3fA1.js";
import { n as computeJobNextRunAtMs, s as hasScheduledNextRunAtMs } from "./jobs-scheduling-Dhi4wJlu.js";
import { a as readHeartbeatMonitorScratchReadOnly, i as readHeartbeatMonitorScratch } from "./scratch-store-BeMQfZeF.js";
import { n as heartbeatTaskDeclarationKey, r as isHeartbeatTaskCronJob } from "./heartbeat-task-ByPo-qx_.js";
import { t as resolveHeartbeatSession } from "./heartbeat-runner-session-Daebrrmj.js";
import { isDeepStrictEqual } from "node:util";
import { randomUUID } from "node:crypto";
//#region src/commands/heartbeat-task-legacy.ts
function splitHeartbeatLines(content) {
const lines = [];
for (const match of content.matchAll(/[^\r\n]*(?:\r\n|\n|\r|$)/g)) {
const source = match[0];
if (!source) continue;
const raw = source.replace(/(?:\r\n|\n|\r)$/, "");
lines.push({
raw,
source
});
}
return lines;
}
function scanHeartbeatLine(raw, state) {
let cursor = 0;
let hasHtmlComment = state.inHtmlComment;
let htmlCommentRaw = "";
let visible = "";
while (cursor < raw.length) {
if (state.inHtmlComment) {
const commentEnd = raw.indexOf("-->", cursor);
if (commentEnd === -1) {
htmlCommentRaw += raw.slice(cursor);
return {
hasHtmlComment,
htmlCommentRaw,
visible
};
}
htmlCommentRaw += raw.slice(cursor, commentEnd + 3);
state.inHtmlComment = false;
cursor = commentEnd + 3;
continue;
}
const commentStart = raw.indexOf("<!--", cursor);
if (commentStart === -1) {
const outside = raw.slice(cursor);
visible += outside;
htmlCommentRaw += outside.replace(/\S/g, "");
return {
hasHtmlComment,
htmlCommentRaw,
visible
};
}
const outside = raw.slice(cursor, commentStart);
visible += outside;
htmlCommentRaw += outside.replace(/\S/g, "") + "<!--";
hasHtmlComment = true;
state.inHtmlComment = true;
cursor = commentStart + 4;
}
return {
hasHtmlComment,
htmlCommentRaw,
visible
};
}
function tokenizeHeartbeatLines(content) {
const state = { inHtmlComment: false };
return splitHeartbeatLines(content).map((line) => {
const scanned = scanHeartbeatLine(line.raw, state);
const lineEnding = line.source.slice(line.raw.length);
const token = {
raw: line.raw,
source: line.source,
visible: scanned.visible
};
if (scanned.hasHtmlComment) token.htmlCommentSource = scanned.htmlCommentRaw + lineEnding;
return token;
});
}
function unquoteTaskValue(value) {
return value.trim().replace(/^["']|["']$/g, "");
}
/**
* Parses and marks removable task syntax in one pass. The same boundary decision
* therefore owns both cron creation and the bytes Doctor may remove.
*/
function analyzeLegacyHeartbeatTasks(content) {
const lines = tokenizeHeartbeatLines(content);
const removedLineIndexes = /* @__PURE__ */ new Set();
const tasks = [];
let taskEntryCount = 0;
let hasTasksBlock = false;
let inTasksBlock = false;
let currentTask;
let orphanEntryOpen = false;
const finishCurrentTask = () => {
if (currentTask?.name && currentTask.interval && currentTask.prompt) tasks.push({
name: currentTask.name,
interval: currentTask.interval,
prompt: currentTask.prompt
});
currentTask = void 0;
};
for (const [index, line] of lines.entries()) {
const trimmed = line.visible.trim();
if (trimmed === "tasks:") {
finishCurrentTask();
orphanEntryOpen = false;
hasTasksBlock = true;
inTasksBlock = true;
removedLineIndexes.add(index);
continue;
}
if (!inTasksBlock) continue;
if (!trimmed) {
if (!line.raw.trim()) removedLineIndexes.add(index);
continue;
}
if (trimmed.startsWith("- name:")) {
finishCurrentTask();
orphanEntryOpen = false;
taskEntryCount += 1;
currentTask = { name: unquoteTaskValue(trimmed.slice(7)) };
removedLineIndexes.add(index);
continue;
}
const isIndented = line.visible.startsWith(" ") || line.visible.startsWith(" ");
if (isIndented && trimmed.startsWith("interval:")) {
if (currentTask) currentTask.interval = unquoteTaskValue(trimmed.slice(9));
else if (!orphanEntryOpen) {
taskEntryCount += 1;
orphanEntryOpen = true;
}
removedLineIndexes.add(index);
continue;
}
if (isIndented && trimmed.startsWith("prompt:")) {
if (currentTask) currentTask.prompt = unquoteTaskValue(trimmed.slice(7));
else if (!orphanEntryOpen) {
taskEntryCount += 1;
orphanEntryOpen = true;
}
removedLineIndexes.add(index);
continue;
}
finishCurrentTask();
orphanEntryOpen = false;
inTasksBlock = false;
}
finishCurrentTask();
return {
hasTasksBlock,
taskEntryCount,
tasks,
strippedContent: lines.map((line, index) => removedLineIndexes.has(index) ? line.htmlCommentSource ?? "" : line.source).join("")
};
}
//#endregion
//#region src/commands/doctor-heartbeat-task-migration.ts
/** Doctor-owned migration from heartbeat scratch `tasks:` blocks into cron jobs. */
const HEARTBEAT_TASK_MIGRATION_CHECK_ID = "core/doctor/heartbeat-task-cron-migration";
function resolveHeartbeatTaskMigrationAgents(cfg) {
return resolveHeartbeatAgents(cfg).filter((agent) => resolveHeartbeatIntervalMs(cfg, void 0, agent.heartbeat) !== null);
}
function validateTasks(tasks, declaredEntryCount) {
if (tasks.length === 0) throw new Error("tasks: block has no complete name/interval/prompt entries");
if (tasks.length !== declaredEntryCount) throw new Error("tasks: block contains an incomplete name/interval/prompt entry");
const occurrenceCounts = /* @__PURE__ */ new Map();
const validated = [];
for (const task of tasks) {
const intervalMs = parseDurationMs(task.interval, { defaultUnit: "m" });
if (intervalMs <= 0) throw new Error(`task ${JSON.stringify(task.name)} interval must be greater than zero`);
const occurrenceIndex = occurrenceCounts.get(task.name) ?? 0;
occurrenceCounts.set(task.name, occurrenceIndex + 1);
validated.push({
task,
intervalMs,
occurrenceIndex
});
}
return validated;
}
function migrationFinding(params) {
return {
checkId: HEARTBEAT_TASK_MIGRATION_CHECK_ID,
severity: params.severity ?? "warning",
message: params.message,
path: params.storePath,
target: params.agentId,
requirement: params.requirement,
fixHint: `Run ${formatCliCommand("openclaw doctor --fix")} to convert heartbeat tasks into automations.`
};
}
/** Reports task blocks still owned by heartbeat scratch without changing them. */
async function collectHeartbeatTaskMigrationFindings(cfg, env = process.env) {
const storePath = resolveCronJobsStorePathFromConfig(cfg, env);
const findings = [];
for (const agent of resolveHeartbeatTaskMigrationAgents(cfg)) {
let monitor;
try {
monitor = readHeartbeatMonitorScratchReadOnly(storePath, agent.agentId, { env });
} catch (error) {
findings.push(migrationFinding({
storePath,
agentId: agent.agentId,
requirement: "heartbeat-task-migration-blocked",
severity: "error",
message: `Agent "${agent.agentId}" heartbeat scratch cannot be inspected: ${formatErrorMessage(error)}`
}));
continue;
}
const content = monitor?.state.scratch?.content;
if (!content) continue;
const document = analyzeLegacyHeartbeatTasks(content);
if (!document.hasTasksBlock) continue;
try {
validateTasks(document.tasks, document.taskEntryCount);
findings.push(migrationFinding({
storePath,
agentId: agent.agentId,
requirement: "heartbeat-tasks-in-scratch",
message: `Agent "${agent.agentId}" has ${document.tasks.length} heartbeat task${document.tasks.length === 1 ? "" : "s"} that must become cron jobs.`
}));
} catch (error) {
findings.push(migrationFinding({
storePath,
agentId: agent.agentId,
requirement: "heartbeat-task-migration-blocked",
severity: "error",
message: `Agent "${agent.agentId}" heartbeat tasks cannot be migrated: ${formatErrorMessage(error)}`
}));
}
}
return findings;
}
function taskJobInput(params) {
const existingAnchor = params.existing?.schedule.kind === "every" && params.existing.schedule.everyMs === params.intervalMs ? params.existing.schedule.anchorMs : void 0;
const nextDueMs = params.lastRunAtMs === void 0 || params.lastRunAtMs + params.intervalMs <= params.nowMs ? params.nowMs + 1 : params.lastRunAtMs + params.intervalMs;
return {
declarationKey: heartbeatTaskDeclarationKey(params.agentId, params.task.name, params.occurrenceIndex),
displayName: truncateUtf16Safe(`Heartbeat task: ${params.task.name}`, 200),
name: params.task.name,
description: "Migrated from heartbeat monitor scratch by openclaw doctor.",
agentId: params.agentId,
enabled: true,
schedule: {
kind: "every",
everyMs: params.intervalMs,
anchorMs: existingAnchor ?? nextDueMs
},
payload: {
kind: "systemEvent",
text: params.task.prompt
},
sessionTarget: "main",
wakeMode: "next-heartbeat",
...params.lastRunAtMs === void 0 ? {} : { state: { lastRunAtMs: params.lastRunAtMs } }
};
}
function taskDeclarativeFields(job) {
return {
schedule: job.schedule,
pacing: job.pacing,
trigger: job.trigger,
payload: job.payload,
delivery: job.delivery,
displayName: job.displayName,
enabled: job.enabled
};
}
function convergeTaskJob(params) {
const input = taskJobInput(params);
if (!params.existing) {
const { state, ...fields } = input;
const job = {
id: randomUUID(),
...fields,
createdAtMs: params.nowMs,
updatedAtMs: params.nowMs,
state: { ...state }
};
job.state.nextRunAtMs = computeJobNextRunAtMs(job, params.nowMs);
return job;
}
const previous = params.existing;
const job = structuredClone(previous);
job.displayName = input.displayName;
job.schedule = structuredClone(input.schedule);
job.payload = structuredClone(input.payload);
job.enabled = true;
delete job.pacing;
delete job.trigger;
delete job.delivery;
if (isDeepStrictEqual(taskDeclarativeFields(previous), taskDeclarativeFields(job))) return job;
job.updatedAtMs = params.nowMs;
if (!cronSchedulingInputsEqual(previous, job)) {
job.state.startupCatchupAtMs = void 0;
job.state.pacedNextRunAtMs = void 0;
job.state.forcePreservedNextRunAtMs = void 0;
job.state.nextRunAtMs = computeJobNextRunAtMs(job, params.nowMs);
} else if (!hasScheduledNextRunAtMs(job.state.nextRunAtMs)) job.state.nextRunAtMs = computeJobNextRunAtMs(job, params.nowMs);
return job;
}
async function loadCronPlanningSnapshot(storePath, env) {
const rows = loadCronRows(openOpenClawStateDatabase({ env }).db, cronStoreKey(storePath));
const sortOrderByJobId = new Map(rows.map((row) => [row.job_id, row.sort_order]));
return {
jobs: loadedCronStoreFromRows(rows).store.jobs,
sortOrderByJobId,
nextSortOrder: rows.reduce((max, row) => Math.max(max, row.sort_order + 1), 0)
};
}
function reserveSortOrder(snapshot, existing) {
const persisted = existing ? snapshot.sortOrderByJobId.get(existing.id) : void 0;
if (persisted !== void 0) return persisted;
const sortOrder = snapshot.nextSortOrder;
snapshot.nextSortOrder += 1;
return sortOrder;
}
function readScratchRevision(db, storeKey, jobId) {
return executeSqliteQuerySync(db, getCronStoreKysely(db).selectFrom("cron_job_scratch").select("revision").where("store_key", "=", storeKey).where("job_id", "=", jobId)).rows[0]?.revision ?? 0;
}
function commitAgentTaskMigration(params) {
const storeKey = cronStoreKey(params.storePath);
return runOpenClawStateWriteTransaction(({ db }) => {
if (readScratchRevision(db, storeKey, params.plan.monitorJobId) !== params.plan.scratchRevision) return {
ok: false,
reason: "revision-conflict"
};
const rows = loadCronRows(db, storeKey);
const jobsById = new Map(loadedCronStoreFromRows(rows).store.jobs.map((job) => [job.id, job]));
for (const jobPlan of params.plan.jobs) {
const matchingRows = rows.filter((row) => row.declaration_key === jobPlan.declarationKey);
if (jobPlan.previous) {
const current = jobsById.get(jobPlan.previous.id);
if (matchingRows.length !== 1 || !current || !isDeepStrictEqual(current, jobPlan.previous)) return {
ok: false,
reason: "job-conflict"
};
} else if (matchingRows.length > 0 || rows.some((row) => row.job_id === jobPlan.job.id)) return {
ok: false,
reason: "job-conflict"
};
}
for (const jobPlan of params.plan.jobs) if (!jobPlan.previous || !isDeepStrictEqual(jobPlan.previous, jobPlan.job)) upsertCronJobRow(db, storeKey, jobPlan.job, jobPlan.sortOrder);
if (executeSqliteQuerySync(db, getCronStoreKysely(db).updateTable("cron_job_scratch").set({
content: params.plan.strippedContent,
revision: params.plan.scratchRevision + 1,
source_sha256: params.plan.sourceSha256 ?? null,
updated_at_ms: params.nowMs
}).where("store_key", "=", storeKey).where("job_id", "=", params.plan.monitorJobId).where("revision", "=", params.plan.scratchRevision)).numAffectedRows !== 1n) throw new Error("scratch revision changed inside task migration transaction");
return {
ok: true,
currentRevision: params.plan.scratchRevision + 1
};
}, { env: params.env }, { operationLabel: "doctor.heartbeat-task-migration" });
}
async function clearLegacyTaskTimestamps(params) {
await patchSessionEntryCore({
storePath: params.storePath,
sessionKey: params.sessionKey,
env: params.env
}, (entry) => {
const remaining = { ...entry.heartbeatTaskState };
let changed = false;
for (const task of params.tasks) if (Object.hasOwn(remaining, task.name)) {
delete remaining[task.name];
changed = true;
}
if (!changed) return null;
return { heartbeatTaskState: Object.keys(remaining).length > 0 ? remaining : void 0 };
}, { preserveActivity: true });
}
/** Converts valid scratch tasks and removes their source block in one SQLite transaction. */
async function maybeMigrateHeartbeatTasksToCron(params) {
const env = params.env ?? process.env;
const nowMs = params.nowMs ?? Date.now();
const storePath = resolveCronJobsStorePathFromConfig(params.cfg, env);
const changes = [];
const warnings = [];
const candidates = [];
for (const agent of resolveHeartbeatTaskMigrationAgents(params.cfg)) {
let monitor;
try {
monitor = readHeartbeatMonitorScratch(storePath, agent.agentId, { env });
} catch (error) {
warnings.push(`Agent "${agent.agentId}" heartbeat scratch could not be inspected: ${formatErrorMessage(error)}.`);
continue;
}
const scratch = monitor?.state.scratch;
if (!monitor || !scratch) continue;
const document = analyzeLegacyHeartbeatTasks(scratch.content);
if (!document.hasTasksBlock) continue;
const tasks = document.tasks;
let validatedTasks;
try {
validatedTasks = validateTasks(tasks, document.taskEntryCount);
} catch (error) {
warnings.push(`Agent "${agent.agentId}" heartbeat tasks were not migrated: ${formatErrorMessage(error)}.`);
continue;
}
if (!params.shouldRepair) {
note(`${tasks.length} task${tasks.length === 1 ? "" : "s"} in ${shortenHomePath(storePath)} will become independently scheduled cron jobs for agent "${agent.agentId}".`, "Heartbeat task migration preview");
continue;
}
candidates.push({
agent,
document,
monitor,
scratchRevision: scratch.revision,
validatedTasks
});
}
if (!params.shouldRepair || candidates.length === 0) {
if (warnings.length > 0) note(warnings.join("\n"), "Doctor warnings");
return {
changes,
warnings
};
}
let snapshot;
try {
snapshot = await loadCronPlanningSnapshot(storePath, env);
} catch (error) {
const warning = `Could not inspect cron jobs for heartbeat task migration: ${formatErrorMessage(error)}`;
note(warning, "Doctor warnings");
return {
changes,
warnings: [...warnings, warning]
};
}
for (const candidate of candidates) {
const { agent, document, monitor, scratchRevision, validatedTasks } = candidate;
const session = resolveHeartbeatSession(params.cfg, agent.agentId, agent.heartbeat, void 0, env);
const legacyState = session.entry?.heartbeatTaskState ?? {};
const jobPlans = [];
let blocked = false;
for (const { task, intervalMs, occurrenceIndex } of validatedTasks) {
const declarationKey = heartbeatTaskDeclarationKey(agent.agentId, task.name, occurrenceIndex);
const matches = snapshot.jobs.filter((job) => job.declarationKey === declarationKey);
const existing = matches[0];
if (matches.length > 1 || existing && (!isHeartbeatTaskCronJob(existing) || existing.agentId !== agent.agentId || existing.name !== task.name)) {
warnings.push(`Agent "${agent.agentId}" task ${JSON.stringify(task.name)} collides with an incompatible cron declaration; scratch was left unchanged.`);
blocked = true;
break;
}
const legacyLastRun = legacyState[task.name];
const lastRunAtMs = typeof legacyLastRun === "number" && Number.isFinite(legacyLastRun) ? legacyLastRun : void 0;
const job = convergeTaskJob({
agentId: agent.agentId,
task,
occurrenceIndex,
intervalMs,
lastRunAtMs,
existing,
nowMs
});
const sortOrder = reserveSortOrder(snapshot, existing);
jobPlans.push({
declarationKey,
...existing ? { previous: structuredClone(existing) } : {},
job,
sortOrder
});
}
if (blocked) continue;
try {
assertCronStoreCanPersist({
version: 1,
jobs: jobPlans.map((plan) => plan.job)
});
} catch (error) {
warnings.push(`Agent "${agent.agentId}" task jobs could not be planned: ${formatErrorMessage(error)}. Scratch was left unchanged.`);
continue;
}
const plan = {
monitorJobId: monitor.jobId,
scratchRevision,
...monitor.state.scratch?.sourceSha256 ? { sourceSha256: monitor.state.scratch.sourceSha256 } : {},
strippedContent: document.strippedContent,
jobs: jobPlans
};
let committed;
try {
committed = commitAgentTaskMigration({
storePath,
env,
nowMs,
plan
});
} catch (error) {
warnings.push(`Agent "${agent.agentId}" task migration could not be committed: ${formatErrorMessage(error)}. Scratch and cron jobs were left unchanged.`);
continue;
}
if (!committed.ok) {
warnings.push(committed.reason === "revision-conflict" ? `Agent "${agent.agentId}" scratch changed during task migration; no changes were committed.` : `Agent "${agent.agentId}" cron jobs changed during task migration; no changes were committed.`);
continue;
}
for (const jobPlan of jobPlans) {
const index = snapshot.jobs.findIndex((job) => job.id === jobPlan.job.id);
if (index >= 0) snapshot.jobs[index] = jobPlan.job;
else snapshot.jobs.push(jobPlan.job);
snapshot.sortOrderByJobId.set(jobPlan.job.id, jobPlan.sortOrder);
}
changes.push(`Converted ${document.tasks.length} heartbeat task${document.tasks.length === 1 ? "" : "s"} into cron jobs for agent "${agent.agentId}".`);
try {
await clearLegacyTaskTimestamps({
storePath: session.storePath,
sessionKey: session.sessionKey,
env,
tasks: document.tasks
});
} catch (error) {
warnings.push(`Agent "${agent.agentId}" legacy task timestamps could not be cleared after migration: ${formatErrorMessage(error)}. Cron jobs remain authoritative and a rerun is safe.`);
}
}
if (changes.length > 0) note(changes.join("\n"), "Doctor changes");
if (warnings.length > 0) note(warnings.join("\n"), "Doctor warnings");
return {
changes,
warnings
};
}
//#endregion
export { collectHeartbeatTaskMigrationFindings, maybeMigrateHeartbeatTasksToCron };