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 resetAutoMigrateLegacyStateForTest() { autoMigrateChecked = false; } export function resetAutoMigrateLegacyAgentDirForTest() { resetAutoMigrateLegacyStateForTest(); } 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[]; }> { return await autoMigrateLegacyState(params); } export async function autoMigrateLegacyState(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.sessions.hasLegacy && !detected.agentDir.hasLegacy) { return { migrated: false, skipped: false, changes: [], warnings: [] }; } const now = params.now ?? (() => Date.now()); const sessions = await migrateLegacySessions(detected, now); const agentDir = await migrateLegacyAgentDir(detected, now); const changes = [...sessions.changes, ...agentDir.changes]; const warnings = [...sessions.warnings, ...agentDir.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")}`, ); } return { migrated: changes.length > 0, skipped: false, changes, warnings, }; }