fix(state): auto-migrate legacy agent dir
This commit is contained in:
@@ -48,6 +48,7 @@ Doctor can migrate older on-disk layouts into the current structure:
|
|||||||
- to `~/.clawdbot/credentials/whatsapp/<accountId>/...` (default account id: `default`)
|
- to `~/.clawdbot/credentials/whatsapp/<accountId>/...` (default account id: `default`)
|
||||||
|
|
||||||
These migrations are best-effort and idempotent; doctor will emit warnings when it leaves any legacy folders behind as backups.
|
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
|
## Usage
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import { danger, setVerbose } from "../globals.js";
|
import { danger, setVerbose } from "../globals.js";
|
||||||
|
import { autoMigrateLegacyAgentDir } from "../infra/state-migrations.js";
|
||||||
import { loginWeb, logoutWeb } from "../provider-web.js";
|
import { loginWeb, logoutWeb } from "../provider-web.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
@@ -127,6 +128,11 @@ export function buildProgram() {
|
|||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||||
|
if (actionCommand.name() === "doctor") return;
|
||||||
|
const cfg = loadConfig();
|
||||||
|
await autoMigrateLegacyAgentDir({ cfg });
|
||||||
|
});
|
||||||
const examples = [
|
const examples = [
|
||||||
[
|
[
|
||||||
"clawdbot login --verbose",
|
"clawdbot login --verbose",
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
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 type { ClawdbotConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
|
autoMigrateLegacyAgentDir,
|
||||||
detectLegacyStateMigrations,
|
detectLegacyStateMigrations,
|
||||||
|
resetAutoMigrateLegacyAgentDirForTest,
|
||||||
runLegacyStateMigrations,
|
runLegacyStateMigrations,
|
||||||
} from "./doctor-state-migrations.js";
|
} from "./doctor-state-migrations.js";
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ async function makeTempRoot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
resetAutoMigrateLegacyAgentDirForTest();
|
||||||
if (!tempRoot) return;
|
if (!tempRoot) return;
|
||||||
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||||||
tempRoot = null;
|
tempRoot = null;
|
||||||
@@ -98,6 +101,28 @@ describe("doctor legacy state migrations", () => {
|
|||||||
expect(fs.existsSync(path.join(backupDir, "foo.txt"))).toBe(true);
|
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 () => {
|
it("migrates legacy WhatsApp auth files without touching oauth.json", async () => {
|
||||||
const root = await makeTempRoot();
|
const root = await makeTempRoot();
|
||||||
const cfg: ClawdbotConfig = {};
|
const cfg: ClawdbotConfig = {};
|
||||||
|
|||||||
@@ -1,456 +1,8 @@
|
|||||||
import fs from "node:fs";
|
export type { LegacyStateDetection } from "../infra/state-migrations.js";
|
||||||
import os from "node:os";
|
export {
|
||||||
import path from "node:path";
|
autoMigrateLegacyAgentDir,
|
||||||
|
detectLegacyStateMigrations,
|
||||||
import JSON5 from "json5";
|
migrateLegacyAgentDir,
|
||||||
|
resetAutoMigrateLegacyAgentDirForTest,
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
runLegacyStateMigrations,
|
||||||
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
} from "../infra/state-migrations.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,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
|
|||||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||||
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
||||||
|
import { autoMigrateLegacyAgentDir } from "../infra/state-migrations.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import {
|
import {
|
||||||
listSystemPresence,
|
listSystemPresence,
|
||||||
@@ -388,6 +389,7 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cfgAtStart = loadConfig();
|
const cfgAtStart = loadConfig();
|
||||||
|
await autoMigrateLegacyAgentDir({ cfg: cfgAtStart, log });
|
||||||
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
|
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
|
||||||
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
|
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
|
||||||
if (!bindHost) {
|
if (!bindHost) {
|
||||||
|
|||||||
527
src/infra/state-migrations.ts
Normal file
527
src/infra/state-migrations.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user