fix(state): auto-migrate legacy agent dir

This commit is contained in:
Peter Steinberger
2026-01-06 22:04:23 +00:00
parent 7aa7fa79d0
commit 2771001720
6 changed files with 570 additions and 457 deletions

View File

@@ -19,6 +19,7 @@ import {
writeConfigFile,
} from "../config/config.js";
import { danger, setVerbose } from "../globals.js";
import { autoMigrateLegacyAgentDir } from "../infra/state-migrations.js";
import { loginWeb, logoutWeb } from "../provider-web.js";
import { defaultRuntime } from "../runtime.js";
import { VERSION } from "../version.js";
@@ -127,6 +128,11 @@ export function buildProgram() {
);
process.exit(1);
});
program.hook("preAction", async (_thisCommand, actionCommand) => {
if (actionCommand.name() === "doctor") return;
const cfg = loadConfig();
await autoMigrateLegacyAgentDir({ cfg });
});
const examples = [
[
"clawdbot login --verbose",

View File

@@ -2,11 +2,13 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
autoMigrateLegacyAgentDir,
detectLegacyStateMigrations,
resetAutoMigrateLegacyAgentDirForTest,
runLegacyStateMigrations,
} from "./doctor-state-migrations.js";
@@ -21,6 +23,7 @@ async function makeTempRoot() {
}
afterEach(async () => {
resetAutoMigrateLegacyAgentDirForTest();
if (!tempRoot) return;
await fs.promises.rm(tempRoot, { recursive: true, force: true });
tempRoot = null;
@@ -98,6 +101,28 @@ describe("doctor legacy state migrations", () => {
expect(fs.existsSync(path.join(backupDir, "foo.txt"))).toBe(true);
});
it("auto-migrates legacy agent dir on startup", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = {};
const legacyAgentDir = path.join(root, "agent");
fs.mkdirSync(legacyAgentDir, { recursive: true });
fs.writeFileSync(path.join(legacyAgentDir, "auth.json"), "{}", "utf-8");
const log = { info: vi.fn(), warn: vi.fn() };
const result = await autoMigrateLegacyAgentDir({
cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
log,
});
const targetAgentDir = path.join(root, "agents", "main", "agent");
expect(fs.existsSync(path.join(targetAgentDir, "auth.json"))).toBe(true);
expect(result.migrated).toBe(true);
expect(log.info).toHaveBeenCalled();
});
it("migrates legacy WhatsApp auth files without touching oauth.json", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = {};

View File

@@ -1,456 +1,8 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import JSON5 from "json5";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import type { SessionEntry } from "../config/sessions.js";
import { saveSessionStore } from "../config/sessions.js";
import {
buildAgentMainSessionKey,
DEFAULT_ACCOUNT_ID,
DEFAULT_AGENT_ID,
DEFAULT_MAIN_KEY,
normalizeAgentId,
} from "../routing/session-key.js";
export type LegacyStateDetection = {
targetAgentId: string;
targetMainKey: string;
stateDir: string;
oauthDir: string;
sessions: {
legacyDir: string;
legacyStorePath: string;
targetDir: string;
targetStorePath: string;
hasLegacy: boolean;
};
agentDir: {
legacyDir: string;
targetDir: string;
hasLegacy: boolean;
};
whatsappAuth: {
legacyDir: string;
targetDir: string;
hasLegacy: boolean;
};
preview: string[];
};
type SessionEntryLike = { sessionId?: string; updatedAt?: number } & Record<
string,
unknown
>;
function safeReadDir(dir: string): fs.Dirent[] {
try {
return fs.readdirSync(dir, { withFileTypes: true });
} catch {
return [];
}
}
function existsDir(dir: string): boolean {
try {
return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
} catch {
return false;
}
}
function ensureDir(dir: string) {
fs.mkdirSync(dir, { recursive: true });
}
function fileExists(p: string): boolean {
try {
return fs.existsSync(p) && fs.statSync(p).isFile();
} catch {
return false;
}
}
function isLegacyWhatsAppAuthFile(name: string): boolean {
if (name === "creds.json" || name === "creds.json.bak") return true;
if (!name.endsWith(".json")) return false;
return /^(app-state-sync|session|sender-key|pre-key)-/.test(name);
}
function readSessionStoreJson5(storePath: string): {
store: Record<string, SessionEntryLike>;
ok: boolean;
} {
try {
const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") {
return { store: parsed as Record<string, SessionEntryLike>, ok: true };
}
} catch {
// ignore
}
return { store: {}, ok: false };
}
function isSurfaceGroupKey(key: string): boolean {
return key.includes(":group:") || key.includes(":channel:");
}
function isLegacyGroupKey(key: string): boolean {
return key.startsWith("group:") || key.includes("@g.us");
}
function normalizeSessionKeyForAgent(key: string, agentId: string): string {
const raw = key.trim();
if (!raw) return raw;
if (raw.startsWith("agent:")) return raw;
if (raw.toLowerCase().startsWith("subagent:")) {
const rest = raw.slice("subagent:".length);
return `agent:${normalizeAgentId(agentId)}:subagent:${rest}`;
}
if (isSurfaceGroupKey(raw)) {
return `agent:${normalizeAgentId(agentId)}:${raw}`;
}
return raw;
}
function pickLatestLegacyDirectEntry(
store: Record<string, SessionEntryLike>,
): SessionEntryLike | null {
let best: SessionEntryLike | null = null;
let bestUpdated = -1;
for (const [key, entry] of Object.entries(store)) {
if (!entry || typeof entry !== "object") continue;
const normalized = key.trim();
if (!normalized) continue;
if (normalized === "global") continue;
if (normalized.startsWith("agent:")) continue;
if (normalized.toLowerCase().startsWith("subagent:")) continue;
if (isLegacyGroupKey(normalized) || isSurfaceGroupKey(normalized)) continue;
const updatedAt = typeof entry.updatedAt === "number" ? entry.updatedAt : 0;
if (updatedAt > bestUpdated) {
bestUpdated = updatedAt;
best = entry;
}
}
return best;
}
function normalizeSessionEntry(entry: SessionEntryLike): SessionEntry | null {
const sessionId =
typeof entry.sessionId === "string" ? entry.sessionId : null;
if (!sessionId) return null;
const updatedAt =
typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt)
? entry.updatedAt
: Date.now();
return { ...(entry as unknown as SessionEntry), sessionId, updatedAt };
}
function emptyDirOrMissing(dir: string): boolean {
if (!existsDir(dir)) return true;
return safeReadDir(dir).length === 0;
}
function removeDirIfEmpty(dir: string) {
if (!existsDir(dir)) return;
if (!emptyDirOrMissing(dir)) return;
try {
fs.rmdirSync(dir);
} catch {
// ignore
}
}
export async function detectLegacyStateMigrations(params: {
cfg: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
homedir?: () => string;
}): Promise<LegacyStateDetection> {
const env = params.env ?? process.env;
const homedir = params.homedir ?? os.homedir;
const stateDir = resolveStateDir(env, homedir);
const oauthDir = resolveOAuthDir(env, stateDir);
const targetAgentId = normalizeAgentId(
params.cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID,
);
const rawMainKey = params.cfg.session?.mainKey;
const targetMainKey =
typeof rawMainKey === "string" && rawMainKey.trim().length > 0
? rawMainKey.trim()
: DEFAULT_MAIN_KEY;
const sessionsLegacyDir = path.join(stateDir, "sessions");
const sessionsLegacyStorePath = path.join(sessionsLegacyDir, "sessions.json");
const sessionsTargetDir = path.join(
stateDir,
"agents",
targetAgentId,
"sessions",
);
const sessionsTargetStorePath = path.join(sessionsTargetDir, "sessions.json");
const legacySessionEntries = safeReadDir(sessionsLegacyDir);
const hasLegacySessions =
fileExists(sessionsLegacyStorePath) ||
legacySessionEntries.some((e) => e.isFile() && e.name.endsWith(".jsonl"));
const legacyAgentDir = path.join(stateDir, "agent");
const targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent");
const hasLegacyAgentDir = existsDir(legacyAgentDir);
const targetWhatsAppAuthDir = path.join(
oauthDir,
"whatsapp",
DEFAULT_ACCOUNT_ID,
);
const hasLegacyWhatsAppAuth =
fileExists(path.join(oauthDir, "creds.json")) &&
!fileExists(path.join(targetWhatsAppAuthDir, "creds.json"));
const preview: string[] = [];
if (hasLegacySessions) {
preview.push(`- Sessions: ${sessionsLegacyDir}${sessionsTargetDir}`);
}
if (hasLegacyAgentDir) {
preview.push(`- Agent dir: ${legacyAgentDir}${targetAgentDir}`);
}
if (hasLegacyWhatsAppAuth) {
preview.push(
`- WhatsApp auth: ${oauthDir}${targetWhatsAppAuthDir} (keep oauth.json)`,
);
}
return {
targetAgentId,
targetMainKey,
stateDir,
oauthDir,
sessions: {
legacyDir: sessionsLegacyDir,
legacyStorePath: sessionsLegacyStorePath,
targetDir: sessionsTargetDir,
targetStorePath: sessionsTargetStorePath,
hasLegacy: hasLegacySessions,
},
agentDir: {
legacyDir: legacyAgentDir,
targetDir: targetAgentDir,
hasLegacy: hasLegacyAgentDir,
},
whatsappAuth: {
legacyDir: oauthDir,
targetDir: targetWhatsAppAuthDir,
hasLegacy: hasLegacyWhatsAppAuth,
},
preview,
};
}
async function migrateLegacySessions(
detected: LegacyStateDetection,
now: () => number,
): Promise<{ changes: string[]; warnings: string[] }> {
const changes: string[] = [];
const warnings: string[] = [];
if (!detected.sessions.hasLegacy) return { changes, warnings };
ensureDir(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;
const normalizedLegacy: Record<string, SessionEntryLike> = {};
for (const [key, entry] of Object.entries(legacyStore)) {
const nextKey = normalizeSessionKeyForAgent(key, detected.targetAgentId);
if (!nextKey) continue;
if (!normalizedLegacy[nextKey]) normalizedLegacy[nextKey] = entry;
}
const merged: Record<string, SessionEntryLike> = {
...normalizedLegacy,
...targetStore,
};
const mainKey = buildAgentMainSessionKey({
agentId: detected.targetAgentId,
mainKey: detected.targetMainKey,
});
if (!merged[mainKey]) {
const latest = pickLatestLegacyDirectEntry(legacyStore);
if (latest?.sessionId) {
merged[mainKey] = latest;
changes.push(`Migrated latest direct-chat session → ${mainKey}`);
}
}
if (!legacyParsed.ok) {
warnings.push(
`Legacy sessions store unreadable; left in place at ${detected.sessions.legacyStorePath}`,
);
}
if (
legacyParsed.ok &&
(Object.keys(legacyStore).length > 0 || Object.keys(targetStore).length > 0)
) {
const normalized: Record<string, SessionEntry> = {};
for (const [key, entry] of Object.entries(merged)) {
const normalizedEntry = normalizeSessionEntry(entry);
if (!normalizedEntry) continue;
normalized[key] = normalizedEntry;
}
await saveSessionStore(detected.sessions.targetStorePath, normalized);
changes.push(
`Merged sessions store → ${detected.sessions.targetStorePath}`,
);
}
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);
const to = path.join(detected.sessions.targetDir, entry.name);
if (fileExists(to)) continue;
try {
fs.renameSync(from, to);
changes.push(
`Moved ${entry.name} → agents/${detected.targetAgentId}/sessions`,
);
} catch (err) {
warnings.push(`Failed moving ${from}: ${String(err)}`);
}
}
if (legacyParsed.ok) {
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 };
}
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 };
ensureDir(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 };
}
async function migrateLegacyWhatsAppAuth(
detected: LegacyStateDetection,
): Promise<{ changes: string[]; warnings: string[] }> {
const changes: string[] = [];
const warnings: string[] = [];
if (!detected.whatsappAuth.hasLegacy) return { changes, warnings };
ensureDir(detected.whatsappAuth.targetDir);
const entries = safeReadDir(detected.whatsappAuth.legacyDir);
for (const entry of entries) {
if (!entry.isFile()) continue;
if (entry.name === "oauth.json") continue;
if (!isLegacyWhatsAppAuthFile(entry.name)) continue;
const from = path.join(detected.whatsappAuth.legacyDir, entry.name);
const to = path.join(detected.whatsappAuth.targetDir, entry.name);
if (fileExists(to)) continue;
try {
fs.renameSync(from, to);
changes.push(`Moved WhatsApp auth ${entry.name} → whatsapp/default`);
} catch (err) {
warnings.push(`Failed moving ${from}: ${String(err)}`);
}
}
return { changes, warnings };
}
export async function runLegacyStateMigrations(params: {
detected: LegacyStateDetection;
now?: () => number;
}): Promise<{ changes: string[]; warnings: string[] }> {
const now = params.now ?? (() => Date.now());
const detected = params.detected;
const sessions = await migrateLegacySessions(detected, now);
const agentDir = await migrateLegacyAgentDir(detected, now);
const whatsappAuth = await migrateLegacyWhatsAppAuth(detected);
return {
changes: [
...sessions.changes,
...agentDir.changes,
...whatsappAuth.changes,
],
warnings: [
...sessions.warnings,
...agentDir.warnings,
...whatsappAuth.warnings,
],
};
}
export type { LegacyStateDetection } from "../infra/state-migrations.js";
export {
autoMigrateLegacyAgentDir,
detectLegacyStateMigrations,
migrateLegacyAgentDir,
resetAutoMigrateLegacyAgentDirForTest,
runLegacyStateMigrations,
} from "../infra/state-migrations.js";

