refactor(state): split migration modules (#106356)

* refactor(state): split migration modules

* fix(state): relocate migration guard baselines
This commit is contained in:
Peter Steinberger
2026-07-13 05:13:42 -07:00
committed by GitHub
parent 3e21c2f256
commit 8aab4ff94d
14 changed files with 6649 additions and 6444 deletions

View File

@@ -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"],

View File

@@ -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",

View File

@@ -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": {},

File diff suppressed because it is too large Load Diff

View 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 };
}

View 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 };
}

View 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 };
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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;
}

View 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 } : {}),
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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[];
};