UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

451 lines (450 loc) 19.6 kB
import { r as isPathInside } from "./path-guards-Cp-mGr3-.js"; import { m as shortenHomePath } from "./utils-P__uGsPB.js"; import { t as escapeRegExp } from "./regexp-BZyMFTlj.js"; import { t as formatCliCommand } from "./command-format-C7YfyMTd.js"; import { m as resolveAgentWorkspaceDir } from "./agent-scope-config-DcbEhP0R.js"; import { w as resolveStateDir } from "./paths-D2sRr1a_.js"; import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js"; import { r as readRegularFile } from "./regular-file-Equ4rrfE.js"; import "./agent-scope-DbtJyKUL.js"; import { f as resolveCronJobsStorePathFromConfig } from "./store-K9I-tbar.js"; import { t as CRON_JOB_SCRATCH_MAX_BYTES } from "./scratch-contract-B-Laspel.js"; import { i as resolveHeartbeatIntervalMs, n as resolveHeartbeatAgents } from "./heartbeat-config-DYNUPVum.js"; import { t as note } from "./note-DqHk3fA1.js"; import { n as hashCronScratchSource, o as writeCronJobScratch, r as readCronJobScratchState, t as deleteCronJobScratch } from "./scratch-store-BeMQfZeF.js"; import { n as ensureHeartbeatMonitorJobs } from "./doctor-heartbeat-cadence-migration-B8a36Zi9.js"; import { TextDecoder } from "node:util"; import path from "node:path"; import fs from "node:fs/promises"; //#region src/commands/doctor-heartbeat-scratch-migration.ts /** Doctor-owned migration from workspace HEARTBEAT.md files into cron job scratch. */ const HEARTBEAT_SCRATCH_MIGRATION_CHECK_ID = "core/doctor/heartbeat-scratch-migration"; const LEGACY_HEARTBEAT_FILENAME = "HEARTBEAT.md"; const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); async function resolveHeartbeatScratchMigrationOwners(cfg) { const migrationAgents = []; const disabledEntryKeys = /* @__PURE__ */ new Set(); for (const agent of resolveHeartbeatAgents(cfg)) { if (resolveHeartbeatIntervalMs(cfg, void 0, agent.heartbeat) !== null) { migrationAgents.push(agent); continue; } const workspaceDir = resolveAgentWorkspaceDir(cfg, agent.agentId); const workspaceRealPath = await fs.realpath(workspaceDir).catch(() => path.resolve(workspaceDir)); disabledEntryKeys.add(path.join(workspaceRealPath, LEGACY_HEARTBEAT_FILENAME)); } return { migrationAgents, disabledEntryKeys }; } async function readHeartbeatSource(cfg, agentId, options) { const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const heartbeatPath = path.join(workspaceDir, LEGACY_HEARTBEAT_FILENAME); let sourceStat; try { sourceStat = await fs.lstat(heartbeatPath); const orphanClaim = await findStaleHeartbeatClaim(heartbeatPath); if (orphanClaim) throw new Error(`both ${heartbeatPath} and an interrupted migration claim at ${orphanClaim} exist; reconcile them manually before rerunning doctor`); } catch (error) { if (error.code !== "ENOENT") throw error; const staleClaim = await findStaleHeartbeatClaim(heartbeatPath); if (!staleClaim) return; if (!options?.recoverClaims) throw new Error(`an interrupted migration claim exists at ${staleClaim}; run openclaw doctor --fix to restore it`, { cause: error }); await restoreClaimNoClobber(staleClaim, heartbeatPath); sourceStat = await fs.lstat(heartbeatPath); } if (!sourceStat.isFile() && !sourceStat.isSymbolicLink()) throw new Error("HEARTBEAT.md must be a regular file or contained symlink"); if (sourceStat.isFile() && sourceStat.nlink > 1) throw new Error("HEARTBEAT.md has multiple hard links; refusing automatic removal"); const workspaceRealPath = await fs.realpath(workspaceDir); const sourceRealPath = await fs.realpath(heartbeatPath); if (sourceRealPath !== workspaceRealPath && !isPathInside(workspaceRealPath, sourceRealPath)) throw new Error("HEARTBEAT.md symlink target escapes the agent workspace"); const file = await readRegularFile({ filePath: sourceRealPath, maxBytes: CRON_JOB_SCRATCH_MAX_BYTES }); let content; try { content = utf8Decoder.decode(file.buffer); } catch { throw new Error("HEARTBEAT.md is not valid UTF-8"); } return { path: heartbeatPath, entryKey: path.join(workspaceRealPath, LEGACY_HEARTBEAT_FILENAME), content, sha256: hashCronScratchSource(content) }; } function archivePathForSource(agentId, sha256, env) { const safeAgentId = agentId.replace(/[^A-Za-z0-9._-]+/g, "-"); return path.join(resolveStateDir(env), "backups", "heartbeat-migration", `${safeAgentId}-${sha256}.md`); } const HEARTBEAT_CLAIM_INFIX = ".doctor-importing-"; const HEARTBEAT_CLAIM_CHANGED_ERROR = "HeartbeatClaimChangedError"; /** Interrupted-claim sibling for a missing canonical heartbeat path. */ async function findStaleHeartbeatClaim(heartbeatPath) { const dir = path.dirname(heartbeatPath); const claimPattern = new RegExp(`^${escapeRegExp(path.basename(heartbeatPath))}${escapeRegExp(HEARTBEAT_CLAIM_INFIX)}\\d+-[0-9a-f]{12}$`); let entries; try { entries = await fs.readdir(dir); } catch { return; } const claims = entries.filter((entry) => claimPattern.test(entry)); if (claims.length > 1) throw new Error(`multiple interrupted migration claims exist for ${heartbeatPath}; remove or restore the stale .doctor-importing-* files manually`); const claim = claims[0]; if (!claim) return; const ownerPid = Number(claim.slice(claim.lastIndexOf(HEARTBEAT_CLAIM_INFIX) + 18).split("-")[0]); if (Number.isSafeInteger(ownerPid) && ownerPid !== process.pid && isProcessAlive(ownerPid)) throw new Error(`a migration claim for ${heartbeatPath} is held by running process ${ownerPid}; wait for that doctor run to finish`); return path.join(dir, claim); } function isProcessAlive(pid) { try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; } } /** * Restore a claim without clobbering: `link` fails with EEXIST when another * process recreated the destination while we held the claim, so both files * survive (the recreation in place, the claimed original at a conflict path). */ async function restoreClaimNoClobber(claimPath, destinationPath) { try { await fs.link(claimPath, destinationPath); await fs.unlink(claimPath); } catch (error) { if (error.code !== "EEXIST") throw error; const conflictPath = `${claimPath}.conflict-${Date.now()}`; await fs.rename(claimPath, conflictPath); throw new Error(`HEARTBEAT.md was recreated during migration; the claimed original is preserved at ${conflictPath}`, { cause: error }); } } /** * Move the source aside and prove the claimed bytes still match what was read. * The claim happens before any scratch write so a concurrent edit can never * leave stale content committed while the replacement file is restored. */ async function claimHeartbeatSource(source) { const claimPath = `${source.path}${HEARTBEAT_CLAIM_INFIX}${process.pid}-${source.sha256.slice(0, 12)}`; await fs.rename(source.path, claimPath); const restore = async (cause) => { await restoreClaimNoClobber(claimPath, source.path).catch((restoreError) => { throw restoreError instanceof Error && restoreError.message.includes("preserved at") ? restoreError : new Error(`HEARTBEAT.md migration claim could not be restored from ${claimPath}`, { cause: cause ?? restoreError }); }); }; try { const workspaceRealPath = await fs.realpath(path.dirname(source.path)); const claimRealPath = await fs.realpath(claimPath); if (claimRealPath !== workspaceRealPath && !isPathInside(workspaceRealPath, claimRealPath)) throw new Error("claimed HEARTBEAT.md target escapes the agent workspace"); const claimed = await readRegularFile({ filePath: claimRealPath, maxBytes: CRON_JOB_SCRATCH_MAX_BYTES }); const claimedContent = utf8Decoder.decode(claimed.buffer); if (hashCronScratchSource(claimedContent) !== source.sha256) throw new Error("HEARTBEAT.md changed before the migration claim was acquired"); } catch (error) { await restore(error); throw error; } const changedError = (message, cause) => { const error = new Error(message, cause !== void 0 ? { cause } : void 0); error.name = HEARTBEAT_CLAIM_CHANGED_ERROR; return error; }; const failChanged = async (message, cause) => { const error = changedError(message, cause); await restore(error).catch(() => void 0); throw error; }; const readFinalContent = async (filePath) => { const workspaceRealPath = await fs.realpath(path.dirname(source.path)); const fileRealPath = await fs.realpath(filePath); if (fileRealPath !== workspaceRealPath && !isPathInside(workspaceRealPath, fileRealPath)) throw new Error("HEARTBEAT.md target escapes the agent workspace"); const finalBytes = await readRegularFile({ filePath: fileRealPath, maxBytes: CRON_JOB_SCRATCH_MAX_BYTES }); return utf8Decoder.decode(finalBytes.buffer); }; const verifyUnchanged = async () => { const finalContent = await readFinalContent(claimPath).catch((error) => failChanged("claimed HEARTBEAT.md could not be re-verified before finalization", error)); if (hashCronScratchSource(finalContent) !== source.sha256) await failChanged("HEARTBEAT.md changed while the migration claim was held"); let recreated; try { await fs.lstat(source.path); recreated = true; } catch (error) { if (error.code !== "ENOENT") await failChanged("could not verify the original HEARTBEAT.md path", error); recreated = false; } if (recreated) await failChanged("HEARTBEAT.md was recreated while the migration claim was held"); }; const verifyRestoredUnchanged = async () => { let finalContent; try { finalContent = await readFinalContent(source.path); } catch (error) { throw changedError("restored HEARTBEAT.md could not be re-verified", error); } if (hashCronScratchSource(finalContent) !== source.sha256) throw changedError("HEARTBEAT.md changed after the migration claim was restored"); }; return { claimPath, restore, retain: async () => { await restore(void 0); await verifyRestoredUnchanged(); }, release: async ({ archivePath }) => { await verifyUnchanged(); if ((await fs.lstat(claimPath)).isSymbolicLink()) { await fs.unlink(claimPath); return; } try { await fs.rename(claimPath, archivePath); } catch (error) { if (error.code !== "EXDEV") throw error; await fs.unlink(claimPath); } } }; } async function archiveSource(params) { const archivePath = archivePathForSource(params.agentId, params.source.sha256, params.env); await fs.mkdir(path.dirname(archivePath), { recursive: true, mode: 448 }); try { await fs.writeFile(archivePath, params.source.content, { flag: "wx", mode: 384 }); } catch (error) { if (error.code !== "EEXIST") throw error; const existing = await fs.readFile(archivePath, "utf8"); if (hashCronScratchSource(existing) !== params.source.sha256) throw new Error(`heartbeat migration archive collision at ${archivePath}`, { cause: error }); } } function migrationFinding(params) { return { checkId: HEARTBEAT_SCRATCH_MIGRATION_CHECK_ID, severity: params.severity ?? "warning", message: params.message, path: params.path, target: params.agentId, requirement: params.requirement, fixHint: `Run ${formatCliCommand("openclaw doctor --fix")} to migrate HEARTBEAT.md into cron scratch.` }; } /** Reports remaining workspace heartbeat files without changing them. */ async function collectHeartbeatScratchMigrationFindings(cfg) { const findings = []; const { migrationAgents, disabledEntryKeys } = await resolveHeartbeatScratchMigrationOwners(cfg); for (const agent of migrationAgents) { const heartbeatPath = path.join(resolveAgentWorkspaceDir(cfg, agent.agentId), LEGACY_HEARTBEAT_FILENAME); try { const source = await readHeartbeatSource(cfg, agent.agentId); if (!source) continue; if (disabledEntryKeys.has(source.entryKey)) continue; findings.push(migrationFinding({ agentId: agent.agentId, path: heartbeatPath, requirement: "legacy-heartbeat-file", message: `Agent "${agent.agentId}" still stores heartbeat instructions in HEARTBEAT.md.` })); } catch (error) { findings.push(migrationFinding({ agentId: agent.agentId, path: heartbeatPath, requirement: "heartbeat-file-migration-blocked", severity: "error", message: `Agent "${agent.agentId}" HEARTBEAT.md cannot be migrated: ${formatErrorMessage(error)}` })); } } return findings; } /** Migrates each enrolled agent's heartbeat file into its stable monitor job. */ async function maybeMigrateHeartbeatFilesToScratch(params) { const env = params.env ?? process.env; const storePath = resolveCronJobsStorePathFromConfig(params.cfg, env); const changes = []; const warnings = []; const { migrationAgents, disabledEntryKeys } = await resolveHeartbeatScratchMigrationOwners(params.cfg); if (!params.shouldRepair) { for (const agent of migrationAgents) try { const source = await readHeartbeatSource(params.cfg, agent.agentId); if (source) { const retained = disabledEntryKeys.has(source.entryKey) ? " The shared legacy file will be retained because a heartbeat owner is disabled." : ""; note(`${shortenHomePath(source.path)} will migrate into scratch for Heartbeat (${agent.agentId}).${retained}`, "Heartbeat migration preview"); } } catch (error) { warnings.push(`Agent "${agent.agentId}" HEARTBEAT.md cannot be migrated: ${formatErrorMessage(error)}`); } if (warnings.length > 0) note(warnings.join("\n"), "Doctor warnings"); return { changes, warnings }; } let monitors; try { monitors = await ensureHeartbeatMonitorJobs(params.cfg, storePath, env); } catch (error) { return { changes, warnings: [`Could not prepare heartbeat monitor jobs: ${formatErrorMessage(error)}`] }; } const groups = /* @__PURE__ */ new Map(); const migrationAgentIds = new Set(migrationAgents.map((agent) => agent.agentId)); for (const [agentId, monitor] of monitors) { if (!migrationAgentIds.has(agentId)) continue; let source; try { source = await readHeartbeatSource(params.cfg, agentId, { recoverClaims: true }); } catch (error) { warnings.push(`Agent "${agentId}" HEARTBEAT.md was not migrated: ${formatErrorMessage(error)}`); continue; } if (!source) continue; const group = groups.get(source.entryKey) ?? { source, agents: [], retainSource: disabledEntryKeys.has(source.entryKey) }; group.agents.push([agentId, monitor]); groups.set(source.entryKey, group); } for (const { source, agents, retainSource } of groups.values()) { let keepSource = retainSource; const importAgents = []; let scratchWriteNeeded = false; const plannedRevisionByJobId = /* @__PURE__ */ new Map(); for (const [agentId, monitor] of agents) { const state = readCronJobScratchState(storePath, monitor.id, { env }); const current = state.scratch; plannedRevisionByJobId.set(monitor.id, state.currentRevision); if (state.currentRevision > 0 && !current) { warnings.push(`Agent "${agentId}" scratch was explicitly unset; it was left unchanged.`); keepSource = true; } else if (current && current.content !== source.content && current.sourceSha256 !== source.sha256) { warnings.push(`Agent "${agentId}" already has different cron scratch; it was left unchanged.`); keepSource = true; } else { importAgents.push([agentId, monitor]); if (current?.sourceSha256 !== source.sha256) scratchWriteNeeded = true; } } if (importAgents.length === 0 || keepSource && !scratchWriteNeeded) continue; if (!keepSource) try { await archiveSource({ agentId: importAgents[0][0], source, env }); } catch (error) { warnings.push(`${shortenHomePath(source.path)} was not migrated: ${formatErrorMessage(error)}. Rerun doctor to retry safely.`); continue; } let claim; try { claim = await claimHeartbeatSource(source); } catch (error) { warnings.push(`${shortenHomePath(source.path)} was not migrated: ${formatErrorMessage(error)}. Rerun doctor to retry safely.`); continue; } let importedAll = true; const groupChanges = []; const committedThisRun = []; for (const [agentId, monitor] of importAgents) try { const state = readCronJobScratchState(storePath, monitor.id, { env }); const shouldWriteScratch = state.scratch?.sourceSha256 !== source.sha256; if (shouldWriteScratch) { const write = writeCronJobScratch({ storePath, jobId: monitor.id, content: source.content, expectedRevision: plannedRevisionByJobId.get(monitor.id) ?? state.currentRevision, sourceSha256: source.sha256, options: { env } }); if (!write.ok) throw new Error("scratch changed during migration"); committedThisRun.push({ agentId, monitor, previous: state.scratch, newRevision: write.currentRevision }); } const verified = readCronJobScratchState(storePath, monitor.id, { env }).scratch; if (!verified || verified.sourceSha256 !== source.sha256) throw new Error("scratch verification failed after write"); if (!keepSource || shouldWriteScratch) groupChanges.push(keepSource ? `Copied ${shortenHomePath(source.path)} into cron scratch for ${monitor.displayName ?? monitor.name}; retained the shared legacy file because ${retainSource ? "a heartbeat owner is disabled" : "another heartbeat owner's scratch was left unchanged"}.` : `Migrated ${shortenHomePath(source.path)} into cron scratch for ${monitor.displayName ?? monitor.name}.`); } catch (error) { warnings.push(`Agent "${agentId}" scratch was not finalized: ${formatErrorMessage(error)}. Rerun doctor to retry safely.`); importedAll = false; } const rollbackCommitted = () => { for (const commit of committedThisRun.toReversed()) { if (!commit.previous) { if (!deleteCronJobScratch(storePath, commit.monitor.id, { env }, { expectedRevision: commit.newRevision })) warnings.push(`Agent "${commit.agentId}" scratch changed before the migration rollback; leaving current scratch in place.`); continue; } if (!writeCronJobScratch({ storePath, jobId: commit.monitor.id, content: commit.previous.content, expectedRevision: commit.newRevision, sourceSha256: commit.previous.sourceSha256, options: { env } }).ok) warnings.push(`Agent "${commit.agentId}" scratch changed before the migration rollback; leaving current scratch in place.`); } }; if (!importedAll) { rollbackCommitted(); try { await claim.restore(void 0); } catch (error) { warnings.push(formatErrorMessage(error)); } continue; } if (keepSource) { try { await claim.retain(); changes.push(...groupChanges); } catch (error) { rollbackCommitted(); warnings.push(`${shortenHomePath(source.path)} was not migrated: ${formatErrorMessage(error)}. Rerun doctor to retry safely.`); } continue; } try { await claim.release({ archivePath: archivePathForSource(importAgents[0][0], source.sha256, env) }); changes.push(...groupChanges); } catch (error) { if (error instanceof Error && error.name === HEARTBEAT_CLAIM_CHANGED_ERROR) { rollbackCommitted(); warnings.push(`${shortenHomePath(source.path)} was not migrated: ${formatErrorMessage(error)}. Rerun doctor to retry safely.`); continue; } changes.push(...groupChanges); warnings.push(`${shortenHomePath(source.path)} was migrated but not removed: ${formatErrorMessage(error)}. Rerun doctor to retry safely.`); } } 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 { collectHeartbeatScratchMigrationFindings, maybeMigrateHeartbeatFilesToScratch };