UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

407 lines (406 loc) • 19.9 kB
import { c as isRecord } from "./record-coerce-TIsquoVR.mjs"; import { d as pathExists, w as root } from "./fs-safe-BBzelG8F.mjs"; import { r as isMissingPathError } from "./errno-FR4CRyya.mjs"; import { r as isPathInside } from "./path-guards-CH7noY0Z.mjs"; import { r as resolveStateDir } from "./state-dir-CYkBUd_G.mjs"; import "./paths-BP0DYpE8.mjs"; import "./errors-BvOkmQM5.mjs"; import { i as tableExists } from "./openclaw-state-db-schema-helpers-DKz5xGgh.mjs"; import { u as getOpenClawDatabaseMaintenanceScope } from "./openclaw-state-db-async-lifecycle-DwFy2ZTk.mjs"; import { n as sha256Hex } from "./node-crypto-BG4u3UnX.mjs"; import "./crypto-digest-y6oZN-_F.mjs"; import { o as resolveCanonicalWorkspacePath } from "./workspace-state-identity-bCDmgNan.mjs"; import { i as isUpdateRehearsalReadOnlyPath } from "./update-rehearsal-paths-FgHeU_TR.mjs"; import { f as dropRetiredSkillWorkshopProposalTables } from "./openclaw-state-db-fast-path-Kg79WElU.mjs"; import { n as normalizeAgentId } from "./agent-id-D1-Q6vR4.mjs"; import "./session-key-DY3MOX4N.mjs"; import { h as parseAgentSessionKey } from "./session-key-utils-CDyBC9Gn.mjs"; import { i as listAgentIds } from "./agent-roster-DH6r_8f4.mjs"; import { d as resolveAgentWorkspaceDir } from "./agent-scope-config-BEk6-ThS.mjs"; import { l as runOpenClawStateWriteTransaction, r as openOpenClawStateDatabase } from "./openclaw-state-db-DJZ7EKoI.mjs"; import { r as acquireGatewayLock } from "./gateway-lock-Dw3jf-HK.mjs"; import { a as readWorkspaceSkillFile, i as prepareWorkspaceSkillRestoration, o as readWorkspaceSupportFile, s as restoreWorkspaceSkillMutation } from "./workspace-skill-write-TDwce489.mjs"; import { t as resolveWorkshopSkillsDir } from "./skills-root-BEm7oxJo.mjs"; import { rmSync } from "node:fs"; import path from "node:path"; import fs$1 from "node:fs/promises"; //#region src/commands/doctor-skill-workshop-retire-proposals.ts const LEGACY_PROPOSALS_DIR = path.join("skill-workshop", "proposals"); const LEGACY_PROPOSALS_MANIFEST = path.join("skill-workshop", "proposals.json"); const PROPOSAL_ID_PATTERN = /^[a-z0-9][a-z0-9-]{5,120}$/u; const DRAFT_FILE_PATTERN = /^(?:generations\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/)?PROPOSAL\.md$/u; const MAX_EXPORT_FILE_BYTES = 8388608; const MAX_ROLLBACK_BYTES = 134217728; const EXPORTED_STATUSES = /* @__PURE__ */ new Set(["pending", "quarantined"]); /** Reads only the export-relevant fields; malformed records keep their tables for manual review. */ function parseRetiredProposal(id, record, ownerAgentId) { if (!isRecord(record)) throw new Error("proposal record is not an object"); const { draftFile = "PROPOSAL.md", supportFiles = [] } = record; if (typeof draftFile !== "string" || !DRAFT_FILE_PATTERN.test(draftFile)) throw new Error("proposal record has an invalid draft path"); if (!Array.isArray(supportFiles)) throw new Error("proposal record has invalid support files"); return { id, ownerAgentId, draftFile, supportFiles: supportFiles.map((file) => { const filePath = isRecord(file) ? file.path : void 0; if (typeof filePath !== "string" || path.posix.isAbsolute(filePath) || filePath.split("/").some((segment) => !segment || segment === "." || segment === "..") || filePath === "SKILL.md") throw new Error("proposal record has an invalid support file path"); return filePath; }) }; } /** * Pairs a rollback (SQLite row or legacy `rollback.json`) with the hashes its proposal wrote, so * recovery only ever replaces bytes that apply itself put there. */ function parseUnfinishedApply(proposalId, record, rollback) { const target = isRecord(record) && isRecord(record.target) ? record.target : {}; const skillFile = rollback.targetSkillFile; if (typeof skillFile !== "string" || !path.isAbsolute(skillFile) || path.basename(skillFile) !== "SKILL.md" || target.skillFile !== skillFile) throw new Error("rollback does not match its proposal target"); const proposedHashes = new Map((isRecord(record) && Array.isArray(record.supportFiles) ? record.supportFiles : []).filter(isRecord).map((file) => [file.path, file.hash])); const rollbackSupport = rollback.supportFiles ?? []; if (!Array.isArray(rollbackSupport)) throw new Error("rollback has invalid support files"); const supportFiles = rollbackSupport.map((file) => { const filePath = isRecord(file) ? file.path : void 0; const proposedContentHash = proposedHashes.get(filePath); const previousContent = isRecord(file) && file.existed === true ? file.previousContent : null; if (typeof filePath !== "string" || typeof proposedContentHash !== "string" || previousContent !== null && typeof previousContent !== "string") throw new Error("rollback has an invalid support file"); return { path: filePath, previousContent, proposedContentHash }; }); const previousContent = rollback.previousContent ?? null; if (previousContent !== null && typeof previousContent !== "string") throw new Error("rollback has invalid previous content"); return { proposalId, skillFile, previousContent, supportFiles }; } function readDatabaseProposals(config, env) { const { db } = openOpenClawStateDatabase({ env }); const hasTables = [ "skill_workshop_proposals", "skill_workshop_proposal_events", "skill_workshop_proposal_rollbacks", "skill_workshop_collection_reviews" ].some((table) => tableExists(db, table)); const proposals = []; const unfinishedApplies = []; const recordedIds = /* @__PURE__ */ new Set(); const failures = []; const result = { proposals, unfinishedApplies, recordedIds, failures, hasTables }; if (!tableExists(db, "skill_workshop_proposals")) return result; const rows = db.prepare(`SELECT proposal_id, record_json, owner_agent_id, status FROM skill_workshop_proposals ORDER BY proposal_id`).all(); for (const row of rows) { const id = String(row.proposal_id); recordedIds.add(id); if (!EXPORTED_STATUSES.has(String(row.status))) continue; try { const record = JSON.parse(String(row.record_json)); const rowOwner = typeof row.owner_agent_id === "string" ? normalizeAgentId(row.owner_agent_id) : void 0; const ownerAgentId = rowOwner !== void 0 ? listAgentIds(config).includes(rowOwner) ? rowOwner : void 0 : isRecord(record) ? inferOwnerAgentId(record, config, env) : void 0; proposals.push(parseRetiredProposal(id, record, ownerAgentId)); } catch (error) { failures.push(`Could not read Skill Workshop proposal ${id}: ${String(error)}`); } } const rollbackRows = tableExists(db, "skill_workshop_proposal_rollbacks") ? db.prepare(`SELECT r.proposal_id, r.target_skill_file, r.previous_content, r.support_files_json, p.record_json FROM skill_workshop_proposal_rollbacks r JOIN skill_workshop_proposals p USING (proposal_id) WHERE p.status != 'applied' ORDER BY r.proposal_id`).all() : []; for (const row of rollbackRows) { const id = String(row.proposal_id); try { unfinishedApplies.push(parseUnfinishedApply(id, JSON.parse(String(row.record_json)), { targetSkillFile: row.target_skill_file, previousContent: row.previous_content, supportFiles: typeof row.support_files_json === "string" ? JSON.parse(row.support_files_json) : [] })); } catch (error) { failures.push(`Could not read the unfinished apply of Skill Workshop proposal ${id}: ${String(error)}`); } } return result; } /** * Records without an owner (legacy sidecars, NULL owner rows) use the recorded origin agent, else * the one agent whose workspace or Workshop holds the target skill, else the sole configured * agent. A recorded owner that is no longer configured is never reassigned; that proposal stays * for manual recovery. */ function inferOwnerAgentId(record, config, env) { const agentIds = listAgentIds(config); const soleAgentId = agentIds.length === 1 ? agentIds[0] : void 0; const origin = isRecord(record.origin) ? record.origin : {}; const recordedOwner = typeof origin.agentId === "string" && origin.agentId.trim() || (typeof origin.sessionKey === "string" ? parseAgentSessionKey(origin.sessionKey)?.agentId : void 0); if (recordedOwner) { const ownerAgentId = normalizeAgentId(recordedOwner); return agentIds.includes(ownerAgentId) ? ownerAgentId : void 0; } const target = isRecord(record.target) ? record.target : {}; if (typeof target.skillDir !== "string") return soleAgentId; const skillDir = resolveCanonicalWorkspacePath(path.resolve(target.skillDir)); const claims = agentIds.flatMap((agentId) => { const workspaceDir = resolveCanonicalWorkspacePath(resolveAgentWorkspaceDir(config, agentId, env)); return [ path.join(workspaceDir, "skills"), path.join(workspaceDir, ".agents", "skills"), resolveCanonicalWorkspacePath(resolveWorkshopSkillsDir(config, agentId, env)) ].filter((skillsRoot) => isPathInside(skillsRoot, skillDir)).map((skillsRoot) => ({ agentId, skillsRoot })); }); const innermost = Math.max(...claims.map(({ skillsRoot }) => skillsRoot.length)); const owners = new Set(claims.filter(({ skillsRoot }) => skillsRoot.length === innermost).map(({ agentId }) => agentId)); return owners.size === 1 ? [...owners][0] : owners.size === 0 ? soleAgentId : void 0; } /** Pre-SQLite bundles keep their record in `proposal.json`; SQLite-backed bundles have none. */ async function appendLegacyJsonProposals(params) { const { proposals, unfinishedApplies, failures, recordedIds } = params.inventory; const stateRoot = await root(params.stateDir); const readOptions = { hardlinks: "reject", symlinks: "reject" }; for (const entry of await stateRoot.list(LEGACY_PROPOSALS_DIR, { withFileTypes: true })) { if (!entry.isDirectory || !PROPOSAL_ID_PATTERN.test(entry.name) || recordedIds.has(entry.name)) continue; const bundleDir = path.join(params.stateDir, LEGACY_PROPOSALS_DIR, entry.name); try { const read = await stateRoot.read(`${LEGACY_PROPOSALS_DIR}/${entry.name}/proposal.json`, { ...readOptions, maxBytes: MAX_EXPORT_FILE_BYTES }); const record = JSON.parse(read.buffer.toString("utf8")); if (!isRecord(record) || typeof record.status !== "string") throw new Error("proposal record has no status"); if (record.status !== "applied") { const rollback = await stateRoot.read(`${LEGACY_PROPOSALS_DIR}/${entry.name}/rollback.json`, { ...readOptions, maxBytes: MAX_ROLLBACK_BYTES }).catch((error) => { if (isMissingPathError(error)) return; throw error; }); if (rollback) { const facts = JSON.parse(rollback.buffer.toString("utf8")); if (!isRecord(facts)) throw new Error("rollback is not an object"); unfinishedApplies.push(parseUnfinishedApply(entry.name, record, facts)); } } if (!EXPORTED_STATUSES.has(record.status)) continue; proposals.push(parseRetiredProposal(entry.name, record, inferOwnerAgentId(record, params.config, params.env))); } catch (error) { failures.push(isMissingPathError(error) ? `Skill Workshop proposal ${entry.name} has no record; kept ${bundleDir}. Copy anything worth keeping, delete that directory, then rerun openclaw doctor --fix.` : `Could not read Skill Workshop proposal ${entry.name}: ${String(error)}`); } } } /** * Apply wrote support files first and SKILL.md last; its compensation restored support files * first and SKILL.md last. SKILL.md at its pre-apply content means the apply never activated, * so every support file it provably wrote is put back. SKILL.md past it with every support file * at its proposed bytes means only the status commit was lost. Anything else stopped mid-way * and keeps its rollback for the operator. */ async function undoUnfinishedApply(apply) { const skillDir = path.dirname(apply.skillFile); const support = await Promise.all(apply.supportFiles.map(async (file) => ({ ...file, current: await readWorkspaceSupportFile({ skillDir, relativePath: file.path }) }))); if (await readWorkspaceSkillFile(apply.skillFile) !== apply.previousContent) { if (support.every(({ current, proposedContentHash }) => current !== null && sha256Hex(current) === proposedContentHash)) return false; throw new Error("SKILL.md is past its pre-apply content but its support files are not"); } if (support.every(({ current, previousContent }) => current === previousContent)) return false; const restoration = await prepareWorkspaceSkillRestoration({ skillsRoot: path.dirname(skillDir), skillDir, skillFile: apply.skillFile, previousContent: apply.previousContent, proposedContentHash: "", supportFiles: apply.supportFiles, mode: "update" }); await restoreWorkspaceSkillMutation(restoration); if (apply.previousContent === null) { if ((await fs$1.readdir(skillDir, { recursive: true, withFileTypes: true })).every((entry) => entry.isDirectory())) await fs$1.rm(skillDir, { recursive: true }); } return true; } /** Copies one draft bundle; an existing export is never overwritten. */ async function exportProposal(proposal, stateDir, exportRoot) { const destination = path.join(exportRoot, proposal.id); if (await pathExists(destination)) return "existing"; const bundleDir = path.join(stateDir, LEGACY_PROPOSALS_DIR, proposal.id, path.posix.dirname(proposal.draftFile)); const readOptions = { hardlinks: "reject", maxBytes: MAX_EXPORT_FILE_BYTES, symlinks: "reject" }; let files; try { const bundle = await root(bundleDir); files = [["SKILL.md", (await bundle.read("PROPOSAL.md", readOptions)).buffer]]; for (const supportFile of proposal.supportFiles) files.push([supportFile, (await bundle.read(supportFile, readOptions)).buffer]); } catch (error) { if (isMissingPathError(error) && !await pathExists(path.join(bundleDir, "PROPOSAL.md"))) return "missing-draft"; throw error; } const staging = path.join(exportRoot, `.${proposal.id}.partial`); await fs$1.rm(staging, { recursive: true, force: true }); for (const [relativePath, content] of files) { const target = path.join(staging, ...relativePath.split("/")); await fs$1.mkdir(path.dirname(target), { recursive: true }); await fs$1.writeFile(target, content, { flag: "wx" }); } await fs$1.rename(staging, destination); return "exported"; } async function retireProposals(params) { const { config, env, assertCurrent } = params; const stateDir = resolveStateDir(env); const legacyDirExists = await pathExists(path.join(stateDir, LEGACY_PROPOSALS_DIR)); const inventory = readDatabaseProposals(config, env); if (!inventory.hasTables && !legacyDirExists) return { changes: [], warnings: [] }; if (legacyDirExists) await appendLegacyJsonProposals({ stateDir, inventory, config, env }); const warnings = inventory.failures; let blocked = warnings.length > 0; const changes = []; const migrationResult = () => ({ changes, warnings, ...warnings.length > 0 ? { warningDisposition: "recoverable" } : {} }); for (const apply of inventory.unfinishedApplies) { assertCurrent(); const skillDir = path.dirname(apply.skillFile); if (isUpdateRehearsalReadOnlyPath(skillDir, env)) { blocked = true; continue; } try { if (await undoUnfinishedApply(apply)) changes.push(`Restored ${skillDir} from the unfinished apply of Skill Workshop proposal ${apply.proposalId}.`); } catch (error) { blocked = true; const causes = error instanceof AggregateError ? error.errors : [error]; warnings.push(`Could not undo the unfinished apply of Skill Workshop proposal ${apply.proposalId} in ${skillDir}: ${causes.map(String).join("; ")}. Its proposal tables and files are kept with the recorded pre-apply contents; restore those files by hand, then rerun openclaw doctor --fix.`); } } const exportedIds = /* @__PURE__ */ new Set(); const exportedByRoot = /* @__PURE__ */ new Map(); for (const proposal of inventory.proposals) { assertCurrent(); const proposalDir = path.join(stateDir, LEGACY_PROPOSALS_DIR, proposal.id); if (!await pathExists(proposalDir)) { warnings.push(`Skill Workshop proposal ${proposal.id} has no draft left to export; retired its record.`); exportedIds.add(proposal.id); continue; } if (!proposal.ownerAgentId) { blocked = true; warnings.push(`No configured agent owns Skill Workshop proposal ${proposal.id}; kept ${proposalDir}. To keep it, add its agent back to your config and rerun openclaw doctor --fix, or have an agent save that whole directory (draft and support files) with /learn and then delete the directory; otherwise delete the directory and rerun openclaw doctor --fix.`); continue; } const exportRoot = path.join(resolveWorkshopSkillsDir(config, proposal.ownerAgentId, env), ".archive", ".retired-proposals"); if (isUpdateRehearsalReadOnlyPath(exportRoot, env)) { blocked = true; continue; } try { await fs$1.mkdir(exportRoot, { recursive: true }); const outcome = await exportProposal(proposal, stateDir, exportRoot); if (outcome === "missing-draft") { blocked = true; warnings.push(`Skill Workshop proposal ${proposal.id} has no draft; kept ${proposalDir}. Copy anything worth keeping, delete that directory, then rerun openclaw doctor --fix.`); continue; } if (outcome === "exported") exportedByRoot.set(exportRoot, (exportedByRoot.get(exportRoot) ?? 0) + 1); exportedIds.add(proposal.id); } catch (error) { blocked = true; warnings.push(`Could not export Skill Workshop proposal ${proposal.id} to ${exportRoot}: ${String(error)}. Its proposal tables and files are kept; rerun openclaw doctor --fix after fixing the cause.`); } } for (const [exportRoot, count] of exportedByRoot) changes.push(`Exported ${count} pending Skill Workshop proposal draft${count === 1 ? "" : "s"} to ${exportRoot}${path.sep}.`); if (blocked) return migrationResult(); assertCurrent(); const dropped = runOpenClawStateWriteTransaction(({ db }) => { assertCurrent(); if ((tableExists(db, "skill_workshop_proposals") ? db.prepare("SELECT proposal_id FROM skill_workshop_proposals WHERE status IN ('pending', 'quarantined')").all() : []).some((row) => !exportedIds.has(String(row.proposal_id)))) return; if (legacyDirExists) { rmSync(path.join(stateDir, LEGACY_PROPOSALS_DIR), { recursive: true, force: true }); rmSync(path.join(stateDir, LEGACY_PROPOSALS_MANIFEST), { force: true }); } return dropRetiredSkillWorkshopProposalTables(db); }, { env }, { operationLabel: "doctor.skill-workshop.retire-proposals" }); if (dropped === void 0) { warnings.push("Skill Workshop proposals changed during export; rerun openclaw doctor --fix to finish retiring the proposal tables."); return migrationResult(); } if (dropped) changes.push("Retired the Skill Workshop proposal tables."); if (legacyDirExists) changes.push(`Removed retired Skill Workshop proposal files from ${path.join(stateDir, LEGACY_PROPOSALS_DIR)}.`); return migrationResult(); } /** * Exports pending and quarantined Skill Workshop proposal drafts into each owning agent's * Workshop archive, then drops the retired proposal tables and legacy proposal files. */ async function retireSkillWorkshopProposals(params) { const env = params.env ?? process.env; const maintenance = getOpenClawDatabaseMaintenanceScope(); if (maintenance?.ownsSchemaMaintenance) return await maintenance.run(() => retireProposals({ config: params.config, env, assertCurrent: () => maintenance.assertOwnerCurrent() })); const owner = await acquireGatewayLock({ env, role: "sqlite-maintenance", allowInTests: true }); if (!owner) throw new Error("Skill Workshop proposal retirement requires exclusive state ownership"); try { return await owner.run(() => retireProposals({ config: params.config, env, assertCurrent: () => owner.assertCurrent() })); } finally { await owner.release(); } } //#endregion export { retireSkillWorkshopProposals };