UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

738 lines (737 loc) • 36.4 kB
import { i as truncateWithMarker } from "./utf16-slice-D_ngcYKd.mjs"; import { d as pathExists, w as root } from "./fs-safe-BUXBSN-4.mjs"; import { r as isMissingPathError, t as hasErrnoCode } from "./errno-FR4CRyya.mjs"; import { r as isPathInside } from "./path-guards-CH7noY0Z.mjs"; import { r as resolveStateDir } from "./state-dir-BHJORNly.mjs"; import "./paths-DGIwWbpu.mjs"; import "./errors-O33D01XW.mjs"; import { a as getNodeSqliteKysely, i as executeSqliteQueryTakeFirstSync, r as executeSqliteQuerySync } from "./kysely-sync-K7ftY-g4.mjs"; import { i as tableExists } from "./openclaw-state-db-schema-helpers-BlZefrPy.mjs"; import { l as getOpenClawDatabaseMaintenanceScope } from "./openclaw-state-db-async-lifecycle-DLKxSPCk.mjs"; import { Jn as string, Nt as array, Pn as number, qn as strictObject } from "./schemas-BOYIvvln.mjs"; import { c as resolveWorkspaceStateIdentity, o as resolveCanonicalWorkspacePath } from "./workspace-state-identity-EPAs22n_.mjs"; import { i as resolveUpdateRehearsalRoot, r as isUpdateRehearsalReadOnlyPath } from "./update-rehearsal-paths-DTVdt0cq.mjs"; import { l as runOpenClawStateWriteTransaction, r as openOpenClawStateDatabase } from "./openclaw-state-db-D1jWDzK3.mjs"; import { c as recordLegacyMigrationSource, l as resolveLegacyMigrationSourceKey, s as recordLegacyMigrationRun } from "./state-migrations.receipts-gEcgM3Aq.mjs"; import { r as acquireGatewayLock } from "./gateway-lock-B6mKYs2x.mjs"; import { s as assertWorkspaceStateMigrationReady } from "./workspace-legacy-state-CfAX5lfc.mjs"; import { a as readWorkspaceStateSnapshot, s as retireWorkspaceRelocationAttestation } from "./workspace-state-store-DCCJC3M9.mjs"; import { n as WORKSPACE_CONTENT_RELOCATION_MIGRATION_KIND } from "./workspace-state-store.kernel-0RlWLaF0.mjs"; import { r as removePathWithinRoot } from "./fs-safe-remove-DuP7yp5L.mjs"; import { n as resolveCronJobsStorePathFromConfig } from "./paths-C7ju4So0.mjs"; import { a as readSkillProposal, d as resolveSkillProposalTarget, m as reconcileInterruptedSkillProposalApply, o as readSkillProposalBundle, r as importLegacySkillProposal, t as SkillProposalDraftMissingError, y as readSkillProposalRollback } from "./store-D9K8vtNx.mjs"; import { t as hashSkillProposalContent } from "./proposal-hash-Cnd47FBL.mjs"; import { n as readSkillProposalTargetTreeSha256 } from "./proposal-bundle-BJEsuqK7.mjs"; import { t as resolveWorkshopSkillsDir } from "./skills-root-5Lc00M9N.mjs"; import { c as validateSkillProposalRecord, l as validateSkillProposalRollback } from "./store-record-CSGbu9Vk.mjs"; import { a as transitionPendingSkillProposalToStale } from "./service-query-4XvRM2NP.mjs"; import { n as loadCronJobsStoreWithConfigJobsReadOnly } from "./read-only-BnZqzQN0.mjs"; import "./store-ChPMJI5O.mjs"; import { a as updateProposal, i as readStoredProposalInDatabase, r as parseSkillProposalRow } from "./store-sqlite-record-B3dq4QRr.mjs"; import { a as readLegacyWorkshopJson, c as classifyWorkshopRelocation, d as planWorkshopRelocation, f as readLegacyWorkshopSourceStat, g as migrateLegacyCollectionBackups, h as listWorkspaceOwnerAgentIds, l as inferOwnerAgentId, m as listPendingLegacyCollectionBackupRoots, n as LEGACY_WORKSHOP_PROPOSALS_DIR, p as resolveLegacyWorkshopWorkspaceDir, r as LEGACY_WORKSHOP_PROPOSAL_ID_PATTERN, s as readWorkshopMigrationRecords, t as LEGACY_WORKSHOP_MAX_RECORD_BYTES, u as isReadOnlyRehearsalProposal } from "./doctor-skill-workshop-sources-DpTIg1UC.mjs"; import path from "node:path"; import fs from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { movePathWithCopyFallback } from "@openclaw/fs-safe/atomic"; //#region src/commands/doctor-skill-workshop-automations.ts function automationFields(job) { const fields = []; if (job.payload.kind === "command") { fields.push(...job.payload.argv.map((value, index) => ({ field: `payload.argv[${index}]`, value, completePath: true }))); if (job.payload.cwd) fields.push({ field: "payload.cwd", value: job.payload.cwd, completePath: true }); } else if (job.payload.kind === "agentTurn") fields.push({ field: "payload.message", value: job.payload.message, completePath: false }); if (job.trigger) fields.push({ field: "trigger.script", value: job.trigger.script, completePath: false }); return fields; } function containsLiteralRoot(value, root) { for (let index = value.indexOf(root); index !== -1; index = value.indexOf(root, index + root.length)) { const before = value[index - 1]; const after = value[index + root.length]; if ((!before || /[\s"'`=([{,:;]/u.test(before)) && (!after || after === path.sep || /[\s"'`),\]};]/u.test(after))) return true; } return false; } async function existingPath(filename) { try { return await fs.realpath(filename); } catch (error) { if (isMissingPathError(error)) return; throw error; } } /** Historical apply events keep the original target after proposal retargeting. */ async function inspectWorkshopAutomationReferences(params) { const records = new Map(params.records.map((entry) => [entry.record.id, entry])); const relocations = /* @__PURE__ */ new Map(); for (const event of params.appliedEvents) { const entry = records.get(event.proposalId); const originalFile = event.payload?.targetSkillFile; if (!entry || entry.record.kind !== "create" || entry.record.status !== "applied" || typeof originalFile !== "string" || !path.isAbsolute(originalFile) || path.basename(originalFile) !== "SKILL.md") continue; const source = path.dirname(originalFile); const destination = path.resolve(entry.record.target.skillDir); const { ownerAgentId } = inferOwnerAgentId({ config: params.config, env: params.env, record: entry.record, workspaceDir: void 0, rowOwnerAgentId: entry.ownerAgentId }); if (!ownerAgentId || !entry.ownerAgentId || source === destination || !resolveLegacyWorkshopWorkspaceDir(source, params.config, params.env) || !isPathInside(resolveWorkshopSkillsDir(params.config, ownerAgentId, params.env), destination)) continue; const destinations = relocations.get(source) ?? /* @__PURE__ */ new Set(); destinations.add(destination); relocations.set(source, destinations); } if (relocations.size === 0) return []; const stateEnv = params.stateEnv ?? params.env; const { store } = await loadCronJobsStoreWithConfigJobsReadOnly(resolveCronJobsStorePathFromConfig(params.config, params.env, stateEnv), stateEnv); const references = []; const orderedRelocations = [...relocations].toSorted(([left], [right]) => left.localeCompare(right)); for (const job of store.jobs.toSorted((left, right) => left.id.localeCompare(right.id))) for (const { field, value, completePath } of automationFields(job)) for (const [source, destinations] of orderedRelocations) { if (!containsLiteralRoot(value, source)) continue; const exactPath = completePath && path.isAbsolute(value) && isPathInside(source, value); if (await existingPath(exactPath ? value : source)) continue; let fixHint = "Relocation target unresolved: retained records do not identify one destination. Review this field manually."; if (destinations.size === 1) { const destination = [...destinations][0]; const destinationRoot = await existingPath(destination); if (exactPath && destinationRoot) { const replacement = path.join(destination, path.relative(source, value)); const resolved = await existingPath(replacement); fixHint = resolved && isPathInside(destinationRoot, resolved) ? `Verified replacement path: ${replacement}. Review this field manually; Doctor leaves automation content unchanged.` : "Replacement path unresolved: the mapped target is missing or outside the Workshop skill. Review this field manually."; } else if (destinationRoot) fixHint = `Recorded directory relocation: ${source} -> ${destination}. Embedded target unresolved; review this field manually. Doctor leaves automation content unchanged.`; else fixHint = "Replacement path unresolved: the recorded Workshop destination is missing. Review this field manually."; } references.push({ automationId: job.id, field, message: `Automation ${job.id} ${field} references legacy Workshop path ${source}.`, fixHint }); } return references; } //#endregion //#region src/commands/doctor-skill-workshop-sidecars.ts const WORKSHOP_DIR = "skill-workshop"; const MANIFEST_PATH = `${WORKSHOP_DIR}/proposals.json`; const RECOVERY_PROPOSALS_DIR = `${`${WORKSHOP_DIR}/recovery`}/proposals`; const MAX_ROLLBACK_BYTES = 134217728; async function readLegacyRollback(stateRoot, proposalId) { try { const rollback = validateSkillProposalRollback(await readLegacyWorkshopJson(stateRoot, `${LEGACY_WORKSHOP_PROPOSALS_DIR}/${proposalId}/rollback.json`, MAX_ROLLBACK_BYTES)); if (!rollback.ok) throw new Error(rollback.error.message); if (rollback.value.proposalId !== proposalId) throw new Error("invalid rollback metadata"); return rollback.value; } catch (error) { if (isMissingPathError(error)) return; throw error; } } async function verifyImportedProposal(config, env, record, rollback) { const imported = (await readSkillProposal(record.id, { config, env }, {}, { config, reconcile: false }))?.record; if (!imported || imported.draftHash !== record.draftHash || imported.target.skillFile !== record.target.skillFile) throw new Error("SQLite verification failed"); if (rollback && !await readSkillProposalRollback(record.id, { env })) throw new Error("SQLite rollback verification failed"); } async function readLegacyProposalArtifacts(stateRoot, record, env) { const rollback = await readLegacyRollback(stateRoot, record.id); if (rollback && isUpdateRehearsalReadOnlyPath(rollback.targetSkillFile, env)) return null; const draft = await stateRoot.read(`${LEGACY_WORKSHOP_PROPOSALS_DIR}/${record.id}/PROPOSAL.md`, { hardlinks: "reject", maxBytes: LEGACY_WORKSHOP_MAX_RECORD_BYTES, symlinks: "reject" }).catch((error) => { if (rollback && isMissingPathError(error)) throw new Error("Legacy bundle has a missing draft and unfinished apply recovery; restore its draft before retrying Doctor."); throw error; }); if (hashSkillProposalContent(draft.buffer.toString("utf8")) !== record.draftHash) throw new Error("proposal draft hash does not match proposal metadata"); return rollback; } async function prepareLegacyProposal(params) { const proposalDir = `${LEGACY_WORKSHOP_PROPOSALS_DIR}/${params.proposalId}`; const record = validateSkillProposalRecord(await readLegacyWorkshopJson(params.stateRoot, `${proposalDir}/proposal.json`, LEGACY_WORKSHOP_MAX_RECORD_BYTES)); if (!record.ok) throw new Error(record.error.message); if (record.value.id !== params.proposalId) throw new Error("invalid proposal metadata"); if (isReadOnlyRehearsalProposal(record.value, params.env)) return null; const rollback = await readLegacyProposalArtifacts(params.stateRoot, record.value, params.env); if (rollback === null) return null; const workspaceDir = resolveLegacyWorkshopWorkspaceDir(record.value.target.skillDir, params.config, params.env); const owner = inferOwnerAgentId({ config: params.config, env: params.env, record: record.value, workspaceDir }); if (!owner.ownerAgentId) { if (rollback) throw new Error(`Legacy bundle has unfinished apply recovery at ${path.join(resolveStateDir(params.env), proposalDir, "rollback.json")}; resolve its owning agent and recover the retained apply before retrying Doctor.`); const candidates = workspaceDir ? listWorkspaceOwnerAgentIds(params.config, params.env, workspaceDir).toSorted() : []; const reason = owner.unconfiguredOwnerAgentId ? `owning agent "${owner.unconfiguredOwnerAgentId}" is not configured` : "owning agent could not be inferred"; return { warning: `Preserved Skill Workshop proposal ${path.join(resolveStateDir(params.env), proposalDir)} for manual review: ${reason}; target ${record.value.target.skillDir} (candidate agents: ${candidates.join(", ") || "none"}). Review the retained metadata and configured workspace ownership before retrying Doctor.` }; } return { record: record.value, ownerAgentId: owner.ownerAgentId }; } async function migrateProposal(params) { const { record, ownerAgentId } = params.prepared; if (isReadOnlyRehearsalProposal(record, params.env)) return false; const proposalDir = `${LEGACY_WORKSHOP_PROPOSALS_DIR}/${record.id}`; const rollback = await readLegacyProposalArtifacts(params.stateRoot, record, params.env); if (rollback === null) return false; await importLegacySkillProposal({ record, rollback, ownerAgentId, store: { env: params.env } }); await verifyImportedProposal(params.config, params.env, record, rollback); if (rollback) await params.stateRoot.remove(`${proposalDir}/rollback.json`); await params.stateRoot.remove(`${proposalDir}/proposal.json`); return true; } async function reconcileIncompleteProposal(params) { if ((await params.stateRoot.list(params.proposalDir, { withFileTypes: true })).length === 0) { await params.stateRoot.remove(params.proposalDir); return `Removed empty legacy Skill Workshop proposal directory ${params.proposalId}.`; } await params.stateRoot.mkdir(RECOVERY_PROPOSALS_DIR); const recoveryPath = `${RECOVERY_PROPOSALS_DIR}/${params.proposalId}-${randomUUID()}`; await params.stateRoot.move(params.proposalDir, recoveryPath, { overwrite: true }); return `Quarantined incomplete Skill Workshop proposal ${params.proposalId} to ${recoveryPath} for manual recovery.`; } /** Import verified legacy proposal sidecars, then remove only the imported JSON metadata. */ async function importLegacySkillProposalSidecars(params) { const env = params.env ?? process.env; const stateDir = resolveStateDir(env); const warnings = []; const changes = []; if (!await pathExists(path.join(stateDir, "skill-workshop/proposals"))) { if (await pathExists(path.join(stateDir, MANIFEST_PATH))) { await removePathWithinRoot({ rootDir: stateDir, relativePath: MANIFEST_PATH }); changes.push("Removed the empty legacy Skill Workshop proposal index."); } return { changes, warnings, detected: 0, migrated: 0 }; } const stateRoot = await root(stateDir); let entries; try { entries = await stateRoot.list(LEGACY_WORKSHOP_PROPOSALS_DIR, { withFileTypes: true }); } catch (error) { if (!hasErrnoCode(error, "not-found")) warnings.push(`Failed to inspect legacy Skill Workshop proposals: ${String(error)}`); return { changes, warnings, detected: 0, migrated: 0 }; } const proposalIds = entries.filter((entry) => entry.isDirectory && LEGACY_WORKSHOP_PROPOSAL_ID_PATTERN.test(entry.name)).map((entry) => entry.name).toSorted((left, right) => left.localeCompare(right)); let recoverableWarningCount = 0; const prepared = /* @__PURE__ */ new Map(); for (const proposalId of proposalIds) try { prepared.set(proposalId, await prepareLegacyProposal({ config: params.config, env, proposalId, stateRoot })); } catch (error) { prepared.set(proposalId, { error }); } const database = openOpenClawStateDatabase({ env }); const kysely = getNodeSqliteKysely(database.db); let migrated = 0; let retainedForRehearsal = false; for (const proposalId of proposalIds) { const proposalDir = `${LEGACY_WORKSHOP_PROPOSALS_DIR}/${proposalId}`; const proposal = prepared.get(proposalId); if (proposal === null) { retainedForRehearsal = true; continue; } if ("warning" in proposal) { warnings.push(proposal.warning); recoverableWarningCount += 1; continue; } try { if ("error" in proposal) throw proposal.error; if (await migrateProposal({ config: params.config, env, prepared: proposal, stateRoot })) migrated += 1; else retainedForRehearsal = true; continue; } catch (error) { if (!isMissingPathError(error)) { warnings.push(`Failed to migrate Skill Workshop proposal ${proposalId}: ${String(error)}`); continue; } const stored = tableExists(database.db, "skill_workshop_proposals") ? executeSqliteQueryTakeFirstSync(database.db, kysely.selectFrom("skill_workshop_proposals").selectAll().where("proposal_id", "=", proposalId).where("owner_agent_id", "is not", null)) : void 0; if (stored && parseSkillProposalRow(stored)) continue; try { changes.push(await reconcileIncompleteProposal({ proposalId, proposalDir, stateRoot })); } catch (reconcileError) { warnings.push(`Could not quarantine incomplete Skill Workshop proposal ${proposalId}: ${String(reconcileError)}. Manually move ${proposalDir} to ${RECOVERY_PROPOSALS_DIR} to recover it.`); } } } if (!retainedForRehearsal) await removePathWithinRoot({ rootDir: stateDir, relativePath: MANIFEST_PATH }).catch((error) => { if (!isMissingPathError(error)) warnings.push(`Failed to remove legacy Skill Workshop proposal index: ${String(error)}`); }); if (migrated > 0) changes.unshift(`Migrated ${migrated} Skill Workshop proposal${migrated === 1 ? "" : "s"} into shared SQLite.`); return { changes, warnings, ...warnings.length > 0 && warnings.length === recoverableWarningCount ? { warningDisposition: "recoverable" } : {}, detected: proposalIds.length, migrated }; } //#endregion //#region src/commands/doctor-skill-workshop-workspaces.ts const MIGRATION_KIND = WORKSPACE_CONTENT_RELOCATION_MIGRATION_KIND; const absolutePathSchema = string().refine((value) => path.isAbsolute(value) && path.resolve(value) === value); const relocationSchema = strictObject({ workspaceDir: absolutePathSchema, workspaceKey: string(), workspacePath: absolutePathSchema, directoryIdentity: string(), attestedAtMs: number().int().nonnegative(), moves: array(strictObject({ source: absolutePathSchema, destination: absolutePathSchema, sha256: string().regex(/^[a-f0-9]{64}$/u) })).min(1) }); async function directoryIdentity(directory) { try { const stat = await fs.stat(directory, { bigint: true }); return stat.isDirectory() ? `${stat.dev}:${stat.ino}:${stat.birthtimeNs}` : void 0; } catch (error) { if (hasErrnoCode(error, "ENOENT")) return; throw error; } } async function containsOnlyMovingSkills(workspaceDir, sources, beforeMove) { const moving = new Set(beforeMove ? sources : []); const parents = /* @__PURE__ */ new Set(); for (const source of sources) { if (!isPathInside(workspaceDir, source) || source === workspaceDir) return false; for (let parent = path.dirname(source); parent !== workspaceDir; parent = path.dirname(parent)) parents.add(parent); } const pending = [workspaceDir]; while (pending.length > 0) { const directory = pending.pop(); for (const entry of await fs.readdir(directory, { withFileTypes: true })) { const entryPath = path.join(directory, entry.name); if (entry.name === ".DS_Store" && entry.isFile()) continue; if (!entry.isDirectory()) return false; if (moving.has(entryPath)) moving.delete(entryPath); else if (parents.has(entryPath)) pending.push(entryPath); else return false; } } return moving.size === 0; } /** Persist the pre-move facts; a retry must not infer them from an already empty workspace. */ async function prepareWorkshopWorkspaceRelocation(workspaceDir, moves, env) { if ([workspaceDir, ...moves.flatMap((move) => [move.source, move.destination])].some((filePath) => isUpdateRehearsalReadOnlyPath(filePath, env))) return; const sourceKey = resolveLegacyMigrationSourceKey(MIGRATION_KIND, workspaceDir); const database = openOpenClawStateDatabase({ env }); const kysely = getNodeSqliteKysely(database.db); if (executeSqliteQueryTakeFirstSync(database.db, kysely.selectFrom("migration_sources").select("status").where("source_key", "=", sourceKey))?.status === "prepared") return; const snapshot = await readWorkspaceStateSnapshot(workspaceDir, { env, readOnly: true }); const attestation = snapshot.attestation; const now = Date.now(); if (snapshot.setupExists || !attestation || attestation.generatedHashes.size > 0 || attestation.attestedAtMs > now || now - attestation.attestedAtMs > 864e5) return; const identity = await directoryIdentity(workspaceDir); if (!identity || !await containsOnlyMovingSkills(workspaceDir, moves.map((move) => move.source), true)) return; const captured = { workspaceDir, ...snapshot.identity, directoryIdentity: identity, attestedAtMs: attestation.attestedAtMs, moves: await Promise.all(moves.map(async (move) => ({ source: move.source, destination: move.destination, sha256: await readSkillProposalTargetTreeSha256(move.source, { includeRootMetadata: true }) }))) }; if (await directoryIdentity(workspaceDir) !== identity) return; const reportJson = JSON.stringify(captured); runOpenClawStateWriteTransaction(({ db }) => { if (executeSqliteQueryTakeFirstSync(db, kysely.selectFrom("migration_sources").select("status").where("source_key", "=", sourceKey))?.status === "prepared") return; const runId = randomUUID(); recordLegacyMigrationRun(db, { runId, startedAt: now, finishedAt: null, status: "prepared", reportJson }); recordLegacyMigrationSource(db, { sourceKey, migrationKind: MIGRATION_KIND, sourcePath: workspaceDir, targetTable: "workspace_setup_state", sourceSha256: null, sourceSizeBytes: null, sourceRecordCount: moves.length, runId, status: "prepared", importedAt: now, reportJson, upsert: true }); }, { env }); } /** Retire only obsolete skill-only evidence; missing, changed, and refreshed workspaces stay guarded. */ async function finishWorkshopWorkspaceRelocations(env) { const database = openOpenClawStateDatabase({ env }); const kysely = getNodeSqliteKysely(database.db); const receipts = executeSqliteQuerySync(database.db, kysely.selectFrom("migration_sources").selectAll().where("migration_kind", "=", MIGRATION_KIND).where("status", "=", "prepared")).rows; for (const receipt of receipts) { const captured = relocationSchema.parse(JSON.parse(receipt.report_json)); const retainedPaths = [ captured.workspaceDir, captured.workspacePath, ...captured.moves.flatMap((move) => [move.source, move.destination]) ]; if (retainedPaths.some((filePath) => isUpdateRehearsalReadOnlyPath(filePath, env))) continue; const sources = captured.moves.map((move) => move.source); let complete = await directoryIdentity(captured.workspaceDir) === captured.directoryIdentity; if (complete) complete = await containsOnlyMovingSkills(captured.workspaceDir, sources, false); for (const move of complete ? captured.moves : []) if (await directoryIdentity(move.source) !== void 0 || await directoryIdentity(move.destination) === void 0 || await readSkillProposalTargetTreeSha256(move.destination, { includeRootMetadata: true }) !== move.sha256) { complete = false; break; } if (complete) complete = await directoryIdentity(captured.workspaceDir) === captured.directoryIdentity; runOpenClawStateWriteTransaction((writeDatabase) => { if (retainedPaths.some((filePath) => isUpdateRehearsalReadOnlyPath(filePath, env))) return; const { db } = writeDatabase; const current = executeSqliteQueryTakeFirstSync(db, kysely.selectFrom("migration_sources").selectAll().where("source_key", "=", receipt.source_key)); if (current?.status !== "prepared" || current.report_json !== receipt.report_json) return; const retired = complete && retireWorkspaceRelocationAttestation({ database: writeDatabase, identity: captured, attestedAtMs: captured.attestedAtMs }); const status = retired ? "completed" : "superseded"; const reportJson = JSON.stringify({ ...captured, outcome: retired ? "obsolete attestation retired" : "workspace or attestation changed" }); executeSqliteQuerySync(db, kysely.updateTable("migration_sources").set({ status, report_json: reportJson }).where("source_key", "=", receipt.source_key)); recordLegacyMigrationRun(db, { runId: current.last_run_id, startedAt: current.imported_at, finishedAt: Date.now(), status, reportJson, upsert: true }); }, { env }); } } //#endregion //#region src/commands/doctor-skill-workshop-sqlite.ts async function inspectLegacySkillWorkshopMigration(params) { const env = params.env ?? process.env; const stateEnv = params.stateEnv ?? env; const { records, appliedEvents } = await readWorkshopMigrationRecords(stateEnv, true); const { external } = classifyWorkshopRelocation(records.filter(({ record }) => !isReadOnlyRehearsalProposal(record, env)), params.config, env); const backups = await listPendingLegacyCollectionBackupRoots(params.config, env); const automationReferences = await inspectWorkshopAutomationReferences({ config: params.config, env, stateEnv, records, appliedEvents }); return { externalProposalCount: external.length, externalProposalCountsByAgent: external.reduce((counts, plan) => { const ownerAgentId = plan.ownerAgentId ?? plan.unconfiguredOwnerAgentId ?? "unknown"; counts[ownerAgentId] = (counts[ownerAgentId] ?? 0) + 1; return counts; }, {}), ...external.length > 0 ? { externalProposalDetails: external.toSorted((left, right) => left.record.id.localeCompare(right.record.id)).slice(0, 20).map(({ record, ownerAgentId, unconfiguredOwnerAgentId }) => truncateWithMarker(`${record.id}: ${record.target.skillDir} (owner: ${ownerAgentId ?? unconfiguredOwnerAgentId ?? "unknown"})`, 2e3, { marker: "…", reserve: 1, trimEnd: true })) } : {}, legacyBackupRootCount: backups.length, preservedLegacyBackupRootCount: backups.filter((backup) => "warning" in backup).length, ...automationReferences.length > 0 ? { automationReferences } : {} }; } async function relocateLegacyWorkshopTargets(config, env, backupRoots, assertCurrent, unavailableWorkspaceDirs = /* @__PURE__ */ new Map()) { assertCurrent(); const database = openOpenClawStateDatabase({ env }); const kysely = getNodeSqliteKysely(database.db); const readRows = () => tableExists(database.db, "skill_workshop_proposals") ? executeSqliteQuerySync(database.db, kysely.selectFrom("skill_workshop_proposals").selectAll()).rows : []; const deferredSources = /* @__PURE__ */ new Set(); const recoverableDeferredSources = /* @__PURE__ */ new Set(); const hasRollback = (proposalId) => tableExists(database.db, "skill_workshop_proposal_rollbacks") && executeSqliteQueryTakeFirstSync(database.db, kysely.selectFrom("skill_workshop_proposal_rollbacks").select("proposal_id").where("proposal_id", "=", proposalId)) !== void 0; const recoveryWarnings = []; const readOnlyProposalIds = new Set(resolveUpdateRehearsalRoot(env) && tableExists(database.db, "skill_workshop_proposal_rollbacks") ? executeSqliteQuerySync(database.db, kysely.selectFrom("skill_workshop_proposal_rollbacks").select(["proposal_id", "target_skill_file"])).rows.filter((row) => isUpdateRehearsalReadOnlyPath(row.target_skill_file, env)).map((row) => row.proposal_id) : []); let missingDraftsRetired = 0; let recoverableWarningCount = 0; for (const row of readRows()) { assertCurrent(); const record = parseSkillProposalRow(row); if (!record || record.status !== "pending") continue; if (readOnlyProposalIds.has(record.id) || isReadOnlyRehearsalProposal(record, env)) { const source = resolveCanonicalWorkspacePath(record.target.skillDir); deferredSources.add(source); recoverableDeferredSources.add(source); continue; } const workspaceDir = resolveLegacyWorkshopWorkspaceDir(record.target.skillDir, config, env); const unavailableReason = workspaceDir && unavailableWorkspaceDirs.get(resolveWorkspaceStateIdentity(workspaceDir).workspacePath); if (unavailableReason && hasRollback(record.id)) { const source = resolveCanonicalWorkspacePath(record.target.skillDir); deferredSources.add(source); recoverableDeferredSources.add(source); recoveryWarnings.push(`Preserved Skill Workshop proposal ${record.id} and its unfinished apply recovery for manual review: ${unavailableReason}`); recoverableWarningCount += 1; continue; } try { await readSkillProposalBundle(record, { config, env }); assertCurrent(); } catch (error) { assertCurrent(); if (!(error instanceof SkillProposalDraftMissingError)) { recoveryWarnings.push(`Could not inspect Skill Workshop proposal ${record.id}: ${String(error)}`); deferredSources.add(resolveCanonicalWorkspacePath(record.target.skillDir)); continue; } if (hasRollback(record.id)) { recoveryWarnings.push(`Skill Workshop proposal ${record.id} has a missing draft and unfinished apply recovery; restore its draft before retrying Doctor.`); deferredSources.add(resolveCanonicalWorkspacePath(record.target.skillDir)); continue; } assertCurrent(); await transitionPendingSkillProposalToStale({ record, reason: "Proposal draft is missing. Metadata and remaining files were preserved for recovery.", input: { config, env, eventActor: { type: "system" } } }); missingDraftsRetired += 1; continue; } const { ownerAgentId } = inferOwnerAgentId({ record, config, env, workspaceDir, rowOwnerAgentId: row.owner_agent_id }); if (!workspaceDir || !ownerAgentId || isPathInside(resolveWorkshopSkillsDir(config, ownerAgentId, env), record.target.skillDir) || !await readSkillProposalRollback(record.id, { env })) continue; try { assertWorkspaceStateMigrationReady({ workspaceDirs: [workspaceDir], env, operation: "doctor" }); const sourceStat = await readLegacyWorkshopSourceStat(workspaceDir, record.target.skillDir); if (sourceStat?.isSymbolicLink()) continue; const skillsRoot = [path.join(workspaceDir, "skills"), path.join(workspaceDir, ".agents", "skills")].find((rootDir) => isPathInside(rootDir, record.target.skillDir)); if (!skillsRoot) throw new Error("the original skill root could not be verified"); const target = resolveSkillProposalTarget({ skillName: record.target.skillKey, config, agentId: ownerAgentId, env }); if (!sourceStat && await pathExists(target.skillDir)) throw new Error("the original skill is missing and its destination already exists"); const store = { config, env, agentId: ownerAgentId }; const proposal = await readSkillProposalBundle(record, store); assertCurrent(); if (!await reconcileInterruptedSkillProposalApply({ record, expectedRecordJson: row.record_json, draftContent: proposal.content, skillsRoot, store })) throw new Error("the interrupted apply could not be verified or restored"); } catch (error) { assertCurrent(); deferredSources.add(resolveCanonicalWorkspacePath(record.target.skillDir)); recoveryWarnings.push(`Skill Workshop did not relocate ${record.target.skillDir}: ${String(error)}. Recovery for proposal ${record.id} remains pending; check its files and saved proposal before retrying Doctor.`); } } const rows = readRows(); const initialRows = new Map(rows.map((row) => [row.proposal_id, row])); const records = rows.flatMap((row) => { const record = parseSkillProposalRow(row); return record && !readOnlyProposalIds.has(record.id) ? [{ record, ownerAgentId: row.owner_agent_id }] : []; }); const persistedUpdates = []; const persistUpdates = (updates) => { if (updates.length === 0 || updates.some(({ record }) => isReadOnlyRehearsalProposal(record, env))) return; if (runOpenClawStateWriteTransaction(({ db }) => { assertCurrent(); const currentUpdates = updates.map((update) => { const expected = initialRows.get(update.record.id); const current = readStoredProposalInDatabase(db, update.record.id); if (!current || current.row.record_json !== expected?.record_json || current.row.owner_agent_id !== expected?.owner_agent_id) throw new Error(`Skill proposal changed during relocation: ${update.record.id}`); return { update, current }; }); if (currentUpdates.some(({ current }) => isReadOnlyRehearsalProposal(current.record, env))) return false; for (const { update, current } of currentUpdates) updateProposal(db, current.row, update.record, update.ownerAgentId); return true; }, { env }, { operationLabel: "skill-workshop.relocation.commit" })) persistedUpdates.push(...updates); }; const plan = await planWorkshopRelocation(records, config, env, deferredSources, unavailableWorkspaceDirs, recoverableDeferredSources); const workspaceMoves = /* @__PURE__ */ new Map(); for (const move of plan.moves) { if (move.operation === "adopt") continue; const moves = workspaceMoves.get(move.workspaceDir) ?? []; moves.push(move); workspaceMoves.set(move.workspaceDir, moves); } for (const [workspaceDir, moves] of workspaceMoves) { assertCurrent(); await prepareWorkshopWorkspaceRelocation(workspaceDir, moves, env); } let movedSkills = 0; for (const move of plan.moves) { assertCurrent(); if ([move.source, move.destination].some((filePath) => isUpdateRehearsalReadOnlyPath(filePath, env)) || move.updates.some(({ record }) => isReadOnlyRehearsalProposal(record, env))) continue; if (move.operation === "move") { await fs.mkdir(path.dirname(move.destination), { recursive: true }); assertCurrent(); await movePathWithCopyFallback({ from: move.source, to: move.destination }); movedSkills += 1; } else if (move.operation === "remove-source") await fs.rm(move.source, { recursive: true, force: false }); persistUpdates(move.updates); } persistUpdates(plan.updates); assertCurrent(); await finishWorkshopWorkspaceRelocations(env); assertCurrent(); const backupMigration = await migrateLegacyCollectionBackups(config, env, backupRoots); const staleProposals = persistedUpdates.filter((update) => update.record.status === "stale").length; return { movedSkills, retargetedProposals: persistedUpdates.length - staleProposals, staleProposals: staleProposals + missingDraftsRetired, migratedBackupRoots: backupMigration.migrated, warnings: [ ...recoveryWarnings, ...plan.warnings, ...backupMigration.warnings ], recoverableWarningCount: recoverableWarningCount + plan.recoverableWarningCount + backupMigration.recoverableWarningCount }; } async function migrateLegacySkillWorkshopProposals(params) { const env = params.env ?? process.env; const migrate = async (assertCurrent) => { assertCurrent(); const backupRoots = await listPendingLegacyCollectionBackupRoots(params.config, env); assertCurrent(); const sidecars = await importLegacySkillProposalSidecars({ config: params.config, env }); assertCurrent(); const relocation = await relocateLegacyWorkshopTargets(params.config, env, backupRoots, assertCurrent, params.unavailableWorkspaceDirs); if (relocation.movedSkills > 0 || relocation.retargetedProposals > 0 || relocation.staleProposals > 0 || relocation.migratedBackupRoots > 0) sidecars.changes.push(`Relocated ${relocation.movedSkills} Skill Workshop skill${relocation.movedSkills === 1 ? "" : "s"}, retargeted ${relocation.retargetedProposals} proposal${relocation.retargetedProposals === 1 ? "" : "s"}, marked ${relocation.staleProposals} stale, and migrated ${relocation.migratedBackupRoots} legacy collection backup root${relocation.migratedBackupRoots === 1 ? "" : "s"}.`); const warnings = [...sidecars.warnings, ...relocation.warnings]; const recoverableWarningCount = (sidecars.warningDisposition === "recoverable" ? sidecars.warnings.length : 0) + relocation.recoverableWarningCount; return { changes: sidecars.changes, detected: sidecars.detected, migrated: sidecars.migrated, warnings, ...warnings.length > 0 && warnings.length === recoverableWarningCount ? { warningDisposition: "recoverable" } : {} }; }; const maintenance = getOpenClawDatabaseMaintenanceScope(); if (maintenance?.ownsSchemaMaintenance) return await maintenance.run(() => migrate(() => maintenance.assertOwnerCurrent())); const owner = await acquireGatewayLock({ env, role: "sqlite-maintenance", allowInTests: true }); if (!owner) throw new Error("Skill Workshop migration requires exclusive state ownership"); try { return await owner.run(() => migrate(() => owner.assertCurrent())); } finally { await owner.release(); } } //#endregion export { inspectLegacySkillWorkshopMigration, migrateLegacySkillWorkshopProposals };