diff --git a/docs/doctor.md b/docs/doctor.md index a68c36be0..3606870fa 100644 --- a/docs/doctor.md +++ b/docs/doctor.md @@ -48,6 +48,7 @@ Doctor can migrate older on-disk layouts into the current structure: - to `~/.clawdbot/credentials/whatsapp//...` (default account id: `default`) These migrations are best-effort and idempotent; doctor will emit warnings when it leaves any legacy folders behind as backups. +The Gateway/CLI also auto-migrates the legacy agent dir on startup so auth/models land in the per-agent path without a manual doctor run. ## Usage diff --git a/src/cli/program.ts b/src/cli/program.ts index d66fb0738..072339a8c 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -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", diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index de8d47750..4cfa69708 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -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 = {}; diff --git a/src/commands/doctor-state-migrations.ts b/src/commands/doctor-state-migrations.ts index b599e8a7f..cda89f0c4 100644 --- a/src/commands/doctor-state-migrations.ts +++ b/src/commands/doctor-state-migrations.ts @@ -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; - 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, 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, -): 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 { - 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 = {}; - 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 = { - ...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 = {}; - 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"; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 523e7f3df..cf46ab54c 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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) { diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts new file mode 100644 index 000000000..66e6ee1b7 --- /dev/null +++ b/src/infra/state-migrations.ts @@ -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; + 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, 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, +): 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 { + 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 = {}; + 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 = { + ...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 = {}; + 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, + }; +}