mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 05:36:03 +00:00
refactor(state): split migration modules (#106356)
* refactor(state): split migration modules * fix(state): relocate migration guard baselines
This commit is contained in:
committed by
GitHub
parent
3e21c2f256
commit
8aab4ff94d
@@ -60,7 +60,7 @@ const rawSqliteAllowPathGroups = {
|
||||
"src/commands/doctor-session-sqlite-readers.ts",
|
||||
"src/commands/doctor-session-sqlite-recover-report.ts",
|
||||
"src/commands/doctor-state-sqlite-compact.ts",
|
||||
"src/infra/state-migrations.ts",
|
||||
"src/infra/state-migrations.storage.ts",
|
||||
"src/infra/state-migrations.debug-proxy.ts",
|
||||
],
|
||||
"shared database stores with direct DatabaseSync access": ["src/proxy-capture/store.sqlite.ts"],
|
||||
|
||||
@@ -2650,6 +2650,7 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [
|
||||
"src/infra/stale-lock-file.ts: LockFileOwnerPayload",
|
||||
"src/infra/state-migrations.debug-proxy.ts: LegacyDebugProxyCaptureDetection",
|
||||
"src/infra/state-migrations.fs.ts: isLegacyWhatsAppAuthFile",
|
||||
"src/infra/state-migrations.session-store.ts: sessionStoreTextMayNeedCanonicalization",
|
||||
"src/infra/state-migrations.ts: sessionStoreTextMayNeedCanonicalization",
|
||||
"src/infra/supervisor-markers.ts: DetectRespawnSupervisorOptions",
|
||||
"src/infra/system-run-approval-binding.ts: matchSystemRunApprovalEnvHash",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"src/commands/doctor-state-integrity.ts": 2,
|
||||
"src/commands/doctor/shared/codex-route-warnings.ts": 2,
|
||||
"src/gateway/test-helpers.mocks.ts": 1,
|
||||
"src/infra/state-migrations.ts": 2,
|
||||
"src/infra/state-migrations.session-store.ts": 2,
|
||||
"src/plugins/registry-runtime.ts": 1
|
||||
},
|
||||
"sessionCompactManualTrim": {},
|
||||
|
||||
1260
src/infra/state-migrations.doctor.ts
Normal file
1260
src/infra/state-migrations.doctor.ts
Normal file
File diff suppressed because it is too large
Load Diff
234
src/infra/state-migrations.exec-approvals.ts
Normal file
234
src/infra/state-migrations.exec-approvals.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isNamedProfile } from "../config/paths.js";
|
||||
import { assertNoSymlinkParentsSync } from "./fs-safe-advanced.js";
|
||||
import { expandHomePrefix, resolveRequiredHomeDir } from "./home-dir.js";
|
||||
import { fileExists } from "./state-migrations.fs.js";
|
||||
import type { LegacyExecApprovalsMigrationDetection } from "./state-migrations.types.js";
|
||||
|
||||
const EXEC_APPROVALS_FILENAME = "exec-approvals.json";
|
||||
const EXEC_APPROVALS_SOCKET_FILENAME = "exec-approvals.sock";
|
||||
|
||||
function resolveDefaultExecApprovalsStateDir(
|
||||
env: NodeJS.ProcessEnv,
|
||||
homedir: () => string,
|
||||
): string {
|
||||
return path.join(resolveRequiredHomeDir(env, homedir), ".openclaw");
|
||||
}
|
||||
|
||||
function resolveDefaultExecApprovalsPath(env: NodeJS.ProcessEnv, homedir: () => string): string {
|
||||
return path.join(resolveDefaultExecApprovalsStateDir(env, homedir), EXEC_APPROVALS_FILENAME);
|
||||
}
|
||||
|
||||
function resolveExecApprovalsPathForStateDir(stateDir: string): string {
|
||||
return path.join(stateDir, EXEC_APPROVALS_FILENAME);
|
||||
}
|
||||
|
||||
function resolveExecApprovalsSocketPathForStateDir(stateDir: string): string {
|
||||
return path.join(stateDir, EXEC_APPROVALS_SOCKET_FILENAME);
|
||||
}
|
||||
|
||||
export function detectLegacyExecApprovalsMigration(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
homedir: () => string;
|
||||
stateDir: string;
|
||||
}): LegacyExecApprovalsMigrationDetection {
|
||||
const sourcePath = resolveDefaultExecApprovalsPath(params.env, params.homedir);
|
||||
const targetPath = resolveExecApprovalsPathForStateDir(params.stateDir);
|
||||
return {
|
||||
sourcePath,
|
||||
targetPath,
|
||||
hasLegacy:
|
||||
Boolean(params.env.OPENCLAW_STATE_DIR?.trim()) &&
|
||||
!isNamedProfile(params.env) &&
|
||||
path.resolve(sourcePath) !== path.resolve(targetPath) &&
|
||||
fileExists(sourcePath) &&
|
||||
!fileExists(targetPath),
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainJsonObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function isDefaultLegacyExecApprovalsSocketPath(params: {
|
||||
socketPath: string;
|
||||
sourcePath: string;
|
||||
}): boolean {
|
||||
const expanded = expandHomePrefix(params.socketPath);
|
||||
return (
|
||||
path.resolve(expanded) ===
|
||||
path.join(path.dirname(params.sourcePath), EXEC_APPROVALS_SOCKET_FILENAME)
|
||||
);
|
||||
}
|
||||
|
||||
function prepareMigratedExecApprovalsFile(params: {
|
||||
raw: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
}): { raw: string; warning?: string } {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(params.raw) as unknown;
|
||||
} catch {
|
||||
return {
|
||||
raw: "",
|
||||
warning: `Legacy exec approvals file unreadable; left in place at ${params.sourcePath}`,
|
||||
};
|
||||
}
|
||||
if (!isPlainJsonObject(parsed) || parsed.version !== 1) {
|
||||
return {
|
||||
raw: "",
|
||||
warning: `Legacy exec approvals file has unsupported shape; left in place at ${params.sourcePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
const next: Record<string, unknown> = { ...parsed };
|
||||
const socket = isPlainJsonObject(next.socket) ? { ...next.socket } : {};
|
||||
const rawSocketPath = typeof socket.path === "string" ? socket.path.trim() : "";
|
||||
if (
|
||||
!rawSocketPath ||
|
||||
isDefaultLegacyExecApprovalsSocketPath({
|
||||
socketPath: rawSocketPath,
|
||||
sourcePath: params.sourcePath,
|
||||
})
|
||||
) {
|
||||
socket.path = resolveExecApprovalsSocketPathForStateDir(path.dirname(params.targetPath));
|
||||
}
|
||||
next.socket = socket;
|
||||
return { raw: `${JSON.stringify(next, null, 2)}\n` };
|
||||
}
|
||||
|
||||
function assertSafeExecApprovalsMigrationTarget(targetPath: string): void {
|
||||
const targetDir = path.dirname(targetPath);
|
||||
assertNoSymlinkParentsSync({
|
||||
rootDir: resolveRequiredHomeDir(),
|
||||
targetPath: targetDir,
|
||||
allowOutsideRoot: true,
|
||||
messagePrefix: "Refusing to traverse symlink in exec approvals migration path",
|
||||
});
|
||||
try {
|
||||
const targetStat = fs.lstatSync(targetPath);
|
||||
if (targetStat.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to migrate exec approvals via symlink: ${targetPath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeMigratedExecApprovalsFile(targetPath: string, raw: string): boolean {
|
||||
const targetDir = path.dirname(targetPath);
|
||||
assertSafeExecApprovalsMigrationTarget(targetPath);
|
||||
fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
|
||||
assertSafeExecApprovalsMigrationTarget(targetPath);
|
||||
const dirStat = fs.lstatSync(targetDir);
|
||||
if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to migrate exec approvals into unsafe directory: ${targetDir}`);
|
||||
}
|
||||
try {
|
||||
fs.chmodSync(targetDir, 0o700);
|
||||
} catch {
|
||||
// best-effort on platforms without chmod
|
||||
}
|
||||
const tempPath = path.join(targetDir, `.exec-approvals.migration.${process.pid}.tmp`);
|
||||
fs.writeFileSync(tempPath, raw, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
try {
|
||||
try {
|
||||
fs.copyFileSync(tempPath, targetPath, fs.constants.COPYFILE_EXCL);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(targetPath, { force: true });
|
||||
} catch {
|
||||
// best-effort cleanup for an incomplete exclusive copy target
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
fs.chmodSync(targetPath, 0o600);
|
||||
} catch {
|
||||
// best-effort on platforms without chmod
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
fs.rmSync(tempPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function archiveMigratedExecApprovalsSource(sourcePath: string): string {
|
||||
let archivePath = `${sourcePath}.migrated`;
|
||||
if (fileExists(archivePath)) {
|
||||
archivePath = `${archivePath}-${Date.now()}`;
|
||||
}
|
||||
fs.renameSync(sourcePath, archivePath);
|
||||
return archivePath;
|
||||
}
|
||||
|
||||
export function migrateLegacyExecApprovals(detected: LegacyExecApprovalsMigrationDetection): {
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
} {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
if (!detected.hasLegacy) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
if (fileExists(detected.targetPath)) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
try {
|
||||
const sourceStat = fs.lstatSync(detected.sourcePath);
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||
warnings.push(
|
||||
`Legacy exec approvals file is not a regular file; left in place at ${detected.sourcePath}`,
|
||||
);
|
||||
return { changes, warnings };
|
||||
}
|
||||
try {
|
||||
const targetStat = fs.lstatSync(detected.targetPath);
|
||||
if (targetStat.isSymbolicLink()) {
|
||||
warnings.push(
|
||||
`Target exec approvals path is a symlink; skipped migration at ${detected.targetPath}`,
|
||||
);
|
||||
return { changes, warnings };
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const prepared = prepareMigratedExecApprovalsFile({
|
||||
raw: fs.readFileSync(detected.sourcePath, "utf8"),
|
||||
sourcePath: detected.sourcePath,
|
||||
targetPath: detected.targetPath,
|
||||
});
|
||||
if (prepared.warning) {
|
||||
warnings.push(prepared.warning);
|
||||
return { changes, warnings };
|
||||
}
|
||||
if (!writeMigratedExecApprovalsFile(detected.targetPath, prepared.raw)) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
changes.push(`Migrated exec approvals → ${detected.targetPath}`);
|
||||
try {
|
||||
const archivePath = archiveMigratedExecApprovalsSource(detected.sourcePath);
|
||||
changes.push(`Archived legacy exec approvals → ${archivePath}`);
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed archiving legacy exec approvals at ${detected.sourcePath}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed migrating exec approvals (${detected.sourcePath} → ${detected.targetPath}): ${String(
|
||||
err,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return { changes, warnings };
|
||||
}
|
||||
341
src/infra/state-migrations.legacy-sessions.ts
Normal file
341
src/infra/state-migrations.legacy-sessions.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { buildAgentMainSessionKey } from "../routing/session-key.js";
|
||||
import {
|
||||
ensureMigrationDir,
|
||||
fileExists,
|
||||
readSessionStoreJson5,
|
||||
safeReadDir,
|
||||
type SessionEntryLike,
|
||||
} from "./state-migrations.fs.js";
|
||||
import {
|
||||
aliasedSessionStoreMigrationWarning,
|
||||
canonicalizeSessionStore,
|
||||
distinctSessionStoreAliasWarning,
|
||||
emptyDirOrMissing,
|
||||
isAmbiguousSharedStoreKey,
|
||||
isLegacyDefaultMainAliasKey,
|
||||
mergeSessionEntry,
|
||||
normalizeSessionEntry,
|
||||
pickLatestLegacyDirectEntry,
|
||||
removeDirIfEmpty,
|
||||
resolveStaleLegacySessionFile,
|
||||
saveSessionStoreStrict,
|
||||
unresolvedSessionStoreIdentityWarning,
|
||||
} from "./state-migrations.session-store.js";
|
||||
import type { LegacyStateDetection } from "./state-migrations.types.js";
|
||||
|
||||
export async function migrateLegacySessions(
|
||||
detected: LegacyStateDetection,
|
||||
now: () => number,
|
||||
options: { recoverCorruptTargetStore?: boolean } = {},
|
||||
): Promise<{ changes: string[]; warnings: string[] }> {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
if (!detected.sessions.hasLegacy) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
ensureMigrationDir(detected.sessions.targetDir);
|
||||
|
||||
const legacyParsed = fileExists(detected.sessions.legacyStorePath)
|
||||
? readSessionStoreJson5(detected.sessions.legacyStorePath)
|
||||
: { store: {}, ok: true };
|
||||
const targetParsed = fileExists(detected.sessions.targetStorePath)
|
||||
? readSessionStoreJson5(detected.sessions.targetStorePath)
|
||||
: { store: {}, ok: true };
|
||||
const legacyStore = legacyParsed.store;
|
||||
const targetStore = targetParsed.store;
|
||||
if (detected.sessions.targetStoreAliases.hasUnresolvedIdentity) {
|
||||
warnings.push(
|
||||
unresolvedSessionStoreIdentityWarning(
|
||||
"legacy session migration",
|
||||
detected.sessions.targetStorePath,
|
||||
),
|
||||
);
|
||||
return { changes, warnings };
|
||||
}
|
||||
if (detected.sessions.targetStoreAliases.hasFinalSymlink) {
|
||||
warnings.push(
|
||||
`Deferred legacy session migration in final-component symlink store ${detected.sessions.targetStorePath}; configure one canonical session.store path, then rerun openclaw doctor --fix`,
|
||||
);
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
const ambiguousAliasedKeys = new Set(
|
||||
[...Object.keys(targetStore), ...Object.keys(legacyStore)].filter(
|
||||
(key) =>
|
||||
isAmbiguousSharedStoreKey(key, detected.targetMainKey, detected.targetScope) ||
|
||||
(detected.sessions.preserveForeignMainAliases &&
|
||||
isLegacyDefaultMainAliasKey(key, detected.targetMainKey)),
|
||||
),
|
||||
);
|
||||
// Atomic replacement separates filesystem aliases. Defer the whole merge so
|
||||
// a later startup cannot treat each pathname as a different session owner.
|
||||
if (detected.sessions.targetStoreAliases.hasDistinctAliases) {
|
||||
warnings.push(
|
||||
ambiguousAliasedKeys.size > 0
|
||||
? aliasedSessionStoreMigrationWarning({
|
||||
subject: "migration of",
|
||||
count: ambiguousAliasedKeys.size,
|
||||
storePath: detected.sessions.targetStorePath,
|
||||
})
|
||||
: distinctSessionStoreAliasWarning(
|
||||
"legacy session migration",
|
||||
detected.sessions.targetStorePath,
|
||||
),
|
||||
);
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
const canonicalizedTarget = canonicalizeSessionStore({
|
||||
store: targetStore,
|
||||
agentId: detected.targetAgentId,
|
||||
mainKey: detected.targetMainKey,
|
||||
scope: detected.targetScope,
|
||||
skipCrossAgentRemap: detected.sessions.preserveAmbiguousKeys,
|
||||
preserveCanonicalAgentOwner: true,
|
||||
preserveAmbiguousKeys: detected.sessions.preserveAmbiguousKeys,
|
||||
preserveForeignMainAliases: detected.sessions.preserveForeignMainAliases,
|
||||
});
|
||||
const canonicalizedLegacy = canonicalizeSessionStore({
|
||||
store: legacyStore,
|
||||
agentId: detected.targetAgentId,
|
||||
mainKey: detected.targetMainKey,
|
||||
scope: detected.targetScope,
|
||||
preserveCanonicalAgentOwner: true,
|
||||
preserveForeignMainAliases: detected.sessions.preserveForeignMainAliases,
|
||||
});
|
||||
const preservedLegacyForeignMainAliasCount = detected.sessions.preserveForeignMainAliases
|
||||
? Object.keys(legacyStore).filter((key) =>
|
||||
isLegacyDefaultMainAliasKey(key, detected.targetMainKey),
|
||||
).length
|
||||
: 0;
|
||||
|
||||
let repairedStaleSessionFiles = false;
|
||||
for (const entry of Object.values(canonicalizedTarget.store)) {
|
||||
const targetSessionFile = resolveStaleLegacySessionFile({
|
||||
entry,
|
||||
legacyDir: detected.sessions.legacyDir,
|
||||
targetDir: detected.sessions.targetDir,
|
||||
});
|
||||
if (targetSessionFile) {
|
||||
entry.sessionFile = targetSessionFile;
|
||||
repairedStaleSessionFiles = true;
|
||||
}
|
||||
}
|
||||
|
||||
const merged = Object.create(null) as Record<string, SessionEntryLike>;
|
||||
for (const [key, entry] of Object.entries(canonicalizedTarget.store)) {
|
||||
merged[key] = entry;
|
||||
}
|
||||
for (const [key, entry] of Object.entries(canonicalizedLegacy.store)) {
|
||||
merged[key] = mergeSessionEntry({
|
||||
existing: merged[key],
|
||||
incoming: entry,
|
||||
preferIncomingOnTie: false,
|
||||
});
|
||||
}
|
||||
|
||||
const mainKey = buildAgentMainSessionKey({
|
||||
agentId: detected.targetAgentId,
|
||||
mainKey: detected.targetMainKey,
|
||||
});
|
||||
let migratedDirectChatKey: string | undefined;
|
||||
if (!merged[mainKey]) {
|
||||
const latest = pickLatestLegacyDirectEntry(legacyStore);
|
||||
if (latest?.sessionId) {
|
||||
merged[mainKey] = latest;
|
||||
migratedDirectChatKey = mainKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (!legacyParsed.ok) {
|
||||
warnings.push(
|
||||
`Legacy sessions store unreadable; left in place at ${detected.sessions.legacyStorePath}`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetExists = fileExists(detected.sessions.targetStorePath);
|
||||
let targetReadable = !targetExists || targetParsed.ok;
|
||||
if (!targetReadable) {
|
||||
if (options.recoverCorruptTargetStore) {
|
||||
const archivedTargetPath = `${detected.sessions.targetStorePath}.corrupt-${now()}`;
|
||||
try {
|
||||
fs.renameSync(detected.sessions.targetStorePath, archivedTargetPath);
|
||||
changes.push(`Archived corrupt target sessions store → ${archivedTargetPath}`);
|
||||
targetReadable = true;
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Target sessions store unreadable; failed to archive ${detected.sessions.targetStorePath}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warnings.push(
|
||||
`Target sessions store unreadable; left untouched to avoid overwriting at ${detected.sessions.targetStorePath}. Run openclaw doctor --fix to archive it and retry the legacy merge.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
targetReadable &&
|
||||
(legacyParsed.ok || targetParsed.ok) &&
|
||||
(Object.keys(legacyStore).length > 0 || Object.keys(targetStore).length > 0)
|
||||
) {
|
||||
const normalized = Object.create(null) as Record<string, SessionEntry>;
|
||||
for (const [key, entry] of Object.entries(merged)) {
|
||||
const normalizedEntry = normalizeSessionEntry(entry);
|
||||
if (!normalizedEntry) {
|
||||
continue;
|
||||
}
|
||||
normalized[key] = normalizedEntry;
|
||||
}
|
||||
await saveSessionStoreStrict(detected.sessions.targetStorePath, normalized);
|
||||
if (migratedDirectChatKey) {
|
||||
changes.push(`Migrated latest direct-chat session → ${migratedDirectChatKey}`);
|
||||
}
|
||||
changes.push(`Merged sessions store → ${detected.sessions.targetStorePath}`);
|
||||
if (preservedLegacyForeignMainAliasCount > 0) {
|
||||
warnings.push(
|
||||
`Preserved ${preservedLegacyForeignMainAliasCount} ambiguous session key(s) while importing legacy sessions into ${detected.sessions.targetStorePath}`,
|
||||
);
|
||||
}
|
||||
if (canonicalizedTarget.legacyKeys.length > 0) {
|
||||
changes.push(`Canonicalized ${canonicalizedTarget.legacyKeys.length} legacy session key(s)`);
|
||||
}
|
||||
if (repairedStaleSessionFiles) {
|
||||
changes.push("Repaired migrated session transcript paths");
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetReadable) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
const movedSessionFiles = new Map<string, string>();
|
||||
const entries = safeReadDir(detected.sessions.legacyDir);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (entry.name === "sessions.json") {
|
||||
continue;
|
||||
}
|
||||
const from = path.join(detected.sessions.legacyDir, entry.name);
|
||||
let to = path.join(detected.sessions.targetDir, entry.name);
|
||||
if (fileExists(to)) {
|
||||
const parsed = path.parse(entry.name);
|
||||
to = path.join(detected.sessions.targetDir, `${parsed.name}.legacy-${now()}${parsed.ext}`);
|
||||
}
|
||||
try {
|
||||
fs.renameSync(from, to);
|
||||
movedSessionFiles.set(path.resolve(from), to);
|
||||
changes.push(`Moved ${entry.name} → agents/${detected.targetAgentId}/sessions`);
|
||||
} catch (err) {
|
||||
warnings.push(`Failed moving ${from}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (movedSessionFiles.size > 0) {
|
||||
let rewroteSessionFiles = false;
|
||||
for (const entry of Object.values(merged)) {
|
||||
const rawSessionFile = entry.sessionFile;
|
||||
const legacySessionFile =
|
||||
typeof rawSessionFile === "string"
|
||||
? path.resolve(detected.sessions.legacyDir, rawSessionFile)
|
||||
: typeof entry.sessionId === "string"
|
||||
? path.join(detected.sessions.legacyDir, `${entry.sessionId}.jsonl`)
|
||||
: undefined;
|
||||
const movedSessionFile = legacySessionFile
|
||||
? movedSessionFiles.get(path.resolve(legacySessionFile))
|
||||
: undefined;
|
||||
if (!movedSessionFile) {
|
||||
continue;
|
||||
}
|
||||
entry.sessionFile = movedSessionFile;
|
||||
rewroteSessionFiles = true;
|
||||
}
|
||||
if (rewroteSessionFiles) {
|
||||
const normalized = Object.create(null) as Record<string, SessionEntry>;
|
||||
for (const [key, entry] of Object.entries(merged)) {
|
||||
const normalizedEntry = normalizeSessionEntry(entry);
|
||||
if (normalizedEntry) {
|
||||
normalized[key] = normalizedEntry;
|
||||
}
|
||||
}
|
||||
await saveSessionStoreStrict(detected.sessions.targetStorePath, normalized);
|
||||
changes.push("Rewrote migrated session transcript paths");
|
||||
}
|
||||
}
|
||||
|
||||
if (legacyParsed.ok && targetReadable) {
|
||||
try {
|
||||
if (fileExists(detected.sessions.legacyStorePath)) {
|
||||
fs.rmSync(detected.sessions.legacyStorePath, { force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
removeDirIfEmpty(detected.sessions.legacyDir);
|
||||
const legacyLeft = safeReadDir(detected.sessions.legacyDir).filter((e) => e.isFile());
|
||||
if (legacyLeft.length > 0) {
|
||||
const backupDir = `${detected.sessions.legacyDir}.legacy-${now()}`;
|
||||
try {
|
||||
fs.renameSync(detected.sessions.legacyDir, backupDir);
|
||||
warnings.push(`Left legacy sessions at ${backupDir}`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
export async function migrateLegacyAgentDir(
|
||||
detected: LegacyStateDetection,
|
||||
now: () => number,
|
||||
): Promise<{ changes: string[]; warnings: string[] }> {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
if (!detected.agentDir.hasLegacy) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
ensureMigrationDir(detected.agentDir.targetDir);
|
||||
|
||||
const entries = safeReadDir(detected.agentDir.legacyDir);
|
||||
for (const entry of entries) {
|
||||
const from = path.join(detected.agentDir.legacyDir, entry.name);
|
||||
const to = path.join(detected.agentDir.targetDir, entry.name);
|
||||
if (fs.existsSync(to)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.renameSync(from, to);
|
||||
changes.push(`Moved agent file ${entry.name} → agents/${detected.targetAgentId}/agent`);
|
||||
} catch (err) {
|
||||
warnings.push(`Failed moving ${from}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
removeDirIfEmpty(detected.agentDir.legacyDir);
|
||||
if (!emptyDirOrMissing(detected.agentDir.legacyDir)) {
|
||||
const backupDir = path.join(
|
||||
detected.stateDir,
|
||||
"agents",
|
||||
detected.targetAgentId,
|
||||
`agent.legacy-${now()}`,
|
||||
);
|
||||
try {
|
||||
fs.renameSync(detected.agentDir.legacyDir, backupDir);
|
||||
warnings.push(`Left legacy agent dir at ${backupDir}`);
|
||||
} catch (err) {
|
||||
warnings.push(`Failed relocating legacy agent dir: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { changes, warnings };
|
||||
}
|
||||
432
src/infra/state-migrations.plugin-state.ts
Normal file
432
src/infra/state-migrations.plugin-state.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js";
|
||||
import {
|
||||
countPluginStateLiveEntries,
|
||||
createPluginStateKeyedStore,
|
||||
MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN,
|
||||
} from "../plugin-state/plugin-state-store.js";
|
||||
import {
|
||||
readPersistedInstalledPluginIndexSync,
|
||||
resolveLegacyInstalledPluginIndexStorePath,
|
||||
writePersistedInstalledPluginIndexSync,
|
||||
} from "../plugins/installed-plugin-index-store.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
|
||||
import {
|
||||
executeSqliteQuerySync,
|
||||
executeSqliteQueryTakeFirstSync,
|
||||
getNodeSqliteKysely,
|
||||
} from "./kysely-sync.js";
|
||||
import { ensureMigrationDir, fileExists } from "./state-migrations.fs.js";
|
||||
import {
|
||||
PLUGIN_STATE_SQLITE_SIDECAR_SUFFIXES,
|
||||
archiveLegacyImportSource,
|
||||
archiveLegacyInstalledPluginIndex,
|
||||
archiveLegacyPluginStateSidecar,
|
||||
hasPendingSqliteSidecarArchive,
|
||||
isLegacyPluginStateRowExpired,
|
||||
legacyInstalledPluginIndexMatches,
|
||||
legacyPluginStateRowsMatch,
|
||||
mergeLegacyInstalledPluginIndexRecords,
|
||||
normalizeLegacySqliteInteger,
|
||||
readLegacyInstalledPluginIndex,
|
||||
readLegacyPluginStateSidecarRows,
|
||||
resolveLegacyPluginStateSidecarPath,
|
||||
type LegacyPluginStateSidecarRow,
|
||||
} from "./state-migrations.storage.js";
|
||||
|
||||
type LegacyPluginStateImportDatabase = Pick<OpenClawStateKyselyDatabase, "plugin_state_entries">;
|
||||
|
||||
export async function migrateLegacyPluginStateSidecar(params: {
|
||||
stateDir: string;
|
||||
}): Promise<{ changes: string[]; warnings: string[] }> {
|
||||
const sourcePath = resolveLegacyPluginStateSidecarPath(params.stateDir);
|
||||
if (!fileExists(sourcePath)) {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
if (hasPendingSqliteSidecarArchive(sourcePath, PLUGIN_STATE_SQLITE_SIDECAR_SUFFIXES)) {
|
||||
archiveLegacyPluginStateSidecar({ sourcePath, changes, warnings });
|
||||
}
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
let rows: LegacyPluginStateSidecarRow[];
|
||||
try {
|
||||
rows = readLegacyPluginStateSidecarRows(sourcePath);
|
||||
} catch (err) {
|
||||
return {
|
||||
changes,
|
||||
warnings: [`Failed reading plugin-state sidecar ${sourcePath}: ${String(err)}`],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const conflictedKeys: string[] = [];
|
||||
const rowsToInsert: LegacyPluginStateSidecarRow[] = [];
|
||||
let imported = 0;
|
||||
let skippedExpired = 0;
|
||||
const now = Date.now();
|
||||
runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const stateDb = getNodeSqliteKysely<LegacyPluginStateImportDatabase>(db);
|
||||
for (const row of rows) {
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
stateDb
|
||||
.deleteFrom("plugin_state_entries")
|
||||
.where("plugin_id", "=", row.plugin_id)
|
||||
.where("namespace", "=", row.namespace)
|
||||
.where("entry_key", "=", row.entry_key)
|
||||
.where("expires_at", "is not", null)
|
||||
.where("expires_at", "<=", now),
|
||||
);
|
||||
const existing = executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
stateDb
|
||||
.selectFrom("plugin_state_entries")
|
||||
.select(["value_json", "created_at", "expires_at"])
|
||||
.where("plugin_id", "=", row.plugin_id)
|
||||
.where("namespace", "=", row.namespace)
|
||||
.where("entry_key", "=", row.entry_key),
|
||||
);
|
||||
const legacyExpired = isLegacyPluginStateRowExpired(row, now);
|
||||
if (existing) {
|
||||
if (!legacyPluginStateRowsMatch(existing, row)) {
|
||||
if (legacyExpired) {
|
||||
skippedExpired += 1;
|
||||
} else {
|
||||
conflictedKeys.push(`${row.plugin_id}/${row.namespace}/${row.entry_key}`);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (legacyExpired) {
|
||||
skippedExpired += 1;
|
||||
continue;
|
||||
}
|
||||
rowsToInsert.push(row);
|
||||
}
|
||||
for (const row of rowsToInsert) {
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
stateDb
|
||||
.insertInto("plugin_state_entries")
|
||||
.values({
|
||||
plugin_id: row.plugin_id,
|
||||
namespace: row.namespace,
|
||||
entry_key: row.entry_key,
|
||||
value_json: row.value_json,
|
||||
created_at: normalizeLegacySqliteInteger(row.created_at) ?? 0,
|
||||
expires_at: normalizeLegacySqliteInteger(row.expires_at),
|
||||
})
|
||||
.onConflict((conflict) =>
|
||||
conflict.columns(["plugin_id", "namespace", "entry_key"]).doNothing(),
|
||||
),
|
||||
);
|
||||
imported += 1;
|
||||
}
|
||||
},
|
||||
{ env: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir } },
|
||||
);
|
||||
if (imported > 0) {
|
||||
changes.push(
|
||||
`Migrated ${imported} plugin-state sidecar ${imported === 1 ? "entry" : "entries"} → shared SQLite state`,
|
||||
);
|
||||
}
|
||||
if (conflictedKeys.length > 0) {
|
||||
return {
|
||||
changes,
|
||||
warnings: [
|
||||
`Left plugin-state sidecar in place because ${conflictedKeys.length} ${conflictedKeys.length === 1 ? "row" : "rows"} already existed in shared state: ${conflictedKeys[0]}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
if (skippedExpired > 0) {
|
||||
changes.push(
|
||||
`Dropped ${skippedExpired} expired plugin-state sidecar ${skippedExpired === 1 ? "entry" : "entries"}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
changes,
|
||||
warnings: [`Failed migrating plugin-state sidecar ${sourcePath}: ${String(err)}`],
|
||||
};
|
||||
}
|
||||
|
||||
archiveLegacyPluginStateSidecar({ sourcePath, changes, warnings });
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
export async function migrateLegacyInstalledPluginIndex(params: {
|
||||
stateDir: string;
|
||||
}): Promise<{ changes: string[]; warnings: string[] }> {
|
||||
const sourcePath = resolveLegacyInstalledPluginIndexStorePath({ stateDir: params.stateDir });
|
||||
if (!fileExists(sourcePath)) {
|
||||
return { changes: [], warnings: [] };
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const legacy = readLegacyInstalledPluginIndex(sourcePath);
|
||||
if (!legacy) {
|
||||
return {
|
||||
changes,
|
||||
warnings: [`Left plugin install index in place because ${sourcePath} is invalid`],
|
||||
};
|
||||
}
|
||||
|
||||
const storeOptions = { stateDir: params.stateDir };
|
||||
const current = readPersistedInstalledPluginIndexSync(storeOptions);
|
||||
if (current && !legacyInstalledPluginIndexMatches(current, legacy)) {
|
||||
const merged = mergeLegacyInstalledPluginIndexRecords(current, legacy);
|
||||
if (merged.addedCount > 0) {
|
||||
try {
|
||||
writePersistedInstalledPluginIndexSync(merged.merged, storeOptions);
|
||||
changes.push(
|
||||
`Merged ${merged.addedCount} legacy plugin install ${merged.addedCount === 1 ? "record" : "records"} → shared SQLite state`,
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
changes,
|
||||
warnings: [`Failed merging plugin install index ${sourcePath}: ${String(err)}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
if (merged.conflicts.length > 0) {
|
||||
return {
|
||||
changes,
|
||||
warnings: [
|
||||
`Left plugin install index in place because shared SQLite state has conflicting plugin install metadata for: ${merged.conflicts.join(", ")}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
try {
|
||||
writePersistedInstalledPluginIndexSync(legacy, storeOptions);
|
||||
const recordCount = Object.keys(legacy.installRecords).length;
|
||||
changes.push(
|
||||
`Migrated plugin install index ${recordCount} ${recordCount === 1 ? "record" : "records"} → shared SQLite state`,
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
changes,
|
||||
warnings: [`Failed migrating plugin install index ${sourcePath}: ${String(err)}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
archiveLegacyInstalledPluginIndex({ sourcePath, changes, warnings });
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
function resolvePluginStateImportTargetKey(scopeKey: string, key: string): string {
|
||||
return scopeKey ? `${scopeKey}:${key}` : key;
|
||||
}
|
||||
|
||||
function findMissingKey(expected: Set<string>, actual: Set<string>): string | undefined {
|
||||
for (const key of expected) {
|
||||
if (!actual.has(key)) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function withPluginStateImportEnv<T>(
|
||||
plan: Extract<ChannelLegacyStateMigrationPlan, { kind: "plugin-state-import" }>,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!plan.stateDir) {
|
||||
return await run();
|
||||
}
|
||||
const previous = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = plan.stateDir;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runLegacyMigrationPlans(
|
||||
plans: ChannelLegacyStateMigrationPlan[],
|
||||
): Promise<{ changes: string[]; warnings: string[] }> {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const plan of plans) {
|
||||
if (plan.kind === "plugin-state-import") {
|
||||
await withPluginStateImportEnv(plan, async () => {
|
||||
let storeEntries: Array<{ key: string; value: unknown }>;
|
||||
let pluginEntryCount;
|
||||
const store = createPluginStateKeyedStore<unknown>(plan.pluginId, {
|
||||
namespace: plan.namespace,
|
||||
maxEntries: plan.maxEntries,
|
||||
...(plan.defaultTtlMs != null ? { defaultTtlMs: plan.defaultTtlMs } : {}),
|
||||
});
|
||||
try {
|
||||
storeEntries = await store.entries();
|
||||
pluginEntryCount = countPluginStateLiveEntries(plan.pluginId);
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed reading ${plan.label} plugin state before migration: ${String(err)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const existingKeys = new Set(storeEntries.map(({ key }) => key));
|
||||
const existingValuesByKey = new Map(storeEntries.map(({ key, value }) => [key, value]));
|
||||
const expectedKeys = new Set(existingKeys);
|
||||
let remainingCapacity = Math.max(0, plan.maxEntries - storeEntries.length);
|
||||
let entries: Awaited<ReturnType<typeof plan.readEntries>>;
|
||||
try {
|
||||
entries = await plan.readEntries();
|
||||
} catch (err) {
|
||||
warnings.push(`Failed reading ${plan.label} legacy source: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
const candidateEntries: Array<{
|
||||
key: string;
|
||||
targetKey: string;
|
||||
value: unknown;
|
||||
ttlMs?: number;
|
||||
existedBefore: boolean;
|
||||
}> = [];
|
||||
const failedTargetKeys = new Set<string>();
|
||||
let missingEntryCount = 0;
|
||||
for (const entry of entries) {
|
||||
const targetKey = resolvePluginStateImportTargetKey(plan.scopeKey, entry.key);
|
||||
const existingValue = existingValuesByKey.get(targetKey);
|
||||
if (existingKeys.has(targetKey)) {
|
||||
const shouldReplace =
|
||||
existingValue !== undefined &&
|
||||
(await plan.shouldReplaceExistingEntry?.({
|
||||
key: entry.key,
|
||||
existingValue,
|
||||
incomingValue: entry.value,
|
||||
}));
|
||||
if (shouldReplace) {
|
||||
candidateEntries.push({ ...entry, targetKey, existedBefore: true });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
candidateEntries.push({ ...entry, targetKey, existedBefore: false });
|
||||
missingEntryCount++;
|
||||
}
|
||||
const pluginRemainingCapacity = Math.max(
|
||||
0,
|
||||
MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN - pluginEntryCount,
|
||||
);
|
||||
if (missingEntryCount > pluginRemainingCapacity) {
|
||||
warnings.push(
|
||||
`Skipped migrating ${plan.label} because plugin state has room for ${pluginRemainingCapacity} of ${missingEntryCount} missing entries; left legacy source in place`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let imported = 0;
|
||||
const changedKeys: string[] = [];
|
||||
for (const entry of candidateEntries) {
|
||||
if (!entry.existedBefore && remainingCapacity <= 0) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await store.register(
|
||||
entry.targetKey,
|
||||
entry.value,
|
||||
entry.ttlMs != null ? { ttlMs: entry.ttlMs } : undefined,
|
||||
);
|
||||
const nextExpectedKeys = new Set(expectedKeys);
|
||||
nextExpectedKeys.add(entry.targetKey);
|
||||
const liveKeys = new Set((await store.entries()).map(({ key }) => key));
|
||||
const missingKey = findMissingKey(nextExpectedKeys, liveKeys);
|
||||
if (missingKey) {
|
||||
for (const changedKey of changedKeys.toReversed()) {
|
||||
if (existingValuesByKey.has(changedKey)) {
|
||||
await store.register(changedKey, existingValuesByKey.get(changedKey));
|
||||
} else {
|
||||
await store.delete(changedKey);
|
||||
}
|
||||
}
|
||||
if (existingValuesByKey.has(entry.targetKey)) {
|
||||
await store.register(entry.targetKey, existingValuesByKey.get(entry.targetKey));
|
||||
} else {
|
||||
await store.delete(entry.targetKey);
|
||||
}
|
||||
warnings.push(
|
||||
`Stopped migrating ${plan.label} because plugin state cap evicted ${missingKey}; left legacy source in place`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
expectedKeys.add(entry.targetKey);
|
||||
existingKeys.add(entry.targetKey);
|
||||
changedKeys.push(entry.targetKey);
|
||||
if (!entry.existedBefore) {
|
||||
remainingCapacity--;
|
||||
}
|
||||
imported++;
|
||||
} catch (err) {
|
||||
failedTargetKeys.add(entry.targetKey);
|
||||
warnings.push(`Failed migrating ${plan.label} entry ${entry.key}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
if (imported > 0) {
|
||||
changes.push(
|
||||
`Migrated ${imported} ${plan.label} ${imported === 1 ? "entry" : "entries"} → plugin state`,
|
||||
);
|
||||
}
|
||||
let cleanupKeys = existingKeys;
|
||||
if (plan.cleanupSource === "rename") {
|
||||
cleanupKeys = expectedKeys;
|
||||
}
|
||||
const allEntriesCovered =
|
||||
(entries.length === 0 && plan.cleanupWhenEmpty === true) ||
|
||||
(entries.length > 0 &&
|
||||
entries.every(
|
||||
({ key }) =>
|
||||
cleanupKeys.has(resolvePluginStateImportTargetKey(plan.scopeKey, key)) &&
|
||||
!failedTargetKeys.has(resolvePluginStateImportTargetKey(plan.scopeKey, key)),
|
||||
));
|
||||
if (allEntriesCovered && plan.cleanupSource === "rename" && fileExists(plan.sourcePath)) {
|
||||
archiveLegacyImportSource({
|
||||
sourcePath: plan.sourcePath,
|
||||
label: plan.label,
|
||||
changes,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
if (allEntriesCovered && plan.removeSource) {
|
||||
try {
|
||||
await plan.removeSource();
|
||||
changes.push(`Removed ${plan.label} legacy source (${plan.sourcePath})`);
|
||||
} catch (err) {
|
||||
warnings.push(`Failed removing ${plan.label} legacy source: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (fileExists(plan.targetPath)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
ensureMigrationDir(path.dirname(plan.targetPath));
|
||||
if (plan.kind === "move") {
|
||||
fs.renameSync(plan.sourcePath, plan.targetPath);
|
||||
changes.push(`Moved ${plan.label} → ${plan.targetPath}`);
|
||||
} else {
|
||||
fs.copyFileSync(plan.sourcePath, plan.targetPath);
|
||||
changes.push(`Copied ${plan.label} → ${plan.targetPath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(`Failed migrating ${plan.label} (${plan.sourcePath}): ${String(err)}`);
|
||||
}
|
||||
}
|
||||
return { changes, warnings };
|
||||
}
|
||||
1128
src/infra/state-migrations.runtime-state.ts
Normal file
1128
src/infra/state-migrations.runtime-state.ts
Normal file
File diff suppressed because it is too large
Load Diff
1392
src/infra/state-migrations.session-store.ts
Normal file
1392
src/infra/state-migrations.session-store.ts
Normal file
File diff suppressed because it is too large
Load Diff
45
src/infra/state-migrations.session-surfaces.ts
Normal file
45
src/infra/state-migrations.session-surfaces.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import { listBundledChannelLegacySessionSurfaces } from "../channels/plugins/bundled.js";
|
||||
|
||||
type LegacySessionSurface = {
|
||||
isLegacyGroupSessionKey?: (key: string) => boolean;
|
||||
canonicalizeLegacySessionKey?: (params: {
|
||||
key: string;
|
||||
agentId: string;
|
||||
}) => string | null | undefined;
|
||||
};
|
||||
|
||||
let cachedLegacySessionSurfaces: LegacySessionSurface[] | null = null;
|
||||
|
||||
export function getLegacySessionSurfaces(): LegacySessionSurface[] {
|
||||
// Legacy migrations run on cold doctor/startup paths. Prefer the narrower
|
||||
// setup plugin surface here so session-key cleanup does not materialize full
|
||||
// bundled channel runtimes.
|
||||
cachedLegacySessionSurfaces ??= [...listBundledChannelLegacySessionSurfaces()];
|
||||
return cachedLegacySessionSurfaces;
|
||||
}
|
||||
|
||||
export function isSurfaceGroupKey(key: string): boolean {
|
||||
return key.includes(":group:") || key.includes(":channel:");
|
||||
}
|
||||
|
||||
export function isLegacyGroupKey(key: string): boolean {
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const lower = normalizeLowercaseStringOrEmpty(trimmed);
|
||||
if (lower.startsWith("group:") || lower.startsWith("channel:")) {
|
||||
return true;
|
||||
}
|
||||
for (const surface of getLegacySessionSurfaces()) {
|
||||
if (surface.isLegacyGroupSessionKey?.(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function resetLegacySessionSurfacesForTest(): void {
|
||||
cachedLegacySessionSurfaces = null;
|
||||
}
|
||||
335
src/infra/state-migrations.state-dir.ts
Normal file
335
src/infra/state-migrations.state-dir.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveLegacyStateDirs, resolveNewStateDir, resolveStateDir } from "../config/paths.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { isWithinDir } from "./path-safety.js";
|
||||
import {
|
||||
detectLegacyExecApprovalsMigration,
|
||||
migrateLegacyExecApprovals,
|
||||
} from "./state-migrations.exec-approvals.js";
|
||||
import { migrateLegacyInstalledPluginIndex } from "./state-migrations.plugin-state.js";
|
||||
import { migrateLegacyTaskStateSidecars } from "./state-migrations.storage.js";
|
||||
import type { MigrationLogger } from "./state-migrations.types.js";
|
||||
|
||||
let autoMigrateStateDirChecked = false;
|
||||
let autoMigrateTaskStateSidecarsChecked = false;
|
||||
|
||||
export function resetAutoMigrateLegacyStateDirForTest() {
|
||||
autoMigrateStateDirChecked = false;
|
||||
}
|
||||
|
||||
export function resetAutoMigrateLegacyTaskStateSidecarsForTest() {
|
||||
autoMigrateTaskStateSidecarsChecked = false;
|
||||
}
|
||||
|
||||
type StateDirMigrationResult = {
|
||||
migrated: boolean;
|
||||
skipped: boolean;
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
notices?: string[];
|
||||
};
|
||||
|
||||
function resolveSymlinkTarget(linkPath: string): string | null {
|
||||
try {
|
||||
const target = fs.readlinkSync(linkPath);
|
||||
return path.resolve(path.dirname(linkPath), target);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatStateDirMigration(legacyDir: string, targetDir: string): string {
|
||||
return `State dir: ${legacyDir} → ${targetDir} (legacy path now symlinked)`;
|
||||
}
|
||||
|
||||
function isDirPath(filePath: string): boolean {
|
||||
try {
|
||||
return fs.statSync(filePath).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isLegacyTreeSymlinkMirror(currentDir: string, realTargetDir: string): boolean {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(currentDir, entry.name);
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(entryPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
const resolvedTarget = resolveSymlinkTarget(entryPath);
|
||||
if (!resolvedTarget) {
|
||||
return false;
|
||||
}
|
||||
let resolvedRealTarget: string;
|
||||
try {
|
||||
resolvedRealTarget = fs.realpathSync(resolvedTarget);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!isWithinDir(realTargetDir, resolvedRealTarget)) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
if (!isLegacyTreeSymlinkMirror(entryPath, realTargetDir)) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isLegacyDirSymlinkMirror(legacyDir: string, targetDir: string): boolean {
|
||||
let realTargetDir: string;
|
||||
try {
|
||||
realTargetDir = fs.realpathSync(targetDir);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return isLegacyTreeSymlinkMirror(legacyDir, realTargetDir);
|
||||
}
|
||||
|
||||
export async function autoMigrateLegacyStateDir(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homedir?: () => string;
|
||||
log?: MigrationLogger;
|
||||
}): Promise<StateDirMigrationResult> {
|
||||
if (autoMigrateStateDirChecked) {
|
||||
return { migrated: false, skipped: true, changes: [], warnings: [] };
|
||||
}
|
||||
autoMigrateStateDirChecked = true;
|
||||
|
||||
const homedir = params.homedir ?? os.homedir;
|
||||
const env = params.env ?? process.env;
|
||||
const warnings: string[] = [];
|
||||
const changes: string[] = [];
|
||||
const hasCustomStateDir = Boolean(env.OPENCLAW_STATE_DIR?.trim());
|
||||
const targetDir = hasCustomStateDir ? resolveStateDir(env, homedir) : resolveNewStateDir(homedir);
|
||||
const migratePluginInstallIndex = async () => {
|
||||
const result = await migrateLegacyInstalledPluginIndex({ stateDir: targetDir });
|
||||
changes.push(...result.changes);
|
||||
warnings.push(...result.warnings);
|
||||
};
|
||||
if (hasCustomStateDir) {
|
||||
await migratePluginInstallIndex();
|
||||
return {
|
||||
migrated: changes.length > 0,
|
||||
skipped: changes.length === 0 && warnings.length === 0,
|
||||
changes,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyDirs = resolveLegacyStateDirs(homedir);
|
||||
let legacyDir = legacyDirs.find((dir) => {
|
||||
try {
|
||||
return fs.existsSync(dir);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
let legacyStat: fs.Stats | null;
|
||||
try {
|
||||
legacyStat = legacyDir ? fs.lstatSync(legacyDir) : null;
|
||||
} catch {
|
||||
legacyStat = null;
|
||||
}
|
||||
if (!legacyStat) {
|
||||
await migratePluginInstallIndex();
|
||||
return { migrated: changes.length > 0, skipped: false, changes, warnings };
|
||||
}
|
||||
if (!legacyStat.isDirectory() && !legacyStat.isSymbolicLink()) {
|
||||
warnings.push(`Legacy state path is not a directory: ${legacyDir}`);
|
||||
return { migrated: false, skipped: false, changes, warnings };
|
||||
}
|
||||
|
||||
let symlinkDepth = 0;
|
||||
while (legacyStat.isSymbolicLink()) {
|
||||
const legacyTarget = legacyDir ? resolveSymlinkTarget(legacyDir) : null;
|
||||
if (!legacyTarget) {
|
||||
warnings.push(
|
||||
`Legacy state dir is a symlink (${legacyDir ?? "unknown"}); could not resolve target.`,
|
||||
);
|
||||
return { migrated: false, skipped: false, changes, warnings };
|
||||
}
|
||||
if (path.resolve(legacyTarget) === path.resolve(targetDir)) {
|
||||
await migratePluginInstallIndex();
|
||||
return { migrated: changes.length > 0, skipped: false, changes, warnings };
|
||||
}
|
||||
if (legacyDirs.some((dir) => path.resolve(dir) === path.resolve(legacyTarget))) {
|
||||
legacyDir = legacyTarget;
|
||||
try {
|
||||
legacyStat = fs.lstatSync(legacyDir);
|
||||
} catch {
|
||||
legacyStat = null;
|
||||
}
|
||||
if (!legacyStat) {
|
||||
warnings.push(`Legacy state dir missing after symlink resolution: ${legacyDir}`);
|
||||
return { migrated: false, skipped: false, changes, warnings };
|
||||
}
|
||||
if (!legacyStat.isDirectory() && !legacyStat.isSymbolicLink()) {
|
||||
warnings.push(`Legacy state path is not a directory: ${legacyDir}`);
|
||||
return { migrated: false, skipped: false, changes, warnings };
|
||||
}
|
||||
symlinkDepth += 1;
|
||||
if (symlinkDepth > 2) {
|
||||
warnings.push(`Legacy state dir symlink chain too deep: ${legacyDir}`);
|
||||
return { migrated: false, skipped: false, changes, warnings };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
warnings.push(
|
||||
`Legacy state dir is a symlink (${legacyDir ?? "unknown"} → ${legacyTarget}); skipping auto-migration.`,
|
||||
);
|
||||
return { migrated: false, skipped: false, changes, warnings };
|
||||
}
|
||||
|
||||
if (isDirPath(targetDir)) {
|
||||
if (legacyDir && isLegacyDirSymlinkMirror(legacyDir, targetDir)) {
|
||||
await migratePluginInstallIndex();
|
||||
return { migrated: changes.length > 0, skipped: false, changes, warnings };
|
||||
}
|
||||
await migratePluginInstallIndex();
|
||||
warnings.push(
|
||||
`State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`,
|
||||
);
|
||||
return { migrated: changes.length > 0, skipped: false, changes, warnings };
|
||||
}
|
||||
|
||||
try {
|
||||
if (!legacyDir) {
|
||||
throw new Error("Legacy state dir not found");
|
||||
}
|
||||
fs.renameSync(legacyDir, targetDir);
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed to move legacy state dir (${legacyDir ?? "unknown"} → ${targetDir}): ${String(err)}`,
|
||||
);
|
||||
return { migrated: false, skipped: false, changes, warnings };
|
||||
}
|
||||
|
||||
try {
|
||||
if (!legacyDir) {
|
||||
throw new Error("Legacy state dir not found");
|
||||
}
|
||||
fs.symlinkSync(targetDir, legacyDir, "dir");
|
||||
changes.push(formatStateDirMigration(legacyDir, targetDir));
|
||||
} catch (err) {
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
if (!legacyDir) {
|
||||
throw new Error("Legacy state dir not found", { cause: err });
|
||||
}
|
||||
fs.symlinkSync(targetDir, legacyDir, "junction");
|
||||
changes.push(formatStateDirMigration(legacyDir, targetDir));
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
try {
|
||||
if (!legacyDir) {
|
||||
throw new Error("Legacy state dir not found", { cause: fallbackErr });
|
||||
}
|
||||
fs.renameSync(targetDir, legacyDir);
|
||||
warnings.push(
|
||||
`State dir migration rolled back (failed to link legacy path): ${String(fallbackErr)}`,
|
||||
);
|
||||
return { migrated: false, skipped: false, changes: [], warnings };
|
||||
} catch (rollbackErr) {
|
||||
warnings.push(
|
||||
`State dir moved but failed to link legacy path (${legacyDir ?? "unknown"} → ${targetDir}): ${String(fallbackErr)}`,
|
||||
);
|
||||
warnings.push(
|
||||
`Rollback failed; set OPENCLAW_STATE_DIR=${targetDir} to avoid split state: ${String(rollbackErr)}`,
|
||||
);
|
||||
changes.push(`State dir: ${legacyDir ?? "unknown"} → ${targetDir}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await migratePluginInstallIndex();
|
||||
return { migrated: changes.length > 0, skipped: false, changes, warnings };
|
||||
}
|
||||
|
||||
export async function autoMigrateLegacyTaskStateSidecars(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homedir?: () => string;
|
||||
log?: MigrationLogger;
|
||||
crossStateDirImports?: boolean;
|
||||
}): Promise<{
|
||||
migrated: boolean;
|
||||
skipped: boolean;
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
notices?: string[];
|
||||
}> {
|
||||
if (autoMigrateTaskStateSidecarsChecked) {
|
||||
return { migrated: false, skipped: true, changes: [], warnings: [] };
|
||||
}
|
||||
autoMigrateTaskStateSidecarsChecked = true;
|
||||
|
||||
const stateDir = resolveStateDir(params.env ?? process.env, params.homedir);
|
||||
const result = await migrateLegacyTaskStateSidecars({ stateDir });
|
||||
const detectedExecApprovals = detectLegacyExecApprovalsMigration({
|
||||
env: params.env ?? process.env,
|
||||
homedir: params.homedir ?? os.homedir,
|
||||
stateDir,
|
||||
});
|
||||
// Cross-state-dir sources need the explicit doctor opt-in (see
|
||||
// detectLegacyStateMigrations); the implicit preflight must not archive
|
||||
// files that belong to the default state dir.
|
||||
const crossStateDirImports = params.crossStateDirImports === true;
|
||||
const execApprovals = migrateLegacyExecApprovals(
|
||||
crossStateDirImports ? detectedExecApprovals : { ...detectedExecApprovals, hasLegacy: false },
|
||||
);
|
||||
const notices: string[] = [];
|
||||
if (detectedExecApprovals.hasLegacy && !crossStateDirImports) {
|
||||
notices.push(
|
||||
`Exec approvals in the default state dir were not imported into OPENCLAW_STATE_DIR automatically (${detectedExecApprovals.sourcePath} -> ${detectedExecApprovals.targetPath}); run \`openclaw doctor --fix\` to import them.`,
|
||||
);
|
||||
}
|
||||
const changes = [...result.changes, ...execApprovals.changes];
|
||||
const warnings = [...result.warnings, ...execApprovals.warnings];
|
||||
const logger = params.log ?? createSubsystemLogger("state-migrations");
|
||||
if (changes.length > 0) {
|
||||
logger.info(`Auto-migrated legacy state:\n${changes.map((entry) => `- ${entry}`).join("\n")}`);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
logger.warn(
|
||||
`Legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
if (notices.length > 0) {
|
||||
logger.info(
|
||||
`Legacy state migration notes:\n${notices.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
migrated: changes.length > 0,
|
||||
skipped: false,
|
||||
changes,
|
||||
warnings,
|
||||
...(notices.length > 0 ? { notices } : {}),
|
||||
};
|
||||
}
|
||||
1342
src/infra/state-migrations.storage.ts
Normal file
1342
src/infra/state-migrations.storage.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
118
src/infra/state-migrations.types.ts
Normal file
118
src/infra/state-migrations.types.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js";
|
||||
import type { SessionScope } from "../config/sessions/types.js";
|
||||
import type { PluginDoctorStateMigration } from "../plugins/doctor-contract-registry.js";
|
||||
import type { LegacyChannelPairingStateDetection } from "./state-migrations.channel-pairing.js";
|
||||
|
||||
export type SessionStoreAliasPlan = {
|
||||
hasDistinctAliases: boolean;
|
||||
hasFinalSymlink: boolean;
|
||||
hasUnresolvedIdentity: boolean;
|
||||
};
|
||||
|
||||
export type LegacyStateDetection = {
|
||||
targetAgentId: string;
|
||||
targetMainKey: string;
|
||||
targetScope?: SessionScope;
|
||||
stateDir: string;
|
||||
oauthDir: string;
|
||||
sessions: {
|
||||
legacyDir: string;
|
||||
legacyStorePath: string;
|
||||
targetDir: string;
|
||||
targetStorePath: string;
|
||||
hasLegacy: boolean;
|
||||
legacyKeys: string[];
|
||||
preserveAmbiguousKeys: boolean;
|
||||
preserveForeignMainAliases: boolean;
|
||||
targetStoreAliases: SessionStoreAliasPlan;
|
||||
};
|
||||
agentDir: {
|
||||
legacyDir: string;
|
||||
targetDir: string;
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
channelPlans: {
|
||||
hasLegacy: boolean;
|
||||
plans: ChannelLegacyStateMigrationPlan[];
|
||||
};
|
||||
pluginPlans?: {
|
||||
hasLegacy: boolean;
|
||||
plans: DetectedPluginDoctorStateMigrationPlan[];
|
||||
};
|
||||
pluginStateSidecar: {
|
||||
sourcePath: string;
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
pluginInstallIndex: {
|
||||
sourcePath: string;
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
debugProxyCaptureSidecar: {
|
||||
sourcePath: string;
|
||||
blobDir: string;
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
stateSchema: {
|
||||
hasLegacy: boolean;
|
||||
preview: string[];
|
||||
};
|
||||
taskStateSidecars: {
|
||||
taskRunsPath: string;
|
||||
flowRunsPath: string;
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
deliveryQueues: {
|
||||
outboundPath: string;
|
||||
sessionPath: string;
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
voiceWake: {
|
||||
triggersPath: string;
|
||||
routingPath: string;
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
updateCheck: {
|
||||
sourcePath: string;
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
configHealth: {
|
||||
sourcePath: string;
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
pluginBindingApprovals: {
|
||||
sourcePath: string;
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
currentConversationBindings: {
|
||||
sourcePath: string;
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
channelPairing: LegacyChannelPairingStateDetection;
|
||||
execApprovals: {
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
warnings: string[];
|
||||
notices: string[];
|
||||
preview: string[];
|
||||
};
|
||||
|
||||
export type LegacyExecApprovalsMigrationDetection = LegacyStateDetection["execApprovals"];
|
||||
|
||||
export type MigrationLogger = {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
};
|
||||
|
||||
export type DetectedPluginDoctorStateMigrationPlan = {
|
||||
pluginId: string;
|
||||
migration: PluginDoctorStateMigration;
|
||||
preview: string[];
|
||||
};
|
||||
|
||||
export type MigrationMessages = {
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
notices?: string[];
|
||||
};
|
||||
Reference in New Issue
Block a user