openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
744 lines (743 loc) • 38.5 kB
JavaScript
import { r as asNullableRecord } from "./record-coerce-TIsquoVR.mjs";
import { d as pathExists$1, w as root$1 } from "./fs-safe-BUXBSN-4.mjs";
import { r as isMissingPathError } from "./errno-FR4CRyya.mjs";
import { i as isPathStrictlyInside, 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 { c as resolveWorkspaceStateIdentity, o as resolveCanonicalWorkspacePath } from "./workspace-state-identity-EPAs22n_.mjs";
import { r as isUpdateRehearsalReadOnlyPath } from "./update-rehearsal-paths-DTVdt0cq.mjs";
import { c as sha256File } from "./directory-durability-CqSvP1tc.mjs";
import { n as normalizeAgentId } from "./agent-id-D1-Q6vR4.mjs";
import { r as captureOpenClawStateWorkerContext } from "./openclaw-state-worker-context-DRG_I6tz.mjs";
import { d as resolveAgentWorkspaceDir } from "./agent-scope-config-CDYpzA0r.mjs";
import { M as parseAgentSessionKey } from "./session-key-C09ow4h0.mjs";
import { i as listAgentIds } from "./agent-roster-BXD1uMJD.mjs";
import "./agent-scope-CG17uXFn.mjs";
import { s as assertWorkspaceStateMigrationReady } from "./workspace-legacy-state-CfAX5lfc.mjs";
import { i as resolveSkillManifestMetadata } from "./frontmatter-CrckYGsg.mjs";
import { M as stripProposalFrontmatterForSkill, a as readSkillProposal, d as resolveSkillProposalTarget } from "./store-D9K8vtNx.mjs";
import { u as readWorkspaceSkillFile } from "./workspace-skill-write-Ch4M1BlA.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 } from "./store-record-CSGbu9Vk.mjs";
import { r as executeSkillWorkshopOperation, t as captureSkillWorkshopStoreOptions } from "./store-client-BMKnc-Bx.mjs";
import { l as readSkillFrontmatterSafe } from "./skill-paths-BxFt63--.mjs";
import { a as resolveSkillDiscoveryLimits } from "./skill-root-discovery-Br--9LSl.mjs";
import { t as resolveSkillCollectionBackupRoot } from "./collection-paths-BqEarDf2.mjs";
import { n as readSkillCollectionBackupDrops } from "./collection-review-state-HIkTXhVF.mjs";
import path from "node:path";
import fs from "node:fs/promises";
import { isDeepStrictEqual } from "node:util";
import { createHash, randomUUID } from "node:crypto";
import { sameFileIdentity } from "@openclaw/fs-safe/advanced";
import { root } from "@openclaw/fs-safe/root";
//#region src/commands/doctor-skill-workshop-collection-backups.ts
const LEGACY_COLLECTION_BACKUP_SCHEMA = "openclaw.skill-collection-backup.v1";
const MAX_BACKUP_MANIFEST_BYTES = 1048576;
async function listLegacyCollectionBackupRoots(env) {
const backupRoot = path.join(resolveStateDir(env), "skill-workshop", "collection-backups");
if (!await pathExists$1(backupRoot)) return [];
return (await fs.readdir(backupRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory() && /^[a-f0-9]{16}$/u.test(entry.name)).map((entry) => path.join(backupRoot, entry.name));
}
async function listLegacyCollectionBackupWorkspaceDirs(env) {
const workspaces = /* @__PURE__ */ new Set();
for (const legacyRoot of await listLegacyCollectionBackupRoots(env)) try {
for (const backup of await readLegacyCollectionBackups(legacyRoot)) workspaces.add(backup.workspaceDir);
} catch {}
return [...workspaces];
}
async function listPendingLegacyCollectionBackupRoots(config, env) {
const roots = [];
const archiveRoots = listAgentIds(config).map((agentId) => resolveSkillCollectionBackupRoot(config, agentId, env)).filter((root) => !isUpdateRehearsalReadOnlyPath(root, env));
for (const legacyRoot of await listLegacyCollectionBackupRoots(env)) {
if (isUpdateRehearsalReadOnlyPath(legacyRoot, env)) continue;
try {
const retainedBackups = await readLegacyCollectionBackups(legacyRoot);
if (retainedBackups.some((backup) => isReadOnlyRehearsalBackup(backup, env))) continue;
const backups = [];
for (const backup of retainedBackups) if (!(await Promise.all(archiveRoots.map((root) => isHistoryOnlyBackup(backup, path.join(root, backup.manifest.id))))).some(Boolean)) backups.push(backup);
if (backups.length === 0) continue;
const workspaceDirs = new Set(retainedBackups.map((backup) => backup.workspaceDir));
const workspaceDir = [...workspaceDirs][0];
const candidateAgentIds = [...new Set([...workspaceDirs].flatMap((directory) => listWorkspaceOwnerAgentIds(config, env, directory)))].toSorted();
let ownerAgentId = workspaceDirs.size === 1 && workspaceDir && candidateAgentIds.length === 1 ? candidateAgentIds[0] : void 0;
if (workspaceDirs.size === 1 && candidateAgentIds.length === 0) {
const verifiedAgents = await verifyLegacyCollectionBackupOwners(backups, config, env);
const candidates = verifiedAgents.filter((agent) => agent.verified);
if (candidates.length === 1) ownerAgentId = candidates[0]?.agentId;
else {
roots.push({
legacyRoot,
warning: `Preserved legacy collection backup root ${legacyRoot}: ${candidates.length === 0 ? "no verified owner" : "multiple verified owners"} after workspace move (candidate agents: ${candidates.map((agent) => agent.agentId).join(", ") || "none"}). ${verifiedAgents.map((agent) => agent.detail).join("; ")}. Pause Workshop writes and compare copies of the retained backup and current skills using https://docs.openclaw.ai/tools/skill-workshop/collection-review#when-an-older-backup-cannot-be-restored-automatically; keep the original manifest unchanged. Retry Doctor after resolving the ownership evidence.`,
recoverable: true
});
continue;
}
}
if (!ownerAgentId) {
roots.push({
legacyRoot,
warning: `Preserved legacy collection backup root ${legacyRoot} for manual review: workspace ${[...workspaceDirs].join(", ")} does not map to exactly one configured agent (candidate agents: ${candidateAgentIds.join(", ") || "none"}). Review the workspace ownership and retained backup manifests before retrying Doctor.`,
recoverable: true
});
continue;
}
assertWorkspaceStateMigrationReady({
workspaceDirs: [...workspaceDirs],
env,
operation: "doctor"
});
const destinationRoot = resolveSkillCollectionBackupRoot(config, ownerAgentId, env);
if (isUpdateRehearsalReadOnlyPath(destinationRoot, env)) continue;
roots.push({
legacyRoot,
backups,
ownerAgentId,
destinationRoot
});
} catch (error) {
roots.push({
legacyRoot,
warning: `Preserved legacy collection backup root ${legacyRoot}: ${String(error)}`
});
}
}
return roots;
}
function isReadOnlyRehearsalBackup(backup, env) {
return [
backup.backupDir,
backup.workspaceDir,
...[...backup.sourceDirs.values()].flatMap((relativeDir) => [path.join(backup.workspaceDir, relativeDir), path.join(backup.backupDir, "workspace", relativeDir)])
].some((filePath) => isUpdateRehearsalReadOnlyPath(filePath, env));
}
function listWorkspaceOwnerAgentIds(config, env, workspaceDir) {
return listAgentIds(config).filter((agentId) => resolveCanonicalWorkspacePath(resolveAgentWorkspaceDir(config, agentId, env)) === resolveCanonicalWorkspacePath(workspaceDir));
}
function legacyCollectionSkillPath(workspaceDir, relativeDir) {
if (!relativeDir || path.isAbsolute(relativeDir) || relativeDir !== path.normalize(relativeDir)) throw new Error(`invalid skill path ${relativeDir}`);
const absoluteDir = path.resolve(workspaceDir, relativeDir);
const writableRoot = [path.resolve(workspaceDir, "skills"), path.resolve(workspaceDir, ".agents", "skills")].find((rootDir) => isPathStrictlyInside(rootDir, absoluteDir));
if (!writableRoot) throw new Error(`skill path is outside the workspace skill roots: ${relativeDir}`);
return path.relative(writableRoot, absoluteDir);
}
function readLegacyCollectionBackupManifest(value, backupDir) {
const backupId = path.basename(backupDir);
const record = asNullableRecord(value);
const skillDirs = record?.skillDirs;
const resultSkillDirs = record?.resultSkillDirs;
if (record?.schema !== LEGACY_COLLECTION_BACKUP_SCHEMA || record.id !== backupId || typeof record.createdAt !== "string" || typeof record.workspaceDir !== "string" || !Array.isArray(skillDirs) || !skillDirs.every((entry) => typeof entry === "string") || !Array.isArray(resultSkillDirs) || !resultSkillDirs.every((entry) => typeof entry === "string")) throw new Error(`invalid legacy collection backup manifest: ${backupId}`);
const workspaceDir = path.resolve(record.workspaceDir);
const convertedDirs = new Map([.../* @__PURE__ */ new Set([...skillDirs, ...resultSkillDirs])].map((relativeDir) => [relativeDir, legacyCollectionSkillPath(workspaceDir, relativeDir)]));
const sourceDirs = new Map([...convertedDirs].map(([source, destination]) => [destination, source]));
if (sourceDirs.size !== convertedDirs.size) throw new Error(`legacy collection backup paths collide: ${backupId}`);
const resultSkillHashes = asNullableRecord(record.resultSkillHashes);
if (!resultSkillHashes) throw new Error(`invalid legacy collection backup hashes: ${backupId}`);
const parsedResultSkillHashes = {};
for (const relativeDir of resultSkillDirs) {
const hash = resultSkillHashes[relativeDir];
if (typeof hash !== "string") throw new Error(`invalid legacy collection backup hashes: ${backupId}`);
parsedResultSkillHashes[convertedDirs.get(relativeDir)] = hash;
}
if (Object.keys(resultSkillHashes).some((key) => !resultSkillDirs.includes(key))) throw new Error(`invalid legacy collection backup hashes: ${backupId}`);
return {
backupDir,
workspaceDir,
sourceDirs,
manifest: {
schema: "openclaw.skill-collection-backup.v2",
id: backupId,
createdAt: record.createdAt,
skillDirs: [...new Set(skillDirs)].map((relativeDir) => convertedDirs.get(relativeDir)),
resultSkillDirs: [...new Set(resultSkillDirs)].map((relativeDir) => convertedDirs.get(relativeDir)),
resultSkillHashes: parsedResultSkillHashes
}
};
}
async function readLegacyCollectionBackups(backupRoot) {
const entries = await fs.readdir(backupRoot, { withFileTypes: true });
const backups = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".pending-")) continue;
const manifestText = await fs.readFile(path.join(backupRoot, entry.name, "manifest.json"), "utf8");
if (Buffer.byteLength(manifestText, "utf8") > MAX_BACKUP_MANIFEST_BYTES) throw new Error(`legacy collection backup manifest is too large: ${entry.name}`);
backups.push(readLegacyCollectionBackupManifest(JSON.parse(manifestText), path.join(backupRoot, entry.name)));
}
return backups;
}
async function hasNewerUnrelatedCollectionBackup(backupRoot, backups) {
const pending = new Map(backups.map((backup) => [backup.manifest.id, backup]));
const newestLegacy = backups.map((backup) => backup.manifest.createdAt).toSorted().at(-1);
const entries = await fs.readdir(backupRoot, { withFileTypes: true }).catch(() => []);
return (await Promise.all(entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".pending-")).map(async (entry) => {
const backup = pending.get(entry.name);
if (backup) {
await verifyLegacyCollectionBackupCopy(backup, path.join(backupRoot, entry.name));
return false;
}
const record = asNullableRecord(JSON.parse(await fs.readFile(path.join(backupRoot, entry.name, "manifest.json"), "utf8")));
return record?.schema === "openclaw.skill-collection-backup.v2" && typeof record.restoreUnavailableReason !== "string" && typeof record.createdAt === "string" && record.createdAt.localeCompare(newestLegacy) >= 0;
}))).some(Boolean);
}
async function isHistoryOnlyBackup(backup, backupDir) {
try {
const record = asNullableRecord(JSON.parse(await fs.readFile(path.join(backupDir, "manifest.json"), "utf8")));
if (typeof record?.restoreUnavailableReason !== "string" || !isDeepStrictEqual(record, {
...backup.manifest,
skillDirs: [],
resultSkillDirs: [],
resultSkillHashes: {},
restoreUnavailableReason: record.restoreUnavailableReason
})) return false;
const source = path.join(backup.backupDir, "workspace");
const copied = path.join(backupDir, "history", "workspace");
const [sourceStat, copiedStat] = await Promise.all([fs.stat(source, { bigint: true }), fs.lstat(copied, { bigint: true })]);
if (!copiedStat.isDirectory() || sameFileIdentity(sourceStat, copiedStat)) return false;
const [sourceHash, copiedHash] = await Promise.all([source, copied].map(async (directory) => {
const scoped = await root(directory);
const hash = createHash("sha256");
for await (const entry of scoped.walk("", {
symlinkPolicy: "include",
order: "sorted"
})) {
if (entry.kind !== "file" && entry.kind !== "directory" && entry.kind !== "symlink") throw new Error(`Unsupported backup entry: ${entry.relativePath}`);
hash.update(JSON.stringify([entry.kind, entry.relativePath]));
if (entry.kind === "file") {
const opened = await scoped.open(entry.relativePath, {
symlinks: "reject",
hardlinks: "allow"
});
try {
hash.update((await sha256File(opened.handle, { maxBytes: opened.stat.size })).digest);
} finally {
await opened.handle.close();
}
} else if (entry.kind === "symlink") {
const linkPath = path.join(directory, entry.relativePath);
const target = path.resolve(path.dirname(linkPath), await fs.readlink(linkPath));
hash.update(JSON.stringify(path.toNamespacedPath(target)));
}
}
return hash.digest("hex");
}));
return sourceHash === copiedHash;
} catch {
return false;
}
}
async function readLegacyBackupSkillKey(backup, relativeDir, fallbackKey, maxSkillFileBytes) {
const skillDir = path.join(backup.backupDir, "workspace", backup.sourceDirs.get(relativeDir));
const frontmatter = readSkillFrontmatterSafe({
rootDir: skillDir,
filePath: path.join(skillDir, "SKILL.md"),
maxBytes: maxSkillFileBytes
});
if (!frontmatter) return await pathExists$1(skillDir) ? void 0 : fallbackKey;
return (resolveSkillManifestMetadata(frontmatter)?.skillKey ?? frontmatter.name)?.trim();
}
async function verifyLegacyCollectionBackupOwners(backups, config, env) {
const maxSkillFileBytes = resolveSkillDiscoveryLimits(config).maxSkillFileBytes;
const results = await Promise.all(backups.flatMap((backup) => backup.manifest.resultSkillDirs.map(async (relativeDir) => ({
label: `${backup.manifest.id}/${relativeDir}`,
hash: backup.manifest.resultSkillHashes[relativeDir],
skillKey: await readLegacyBackupSkillKey(backup, relativeDir, backup.manifest.skillDirs.includes(relativeDir) ? void 0 : path.basename(relativeDir), maxSkillFileBytes)
}))));
const emptyBackups = backups.filter((backup) => backup.manifest.resultSkillDirs.length === 0);
return Promise.all(listAgentIds(config).toSorted().map(async (agentId) => {
let matches = 0;
const failures = emptyBackups.map((backup) => `${backup.manifest.id}: no recorded result hashes`);
for (const result of results) try {
if (!result.skillKey) throw new Error("saved skill identity unavailable");
const target = resolveSkillProposalTarget({
skillName: result.skillKey,
config,
agentId,
env
});
if (isUpdateRehearsalReadOnlyPath(target.skillDir, env)) throw new Error("Workshop target is outside the update rehearsal");
if (!await pathExists$1(target.skillDir)) throw new Error("Workshop skill missing");
if (await readSkillProposalTargetTreeSha256(target.skillDir) !== result.hash) throw new Error("result hash differs");
matches += 1;
} catch (error) {
failures.push(`${result.label}: ${String(error)}`);
}
return {
agentId,
verified: results.length > 0 && failures.length === 0,
detail: `agent ${agentId}: ${matches}/${results.length} skills match${failures.length > 0 ? ` (${failures.join(", ")})` : ""}`
};
}));
}
async function findUnownedLegacyCollectionBackupDirs(backup, config, env, ownerAgentId) {
const unownedDirs = new Set(backup.sourceDirs.values());
const backedUpDirs = new Set(backup.manifest.skillDirs);
const resultDirs = new Set(backup.manifest.resultSkillDirs);
const store = captureSkillWorkshopStoreOptions({ env });
const droppedNames = await readSkillCollectionBackupDrops(ownerAgentId, backup.manifest.id, store);
const appliedCreates = (await executeSkillWorkshopOperation("workshop.proposals.list", {
agentId: ownerAgentId,
kind: "create"
}, store)).flatMap(({ record }) => record.appliedAt !== void 0 ? [record] : []);
const maxSkillFileBytes = resolveSkillDiscoveryLimits(config).maxSkillFileBytes;
for (const [relativeDir, sourceDir] of backup.sourceDirs) {
const legacyPath = path.resolve(backup.workspaceDir, sourceDir);
if (await pathExists$1(legacyPath)) continue;
const skillKey = await readLegacyBackupSkillKey(backup, relativeDir, backedUpDirs.has(relativeDir) ? void 0 : path.basename(relativeDir), maxSkillFileBytes);
if (!skillKey) continue;
const target = resolveSkillProposalTarget({
skillName: skillKey,
config,
agentId: ownerAgentId,
env
});
const targetExists = await pathExists$1(target.skillDir);
if (!resultDirs.has(relativeDir)) {
if (!targetExists && droppedNames.has(skillKey) && appliedCreates.some((record) => path.resolve(record.target.skillDir) === legacyPath && path.resolve(record.target.skillFile) === path.join(legacyPath, "SKILL.md") && record.target.skillKey === skillKey)) unownedDirs.delete(sourceDir);
continue;
}
if (!targetExists) continue;
if (await readSkillProposalTargetTreeSha256(target.skillDir) === backup.manifest.resultSkillHashes[relativeDir]) unownedDirs.delete(sourceDir);
}
return [...unownedDirs];
}
async function verifyLegacyCollectionBackupCopy(backup, destination) {
const published = JSON.parse(await fs.readFile(path.join(destination, "manifest.json"), "utf8"));
if (!isDeepStrictEqual(published, backup.manifest)) throw new Error(`destination backup manifest differs: ${destination}`);
for (const relativeDir of backup.manifest.skillDirs) {
const source = path.join(backup.backupDir, "workspace", backup.sourceDirs.get(relativeDir));
const copied = path.join(destination, "skills", relativeDir);
await Promise.all([fs.access(source), fs.access(copied)]);
const [sourceHash, copiedHash] = await Promise.all([source, copied].map((skillDir) => readSkillProposalTargetTreeSha256(skillDir, { includeRootMetadata: true })));
if (sourceHash !== copiedHash) throw new Error(`destination backup contents differ: ${destination}`);
}
}
async function publishLegacyCollectionBackup(backup, destinationRoot, config, env, ownerAgentId, newerBackupExists) {
const destination = path.join(destinationRoot, backup.manifest.id);
if (await pathExists$1(destination)) return true;
if (newerBackupExists) throw new Error(`newer agent backup already exists at ${destinationRoot}`);
const unownedDirs = await findUnownedLegacyCollectionBackupDirs(backup, config, env, ownerAgentId);
const restorable = unownedDirs.length === 0;
const manifest = restorable ? backup.manifest : {
...backup.manifest,
skillDirs: [],
resultSkillDirs: [],
resultSkillHashes: {},
restoreUnavailableReason: `Legacy collection paths are not proven Workshop-owned: ${unownedDirs.join(", ")}`
};
const copies = restorable ? backup.manifest.skillDirs.map((relativeDir) => ({
source: path.join(backup.backupDir, "workspace", backup.sourceDirs.get(relativeDir)),
relativeDir: path.join("skills", relativeDir)
})) : [{
source: path.join(backup.backupDir, "workspace"),
relativeDir: path.join("history", "workspace")
}];
const staging = path.join(destinationRoot, `.pending-legacy-${backup.manifest.id}-${randomUUID()}`);
try {
await fs.mkdir(path.join(staging, restorable ? "skills" : "history"), { recursive: true });
for (const copy of copies) {
const copied = path.join(staging, copy.relativeDir);
await fs.mkdir(path.dirname(copied), { recursive: true });
await fs.cp(copy.source, copied, {
recursive: true,
errorOnExist: true,
force: false,
preserveTimestamps: true
});
}
await fs.writeFile(path.join(staging, "manifest.json"), JSON.stringify(manifest, null, 2));
await fs.rename(staging, destination);
} catch (error) {
await fs.rm(staging, {
recursive: true,
force: true
});
throw error;
}
return restorable;
}
async function migrateLegacyCollectionBackups(config, env, roots) {
let migrated = 0;
let recoverableWarningCount = 0;
const warnings = [];
for (const root of roots) {
if ("warning" in root) {
warnings.push(root.warning);
if (root.recoverable) recoverableWarningCount += 1;
continue;
}
const { legacyRoot, backups, ownerAgentId, destinationRoot } = root;
if ([legacyRoot, destinationRoot].some((filePath) => isUpdateRehearsalReadOnlyPath(filePath, env)) || backups.some((backup) => isReadOnlyRehearsalBackup(backup, env))) continue;
try {
const newerBackupExists = await hasNewerUnrelatedCollectionBackup(destinationRoot, backups);
const restorable = [];
for (const backup of backups) if (await publishLegacyCollectionBackup(backup, destinationRoot, config, env, ownerAgentId, newerBackupExists)) restorable.push(backup);
for (const backup of restorable) await verifyLegacyCollectionBackupCopy(backup, path.join(destinationRoot, backup.manifest.id));
for (const backup of restorable) {
if (isReadOnlyRehearsalBackup(backup, env)) continue;
await fs.rm(backup.backupDir, {
recursive: true,
force: false
});
}
if ((await fs.readdir(legacyRoot)).length === 0) await fs.rmdir(legacyRoot);
migrated += 1;
} catch (error) {
warnings.push(`Preserved legacy collection backup root ${legacyRoot}: ${String(error)}`);
}
}
return {
migrated,
warnings,
recoverableWarningCount
};
}
//#endregion
//#region src/commands/doctor-skill-workshop-relocation.ts
const INVALID_LEGACY_SKILL_REASON = "Skill Workshop could not load the applied legacy skill; the path stays in place and the proposal is stale.";
function isReadOnlyRehearsalProposal(record, env) {
return [
record.target.skillDir,
record.target.skillFile,
...(record.supportFiles ?? []).map((file) => path.join(record.target.skillDir, file.path))
].some((filePath) => isUpdateRehearsalReadOnlyPath(filePath, env));
}
function resolveLegacyWorkshopWorkspaceDir(skillDir, config, env) {
const source = path.resolve(skillDir);
const configured = listAgentIds(config).flatMap((agentId) => {
const workspaceDir = path.resolve(resolveAgentWorkspaceDir(config, agentId, env));
return [workspaceDir, resolveCanonicalWorkspacePath(workspaceDir)];
}).toSorted((left, right) => right.length - left.length).find((workspaceDir) => [path.join(workspaceDir, "skills"), path.join(workspaceDir, ".agents", "skills")].some((skillsRoot) => isPathInside(skillsRoot, source)));
if (configured) return configured;
for (let directory = path.dirname(source); directory !== path.dirname(directory);) {
if (path.basename(directory) === "skills") {
const parent = path.dirname(directory);
return path.basename(parent) === ".agents" ? path.dirname(parent) : parent;
}
directory = path.dirname(directory);
}
}
function inferOwnerAgentId(params) {
const recordedOwner = params.rowOwnerAgentId || params.record.origin?.agentId || (params.record.origin?.sessionKey ? parseAgentSessionKey(params.record.origin.sessionKey)?.agentId : void 0);
let ownerAgentId = recordedOwner ? normalizeAgentId(recordedOwner) : void 0;
if (!ownerAgentId && params.workspaceDir) {
const matches = listWorkspaceOwnerAgentIds(params.config, params.env, params.workspaceDir);
ownerAgentId = matches.length === 1 ? matches[0] : void 0;
}
if (!ownerAgentId) return {};
return listAgentIds(params.config).includes(ownerAgentId) ? { ownerAgentId } : { unconfiguredOwnerAgentId: ownerAgentId };
}
async function verifyRelocationDestination(params) {
const content = await readWorkspaceSkillFile(params.destinationSkillFile);
const frontmatter = readSkillFrontmatterSafe({
rootDir: params.destinationSkillDir,
filePath: params.destinationSkillFile,
maxBytes: resolveSkillDiscoveryLimits(params.config).maxSkillFileBytes
});
const name = frontmatter?.name?.trim();
const skillKey = frontmatter ? (resolveSkillManifestMetadata(frontmatter)?.skillKey ?? name)?.trim() : void 0;
if (content === null || skillKey !== params.skillKey) return false;
const contentHash = hashSkillProposalContent(content);
for (const record of params.records) {
if (record.status === "pending") {
if (record.kind === "update" && record.target.currentContentHash === contentHash) return true;
continue;
}
let appliedContentHash = record.kind === "create" ? record.draftHash : void 0;
try {
const proposal = await readSkillProposal(record.id, {
config: params.config,
env: params.env
}, {}, {
config: params.config,
reconcile: false
});
if (proposal) appliedContentHash = hashSkillProposalContent(stripProposalFrontmatterForSkill(proposal.content));
} catch {}
if (contentHash === appliedContentHash) return true;
}
return false;
}
function staleWorkshopProposal(record, reason) {
const now = (/* @__PURE__ */ new Date()).toISOString();
return {
...record,
status: "stale",
updatedAt: now,
staleAt: now,
statusReason: reason
};
}
async function readLegacyWorkshopSourceStat(workspaceDir, source) {
let sourceStat;
try {
let directory = workspaceDir;
for (const segment of path.relative(workspaceDir, source).split(path.sep)) {
directory = path.join(directory, segment);
sourceStat = await fs.lstat(directory);
if (sourceStat.isSymbolicLink()) break;
}
} catch (error) {
if (!isMissingPathError(error)) throw error;
return;
}
return sourceStat;
}
function classifyWorkshopRelocation(records, config, env, deferredSources = /* @__PURE__ */ new Set()) {
const candidates = records.flatMap((entry) => {
if (entry.record.status !== "pending" && entry.record.status !== "applied") return [];
const source = path.resolve(entry.record.target.skillDir);
const workspaceDir = resolveLegacyWorkshopWorkspaceDir(source, config, env);
const owner = inferOwnerAgentId({
config,
env,
record: entry.record,
workspaceDir,
rowOwnerAgentId: entry.ownerAgentId
});
if (owner.ownerAgentId && entry.ownerAgentId && isPathInside(path.resolve(resolveWorkshopSkillsDir(config, owner.ownerAgentId, env)), source)) return [];
return [{
record: entry.record,
source,
workspaceDir,
deferred: deferredSources.size > 0 && deferredSources.has(resolveCanonicalWorkspacePath(source)),
...owner
}];
});
return {
candidates,
external: candidates.filter(({ record }) => record.status === "pending" || record.kind === "create")
};
}
async function planWorkshopRelocation(records, config, env, deferredSources = /* @__PURE__ */ new Set(), unavailableWorkspaceDirs = /* @__PURE__ */ new Map(), recoverableDeferredSources = /* @__PURE__ */ new Set()) {
const { candidates, external } = classifyWorkshopRelocation(records.filter(({ record }) => !isReadOnlyRehearsalProposal(record, env)), config, env, deferredSources);
const warnings = [];
let recoverableWarningCount = 0;
const deferredWorkspaces = /* @__PURE__ */ new Set();
for (const workspaceDir of new Set(external.map((plan) => plan.workspaceDir))) {
if (!workspaceDir || unavailableWorkspaceDirs.has(resolveWorkspaceStateIdentity(workspaceDir).workspacePath)) continue;
try {
assertWorkspaceStateMigrationReady({
workspaceDirs: [workspaceDir],
env,
operation: "doctor"
});
} catch (error) {
deferredWorkspaces.add(workspaceDir);
warnings.push(String(error));
}
}
const relocations = /* @__PURE__ */ new Map();
for (const plan of external) {
plan.unavailableReason = plan.workspaceDir ? unavailableWorkspaceDirs.get(resolveWorkspaceStateIdentity(plan.workspaceDir).workspacePath) : void 0;
plan.deferred ||= Boolean(plan.workspaceDir && deferredWorkspaces.has(plan.workspaceDir));
if (!plan.ownerAgentId || plan.record.status === "applied" && !plan.workspaceDir) continue;
const target = resolveSkillProposalTarget({
skillName: plan.record.target.skillKey,
config,
agentId: plan.ownerAgentId,
env
});
if ([target.skillDir, target.skillFile].some((filePath) => isUpdateRehearsalReadOnlyPath(filePath, env))) {
plan.deferred = true;
continue;
}
const key = [
plan.ownerAgentId,
plan.source,
path.resolve(plan.record.target.skillFile),
plan.record.target.skillKey,
target.skillDir
].join("\0");
plan.relocation = relocations.get(key) ?? {
target,
plans: []
};
plan.relocation.plans.push(plan);
relocations.set(key, plan.relocation);
}
const moves = [];
for (const relocation of relocations.values()) {
if (relocation.plans.some((plan) => plan.deferred)) continue;
const unavailableReason = relocation.plans.find((plan) => plan.unavailableReason)?.unavailableReason;
if (unavailableReason) {
relocation.rejection = unavailableReason;
continue;
}
const plan = relocation.plans.find(({ record }) => record.kind === "create" && record.status === "applied");
if (!plan?.workspaceDir) continue;
const { record } = plan;
const { target } = relocation;
let operation = "move";
const sourceStat = await readLegacyWorkshopSourceStat(plan.workspaceDir, plan.source);
if (sourceStat?.isSymbolicLink()) {
relocation.rejection = `Skill Workshop no longer writes through symlinked skills; ${plan.source} stays a workspace skill.`;
continue;
}
if (!sourceStat) {
if (!await pathExists$1(target.skillFile)) {
relocation.rejection = "Skill Workshop could not find the applied legacy skill; the proposal is stale.";
continue;
}
if (!await verifyRelocationDestination({
records: candidates.filter(({ record: candidate, source, ownerAgentId }) => ownerAgentId === plan.ownerAgentId && source === plan.source && candidate.target.skillKey === record.target.skillKey && path.resolve(candidate.target.skillFile) === path.resolve(record.target.skillFile)).map((candidate) => candidate.record),
skillKey: target.skillKey,
destinationSkillDir: target.skillDir,
destinationSkillFile: target.skillFile,
config,
env
})) {
relocation.rejection = "Skill Workshop could not adopt the relocated skill: destination identity mismatch (content hash or frontmatter name/key); the proposal is stale.";
continue;
}
operation = "adopt";
} else {
if (!readSkillFrontmatterSafe({
rootDir: plan.source,
filePath: path.join(plan.source, "SKILL.md"),
maxBytes: resolveSkillDiscoveryLimits(config).maxSkillFileBytes
})?.description?.trim()) {
relocation.rejection = INVALID_LEGACY_SKILL_REASON;
continue;
}
if (await pathExists$1(target.skillDir)) {
const destinationStat = await fs.lstat(target.skillDir);
let copied = false;
if (destinationStat.isDirectory()) {
const [sourceHash, destinationHash] = await Promise.all([plan.source, target.skillDir].map((skillDir) => readSkillProposalTargetTreeSha256(skillDir, { includeRootMetadata: true })));
copied = sourceHash === destinationHash;
}
if (!copied) {
relocation.rejection = `Skill Workshop relocation conflict: destination already exists at ${target.skillDir}.`;
continue;
}
operation = "remove-source";
}
}
relocation.move = {
source: plan.source,
workspaceDir: plan.workspaceDir,
destination: target.skillDir,
operation,
updates: []
};
moves.push(relocation.move);
}
const conflictsBySource = /* @__PURE__ */ new Map();
const sources = moves.map((move) => ({
move,
source: resolveCanonicalWorkspacePath(move.source)
}));
const deferredReservations = external.filter((plan) => plan.deferred).map((plan) => ({
source: resolveCanonicalWorkspacePath(plan.source),
destination: plan.relocation?.target.skillDir,
recoverable: recoverableDeferredSources.has(resolveCanonicalWorkspacePath(plan.source))
}));
const deferredMoves = /* @__PURE__ */ new Set();
for (const reservation of deferredReservations) for (const { move, source } of sources) if (!deferredMoves.has(move) && (reservation.destination === move.destination || isPathInside(reservation.source, source) || isPathInside(source, reservation.source))) {
deferredMoves.add(move);
deferredReservations.push({
source,
destination: move.destination,
recoverable: reservation.recoverable
});
if (reservation.recoverable) recoverableWarningCount += 1;
warnings.push(`Skill Workshop left ${move.source} in place because a connected skill still needs migration or recovery.`);
}
const deferredMoveSources = new Set([...deferredMoves].map((move) => move.source));
const workspaces = listAgentIds(config).map((agentId) => resolveCanonicalWorkspacePath(resolveAgentWorkspaceDir(config, agentId, env)));
for (const { move, source } of sources) {
const sharedDestination = moves.filter((other) => other.destination === move.destination);
const overlap = sources.find(({ move: other, source: otherSource }) => other !== move && (isPathInside(source, otherSource) || isPathInside(otherSource, source)));
const workspace = workspaces.find((directory) => isPathInside(source, directory));
const reason = sharedDestination.length > 1 ? `Skill Workshop relocation conflict: sources ${sharedDestination.map((other) => other.source).toSorted().join(", ")} map to the same destination ${move.destination}.` : overlap ? `Skill Workshop relocation conflict: source ${move.source} overlaps source ${overlap.move.source} targeting ${overlap.move.destination}.` : workspace ? `Skill Workshop relocation conflict: source ${move.source} contains configured workspace ${workspace}.` : void 0;
if (reason) conflictsBySource.set(move.source, reason);
}
const updates = [];
for (const plan of external) {
if (plan.deferred || deferredMoveSources.has(plan.source)) continue;
const { record, relocation, ownerAgentId } = plan;
const conflictReason = conflictsBySource.get(plan.source);
if (!relocation) {
updates.push({
record: staleWorkshopProposal(record, plan.unavailableReason ?? conflictReason ?? (ownerAgentId ? "Skill Workshop could not identify the legacy workspace; the path stays in place and the proposal is stale." : plan.unconfiguredOwnerAgentId ? `Skill Workshop could not use unconfigured owning agent "${plan.unconfiguredOwnerAgentId}"; the legacy path stays in place and the proposal is stale.` : "Skill Workshop could not identify one owning agent; the legacy path stays in place and the proposal is stale.")),
...ownerAgentId ? { ownerAgentId } : {}
});
continue;
}
const { target, move } = relocation;
const staleReason = conflictReason ?? relocation.rejection ?? (!move && record.kind === "update" ? "Skill Workshop no longer edits skills outside its own directory." : void 0);
const update = {
record: staleReason ? staleWorkshopProposal(record, staleReason) : {
...record,
target: {
...record.target,
skillDir: target.skillDir,
skillFile: target.skillFile,
source: "openclaw-workshop"
}
},
...ownerAgentId ? { ownerAgentId } : {}
};
(move && !staleReason ? move.updates : updates).push(update);
}
return {
moves: moves.filter((move) => !conflictsBySource.has(move.source) && !deferredMoveSources.has(move.source)),
updates,
warnings,
recoverableWarningCount
};
}
//#endregion
//#region src/commands/doctor-skill-workshop-sources.ts
const LEGACY_WORKSHOP_PROPOSALS_DIR = "skill-workshop/proposals";
const LEGACY_WORKSHOP_MAX_RECORD_BYTES = 1048576;
const LEGACY_WORKSHOP_PROPOSAL_ID_PATTERN = /^[a-z0-9][a-z0-9-]{5,120}$/;
async function readLegacyWorkshopJson(rootDir, relativePath, maxBytes) {
const read = await rootDir.read(relativePath, {
hardlinks: "reject",
maxBytes,
symlinks: "reject"
});
return JSON.parse(read.buffer.toString("utf8"));
}
async function readLegacyWorkshopProposals(stateRoot) {
const records = [];
for (const entry of await stateRoot.list(LEGACY_WORKSHOP_PROPOSALS_DIR, { withFileTypes: true })) {
if (!entry.isDirectory || !LEGACY_WORKSHOP_PROPOSAL_ID_PATTERN.test(entry.name)) continue;
try {
const parsed = validateSkillProposalRecord(await readLegacyWorkshopJson(stateRoot, `${LEGACY_WORKSHOP_PROPOSALS_DIR}/${entry.name}/proposal.json`, LEGACY_WORKSHOP_MAX_RECORD_BYTES));
if (parsed.ok && parsed.value.id === entry.name) records.push({
record: parsed.value,
ownerAgentId: null
});
} catch {}
}
return records;
}
async function readWorkshopMigrationRecords(env, includeEvents = false) {
const context = captureOpenClawStateWorkerContext({ env });
const { runOpenClawStateWorkerOperation } = await import("./openclaw-state-worker-store-RW5P1VzC.mjs");
context.admission.assertCurrent();
return await runOpenClawStateWorkerOperation(context, async (scope) => {
const records = await scope.execute({
type: "doctor.workshopMigrationRecords.read",
input: { includeEvents }
});
context.admission.assertCurrent();
return records;
}, { existingOnly: true }) ?? {
records: [],
appliedEvents: []
};
}
async function listLegacySkillWorkshopWorkspaceDirs(config, env) {
const { records } = await readWorkshopMigrationRecords(env);
const stateDir = resolveStateDir(env);
if (await pathExists$1(path.join(stateDir, "skill-workshop/proposals"))) for (const proposal of await readLegacyWorkshopProposals(await root$1(stateDir))) records.push(proposal);
const { external } = classifyWorkshopRelocation(records, config, env);
return [.../* @__PURE__ */ new Set([...external.flatMap(({ workspaceDir }) => workspaceDir ? [workspaceDir] : []), ...await listLegacyCollectionBackupWorkspaceDirs(env)])];
}
//#endregion
export { readLegacyWorkshopJson as a, classifyWorkshopRelocation as c, planWorkshopRelocation as d, readLegacyWorkshopSourceStat as f, migrateLegacyCollectionBackups as g, listWorkspaceOwnerAgentIds as h, listLegacySkillWorkshopWorkspaceDirs as i, inferOwnerAgentId as l, listPendingLegacyCollectionBackupRoots as m, LEGACY_WORKSHOP_PROPOSALS_DIR as n, readLegacyWorkshopProposals as o, resolveLegacyWorkshopWorkspaceDir as p, LEGACY_WORKSHOP_PROPOSAL_ID_PATTERN as r, readWorkshopMigrationRecords as s, LEGACY_WORKSHOP_MAX_RECORD_BYTES as t, isReadOnlyRehearsalProposal as u };