View File

@@ -54,6 +54,7 @@ import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
import { autoMigrateLegacyAgentDir } from "../infra/state-migrations.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import {
listSystemPresence,
@@ -388,6 +389,7 @@ export async function startGatewayServer(
}
const cfgAtStart = loadConfig();
await autoMigrateLegacyAgentDir({ cfg: cfgAtStart, log });
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
if (!bindHost) {

View File

@@ -0,0 +1,527 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import JSON5 from "json5";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import type { SessionEntry } from "../config/sessions.js";
import { saveSessionStore } from "../config/sessions.js";
import { createSubsystemLogger } from "../logging.js";
import {
buildAgentMainSessionKey,
DEFAULT_ACCOUNT_ID,
DEFAULT_AGENT_ID,
DEFAULT_MAIN_KEY,
normalizeAgentId,
} from "../routing/session-key.js";
export type LegacyStateDetection = {
targetAgentId: string;
targetMainKey: string;
stateDir: string;
oauthDir: string;
sessions: {
legacyDir: string;
legacyStorePath: string;
targetDir: string;
targetStorePath: string;
hasLegacy: boolean;
};
agentDir: {
legacyDir: string;
targetDir: string;
hasLegacy: boolean;
};
whatsappAuth: {
legacyDir: string;
targetDir: string;
hasLegacy: boolean;
};
preview: string[];
};
type SessionEntryLike = { sessionId?: string; updatedAt?: number } & Record<
string,
unknown
>;
type MigrationLogger = {
info: (message: string) => void;
warn: (message: string) => void;
};
let autoMigrateChecked = false;
function safeReadDir(dir: string): fs.Dirent[] {
try {
return fs.readdirSync(dir, { withFileTypes: true });
} catch {
return [];
}
}
function existsDir(dir: string): boolean {
try {
return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
} catch {
return false;
}
}
function ensureDir(dir: string) {
fs.mkdirSync(dir, { recursive: true });
}
function fileExists(p: string): boolean {
try {
return fs.existsSync(p) && fs.statSync(p).isFile();
} catch {
return false;
}
}
function isLegacyWhatsAppAuthFile(name: string): boolean {
if (name === "creds.json" || name === "creds.json.bak") return true;
if (!name.endsWith(".json")) return false;
return /^(app-state-sync|session|sender-key|pre-key)-/.test(name);
}
function readSessionStoreJson5(storePath: string): {
store: Record<string, SessionEntryLike>;
ok: boolean;
} {
try {
const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") {
return { store: parsed as Record<string, SessionEntryLike>, ok: true };
}
} catch {
// ignore
}
return { store: {}, ok: false };
}
function isSurfaceGroupKey(key: string): boolean {
return key.includes(":group:") || key.includes(":channel:");
}
function isLegacyGroupKey(key: string): boolean {
return key.startsWith("group:") || key.includes("@g.us");
}
function normalizeSessionKeyForAgent(key: string, agentId: string): string {
const raw = key.trim();
if (!raw) return raw;
if (raw.startsWith("agent:")) return raw;
if (raw.toLowerCase().startsWith("subagent:")) {
const rest = raw.slice("subagent:".length);
return `agent:${normalizeAgentId(agentId)}:subagent:${rest}`;
}
if (isSurfaceGroupKey(raw)) {
return `agent:${normalizeAgentId(agentId)}:${raw}`;
}
return raw;
}
function pickLatestLegacyDirectEntry(
store: Record<string, SessionEntryLike>,
): SessionEntryLike | null {
let best: SessionEntryLike | null = null;
let bestUpdated = -1;
for (const [key, entry] of Object.entries(store)) {
if (!entry || typeof entry !== "object") continue;
const normalized = key.trim();
if (!normalized) continue;
if (normalized === "global") continue;
if (normalized.startsWith("agent:")) continue;
if (normalized.toLowerCase().startsWith("subagent:")) continue;
if (isLegacyGroupKey(normalized) || isSurfaceGroupKey(normalized)) continue;
const updatedAt = typeof entry.updatedAt === "number" ? entry.updatedAt : 0;
if (updatedAt > bestUpdated) {
bestUpdated = updatedAt;
best = entry;
}
}
return best;
}
function normalizeSessionEntry(entry: SessionEntryLike): SessionEntry | null {
const sessionId =
typeof entry.sessionId === "string" ? entry.sessionId : null;
if (!sessionId) return null;
const updatedAt =
typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt)
? entry.updatedAt
: Date.now();
return { ...(entry as unknown as SessionEntry), sessionId, updatedAt };
}
function emptyDirOrMissing(dir: string): boolean {
if (!existsDir(dir)) return true;
return safeReadDir(dir).length === 0;
}
function removeDirIfEmpty(dir: string) {
if (!existsDir(dir)) return;
if (!emptyDirOrMissing(dir)) return;
try {
fs.rmdirSync(dir);
} catch {
// ignore
}
}
export function resetAutoMigrateLegacyAgentDirForTest() {
autoMigrateChecked = false;
}
export async function detectLegacyStateMigrations(params: {
cfg: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
homedir?: () => string;
}): Promise<LegacyStateDetection> {
const env = params.env ?? process.env;
const homedir = params.homedir ?? os.homedir;
const stateDir = resolveStateDir(env, homedir);
const oauthDir = resolveOAuthDir(env, stateDir);
const targetAgentId = normalizeAgentId(
params.cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID,
);
const rawMainKey = params.cfg.session?.mainKey;
const targetMainKey =
typeof rawMainKey === "string" && rawMainKey.trim().length > 0
? rawMainKey.trim()
: DEFAULT_MAIN_KEY;
const sessionsLegacyDir = path.join(stateDir, "sessions");
const sessionsLegacyStorePath = path.join(sessionsLegacyDir, "sessions.json");
const sessionsTargetDir = path.join(
stateDir,
"agents",
targetAgentId,
"sessions",
);
const sessionsTargetStorePath = path.join(sessionsTargetDir, "sessions.json");
const legacySessionEntries = safeReadDir(sessionsLegacyDir);
const hasLegacySessions =
fileExists(sessionsLegacyStorePath) ||
legacySessionEntries.some((e) => e.isFile() && e.name.endsWith(".jsonl"));
const legacyAgentDir = path.join(stateDir, "agent");
const targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent");
const hasLegacyAgentDir = existsDir(legacyAgentDir);
const targetWhatsAppAuthDir = path.join(
oauthDir,
"whatsapp",
DEFAULT_ACCOUNT_ID,
);
const hasLegacyWhatsAppAuth =
fileExists(path.join(oauthDir, "creds.json")) &&
!fileExists(path.join(targetWhatsAppAuthDir, "creds.json"));
const preview: string[] = [];
if (hasLegacySessions) {
preview.push(`- Sessions: ${sessionsLegacyDir}${sessionsTargetDir}`);
}
if (hasLegacyAgentDir) {
preview.push(`- Agent dir: ${legacyAgentDir}${targetAgentDir}`);
}
if (hasLegacyWhatsAppAuth) {
preview.push(
`- WhatsApp auth: ${oauthDir}${targetWhatsAppAuthDir} (keep oauth.json)`,
);
}
return {
targetAgentId,
targetMainKey,
stateDir,
oauthDir,
sessions: {
legacyDir: sessionsLegacyDir,
legacyStorePath: sessionsLegacyStorePath,
targetDir: sessionsTargetDir,
targetStorePath: sessionsTargetStorePath,
hasLegacy: hasLegacySessions,
},
agentDir: {
legacyDir: legacyAgentDir,
targetDir: targetAgentDir,
hasLegacy: hasLegacyAgentDir,
},
whatsappAuth: {
legacyDir: oauthDir,
targetDir: targetWhatsAppAuthDir,
hasLegacy: hasLegacyWhatsAppAuth,
},
preview,
};
}
async function migrateLegacySessions(
detected: LegacyStateDetection,
now: () => number,
): Promise<{ changes: string[]; warnings: string[] }> {
const changes: string[] = [];
const warnings: string[] = [];
if (!detected.sessions.hasLegacy) return { changes, warnings };
ensureDir(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;
const normalizedLegacy: Record<string, SessionEntryLike> = {};
for (const [key, entry] of Object.entries(legacyStore)) {
const nextKey = normalizeSessionKeyForAgent(key, detected.targetAgentId);
if (!nextKey) continue;
if (!normalizedLegacy[nextKey]) normalizedLegacy[nextKey] = entry;
}
const merged: Record<string, SessionEntryLike> = {
...normalizedLegacy,
...targetStore,
};
const mainKey = buildAgentMainSessionKey({
agentId: detected.targetAgentId,
mainKey: detected.targetMainKey,
});
if (!merged[mainKey]) {
const latest = pickLatestLegacyDirectEntry(legacyStore);
if (latest?.sessionId) {
merged[mainKey] = latest;
changes.push(`Migrated latest direct-chat session → ${mainKey}`);
}
}
if (!legacyParsed.ok) {
warnings.push(
`Legacy sessions store unreadable; left in place at ${detected.sessions.legacyStorePath}`,
);
}
if (
legacyParsed.ok &&
(Object.keys(legacyStore).length > 0 || Object.keys(targetStore).length > 0)
) {
const normalized: Record<string, SessionEntry> = {};
for (const [key, entry] of Object.entries(merged)) {
const normalizedEntry = normalizeSessionEntry(entry);
if (!normalizedEntry) continue;
normalized[key] = normalizedEntry;
}
await saveSessionStore(detected.sessions.targetStorePath, normalized);
changes.push(
`Merged sessions store → ${detected.sessions.targetStorePath}`,
);
}
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);
const to = path.join(detected.sessions.targetDir, entry.name);
if (fileExists(to)) continue;
try {
fs.renameSync(from, to);
changes.push(
`Moved ${entry.name} → agents/${detected.targetAgentId}/sessions`,
);
} catch (err) {
warnings.push(`Failed moving ${from}: ${String(err)}`);
}
}
if (legacyParsed.ok) {
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 };
ensureDir(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 };
}
async function migrateLegacyWhatsAppAuth(
detected: LegacyStateDetection,
): Promise<{ changes: string[]; warnings: string[] }> {
const changes: string[] = [];
const warnings: string[] = [];
if (!detected.whatsappAuth.hasLegacy) return { changes, warnings };
ensureDir(detected.whatsappAuth.targetDir);
const entries = safeReadDir(detected.whatsappAuth.legacyDir);
for (const entry of entries) {
if (!entry.isFile()) continue;
if (entry.name === "oauth.json") continue;
if (!isLegacyWhatsAppAuthFile(entry.name)) continue;
const from = path.join(detected.whatsappAuth.legacyDir, entry.name);
const to = path.join(detected.whatsappAuth.targetDir, entry.name);
if (fileExists(to)) continue;
try {
fs.renameSync(from, to);
changes.push(`Moved WhatsApp auth ${entry.name} → whatsapp/default`);
} catch (err) {
warnings.push(`Failed moving ${from}: ${String(err)}`);
}
}
return { changes, warnings };
}
export async function runLegacyStateMigrations(params: {
detected: LegacyStateDetection;
now?: () => number;
}): Promise<{ changes: string[]; warnings: string[] }> {
const now = params.now ?? (() => Date.now());
const detected = params.detected;
const sessions = await migrateLegacySessions(detected, now);
const agentDir = await migrateLegacyAgentDir(detected, now);
const whatsappAuth = await migrateLegacyWhatsAppAuth(detected);
return {
changes: [
...sessions.changes,
...agentDir.changes,
...whatsappAuth.changes,
],
warnings: [
...sessions.warnings,
...agentDir.warnings,
...whatsappAuth.warnings,
],
};
}
export async function autoMigrateLegacyAgentDir(params: {
cfg: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
homedir?: () => string;
log?: MigrationLogger;
now?: () => number;
}): Promise<{
migrated: boolean;
skipped: boolean;
changes: string[];
warnings: string[];
}> {
if (autoMigrateChecked) {
return { migrated: false, skipped: true, changes: [], warnings: [] };
}
autoMigrateChecked = true;
const env = params.env ?? process.env;
if (env.CLAWDBOT_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim()) {
return { migrated: false, skipped: true, changes: [], warnings: [] };
}
const detected = await detectLegacyStateMigrations({
cfg: params.cfg,
env,
homedir: params.homedir,
});
if (!detected.agentDir.hasLegacy) {
return { migrated: false, skipped: false, changes: [], warnings: [] };
}
const { changes, warnings } = await migrateLegacyAgentDir(
detected,
params.now ?? (() => Date.now()),
);
const logger = params.log ?? createSubsystemLogger("state-migrations");
if (changes.length > 0) {
logger.info(
`Auto-migrated legacy agent dir:\n${changes
.map((entry) => `- ${entry}`)
.join("\n")}`,
);
}
if (warnings.length > 0) {
logger.warn(
`Legacy agent dir migration warnings:\n${warnings
.map((entry) => `- ${entry}`)
.join("\n")}`,
);
}
return {
migrated: changes.length > 0,
skipped: false,
changes,
warnings,
};
}