feat: multi-agent routing + multi-account providers
This commit is contained in:
@@ -127,7 +127,7 @@ export async function agentViaGatewayCommand(
|
||||
sessionId: opts.sessionId,
|
||||
});
|
||||
|
||||
const channel = normalizeProvider(opts.provider) ?? "whatsapp";
|
||||
const provider = normalizeProvider(opts.provider) ?? "whatsapp";
|
||||
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
|
||||
|
||||
const response = await callGateway<GatewayAgentResponse>({
|
||||
@@ -139,7 +139,7 @@ export async function agentViaGatewayCommand(
|
||||
sessionKey,
|
||||
thinking: opts.thinking,
|
||||
deliver: Boolean(opts.deliver),
|
||||
channel,
|
||||
provider,
|
||||
timeout: timeoutSeconds,
|
||||
lane: opts.lane,
|
||||
extraSystemPrompt: opts.extraSystemPrompt,
|
||||
|
||||
@@ -59,7 +59,8 @@ type AgentCommandOpts = {
|
||||
json?: boolean;
|
||||
timeout?: string;
|
||||
deliver?: boolean;
|
||||
surface?: string;
|
||||
/** Message provider context (webchat|voicewake|whatsapp|...). */
|
||||
messageProvider?: string;
|
||||
provider?: string; // delivery provider (whatsapp|telegram|...)
|
||||
bestEffortDeliver?: boolean;
|
||||
abortSignal?: AbortSignal;
|
||||
@@ -231,7 +232,7 @@ export async function agentCommand(
|
||||
cfg,
|
||||
entry: sessionEntry,
|
||||
sessionKey,
|
||||
surface: sessionEntry?.surface,
|
||||
provider: sessionEntry?.provider,
|
||||
chatType: sessionEntry?.chatType,
|
||||
});
|
||||
if (sendPolicy === "deny") {
|
||||
@@ -379,8 +380,8 @@ export async function agentCommand(
|
||||
let fallbackProvider = provider;
|
||||
let fallbackModel = model;
|
||||
try {
|
||||
const surface =
|
||||
opts.surface?.trim().toLowerCase() ||
|
||||
const messageProvider =
|
||||
opts.messageProvider?.trim().toLowerCase() ||
|
||||
(() => {
|
||||
const raw = opts.provider?.trim().toLowerCase();
|
||||
if (!raw) return undefined;
|
||||
@@ -394,7 +395,7 @@ export async function agentCommand(
|
||||
runEmbeddedPiAgent({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
surface,
|
||||
messageProvider,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
|
||||
180
src/commands/doctor-state-migrations.test.ts
Normal file
180
src/commands/doctor-state-migrations.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
detectLegacyStateMigrations,
|
||||
runLegacyStateMigrations,
|
||||
} from "./doctor-state-migrations.js";
|
||||
|
||||
let tempRoot: string | null = null;
|
||||
|
||||
async function makeTempRoot() {
|
||||
const root = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-doctor-"),
|
||||
);
|
||||
tempRoot = root;
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (!tempRoot) return;
|
||||
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||||
tempRoot = null;
|
||||
});
|
||||
|
||||
function writeJson5(filePath: string, value: unknown) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
describe("doctor legacy state migrations", () => {
|
||||
it("migrates legacy sessions into agents/<id>/sessions", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const cfg: ClawdbotConfig = {};
|
||||
const legacySessionsDir = path.join(root, "sessions");
|
||||
fs.mkdirSync(legacySessionsDir, { recursive: true });
|
||||
|
||||
writeJson5(path.join(legacySessionsDir, "sessions.json"), {
|
||||
"+1555": { sessionId: "a", updatedAt: 10 },
|
||||
"+1666": { sessionId: "b", updatedAt: 20 },
|
||||
"slack:channel:C123": { sessionId: "c", updatedAt: 30 },
|
||||
"group:abc": { sessionId: "d", updatedAt: 40 },
|
||||
"subagent:xyz": { sessionId: "e", updatedAt: 50 },
|
||||
});
|
||||
fs.writeFileSync(path.join(legacySessionsDir, "a.jsonl"), "a", "utf-8");
|
||||
fs.writeFileSync(path.join(legacySessionsDir, "b.jsonl"), "b", "utf-8");
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||
});
|
||||
const result = await runLegacyStateMigrations({
|
||||
detected,
|
||||
now: () => 123,
|
||||
});
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||
expect(fs.existsSync(path.join(targetDir, "a.jsonl"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(targetDir, "b.jsonl"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(legacySessionsDir, "a.jsonl"))).toBe(false);
|
||||
|
||||
const store = JSON.parse(
|
||||
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
|
||||
) as Record<string, { sessionId: string }>;
|
||||
expect(store["agent:main:main"]?.sessionId).toBe("b");
|
||||
expect(store["agent:main:slack:channel:C123"]?.sessionId).toBe("c");
|
||||
expect(store["group:abc"]?.sessionId).toBe("d");
|
||||
expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e");
|
||||
});
|
||||
|
||||
it("migrates legacy agent dir with conflict fallback", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const cfg: ClawdbotConfig = {};
|
||||
|
||||
const legacyAgentDir = path.join(root, "agent");
|
||||
fs.mkdirSync(legacyAgentDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyAgentDir, "foo.txt"), "legacy", "utf-8");
|
||||
fs.writeFileSync(path.join(legacyAgentDir, "baz.txt"), "legacy2", "utf-8");
|
||||
|
||||
const targetAgentDir = path.join(root, "agents", "main", "agent");
|
||||
fs.mkdirSync(targetAgentDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(targetAgentDir, "foo.txt"), "new", "utf-8");
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||
});
|
||||
await runLegacyStateMigrations({ detected, now: () => 123 });
|
||||
|
||||
expect(fs.readFileSync(path.join(targetAgentDir, "baz.txt"), "utf-8")).toBe(
|
||||
"legacy2",
|
||||
);
|
||||
const backupDir = path.join(root, "agents", "main", "agent.legacy-123");
|
||||
expect(fs.existsSync(path.join(backupDir, "foo.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("migrates legacy WhatsApp auth files without touching oauth.json", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const cfg: ClawdbotConfig = {};
|
||||
|
||||
const oauthDir = path.join(root, "credentials");
|
||||
fs.mkdirSync(oauthDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(oauthDir, "oauth.json"), "{}", "utf-8");
|
||||
fs.writeFileSync(path.join(oauthDir, "creds.json"), "{}", "utf-8");
|
||||
fs.writeFileSync(path.join(oauthDir, "session-abc.json"), "{}", "utf-8");
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||
});
|
||||
await runLegacyStateMigrations({ detected, now: () => 123 });
|
||||
|
||||
const target = path.join(oauthDir, "whatsapp", "default");
|
||||
expect(fs.existsSync(path.join(target, "creds.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(target, "session-abc.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(oauthDir, "oauth.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(oauthDir, "creds.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("no-ops when nothing detected", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const cfg: ClawdbotConfig = {};
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||
});
|
||||
const result = await runLegacyStateMigrations({ detected });
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes legacy state to routing.defaultAgentId", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const cfg: ClawdbotConfig = { routing: { defaultAgentId: "alpha" } };
|
||||
const legacySessionsDir = path.join(root, "sessions");
|
||||
fs.mkdirSync(legacySessionsDir, { recursive: true });
|
||||
writeJson5(path.join(legacySessionsDir, "sessions.json"), {
|
||||
"+1555": { sessionId: "a", updatedAt: 10 },
|
||||
});
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||
});
|
||||
await runLegacyStateMigrations({ detected, now: () => 123 });
|
||||
|
||||
const targetDir = path.join(root, "agents", "alpha", "sessions");
|
||||
const store = JSON.parse(
|
||||
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
|
||||
) as Record<string, { sessionId: string }>;
|
||||
expect(store["agent:alpha:main"]?.sessionId).toBe("a");
|
||||
});
|
||||
|
||||
it("honors session.mainKey when seeding the direct-chat bucket", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const cfg: ClawdbotConfig = { session: { mainKey: "work" } };
|
||||
const legacySessionsDir = path.join(root, "sessions");
|
||||
fs.mkdirSync(legacySessionsDir, { recursive: true });
|
||||
writeJson5(path.join(legacySessionsDir, "sessions.json"), {
|
||||
"+1555": { sessionId: "a", updatedAt: 10 },
|
||||
"+1666": { sessionId: "b", updatedAt: 20 },
|
||||
});
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||
});
|
||||
await runLegacyStateMigrations({ detected, now: () => 123 });
|
||||
|
||||
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||
const store = JSON.parse(
|
||||
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
|
||||
) as Record<string, { sessionId: string }>;
|
||||
expect(store["agent:main:work"]?.sessionId).toBe("b");
|
||||
expect(store["agent:main:main"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
456
src/commands/doctor-state-migrations.ts
Normal file
456
src/commands/doctor-state-migrations.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
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,
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -135,6 +135,37 @@ vi.mock("./onboard-helpers.js", () => ({
|
||||
printWizardHeader: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./doctor-state-migrations.js", () => ({
|
||||
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
||||
targetAgentId: "main",
|
||||
targetMainKey: "main",
|
||||
stateDir: "/tmp/state",
|
||||
oauthDir: "/tmp/oauth",
|
||||
sessions: {
|
||||
legacyDir: "/tmp/state/sessions",
|
||||
legacyStorePath: "/tmp/state/sessions/sessions.json",
|
||||
targetDir: "/tmp/state/agents/main/sessions",
|
||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||
hasLegacy: false,
|
||||
},
|
||||
agentDir: {
|
||||
legacyDir: "/tmp/state/agent",
|
||||
targetDir: "/tmp/state/agents/main/agent",
|
||||
hasLegacy: false,
|
||||
},
|
||||
whatsappAuth: {
|
||||
legacyDir: "/tmp/oauth",
|
||||
targetDir: "/tmp/oauth/whatsapp/default",
|
||||
hasLegacy: false,
|
||||
},
|
||||
preview: [],
|
||||
}),
|
||||
runLegacyStateMigrations: vi.fn().mockResolvedValue({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("doctor", () => {
|
||||
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
|
||||
readConfigFileSnapshot.mockResolvedValue({
|
||||
|
||||
@@ -34,6 +34,10 @@ import { defaultRuntime } from "../runtime.js";
|
||||
import { readTelegramAllowFromStore } from "../telegram/pairing-store.js";
|
||||
import { resolveTelegramToken } from "../telegram/token.js";
|
||||
import { normalizeE164, resolveUserPath, sleep } from "../utils.js";
|
||||
import {
|
||||
detectLegacyStateMigrations,
|
||||
runLegacyStateMigrations,
|
||||
} from "./doctor-state-migrations.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import {
|
||||
applyWizardMetadata,
|
||||
@@ -834,6 +838,29 @@ export async function doctorCommand(
|
||||
cfg = normalized.config;
|
||||
}
|
||||
|
||||
const legacyState = await detectLegacyStateMigrations({ cfg });
|
||||
if (legacyState.preview.length > 0) {
|
||||
note(legacyState.preview.join("\n"), "Legacy state detected");
|
||||
const migrate = guardCancel(
|
||||
await confirm({
|
||||
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
|
||||
initialValue: true,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
if (migrate) {
|
||||
const migrated = await runLegacyStateMigrations({
|
||||
detected: legacyState,
|
||||
});
|
||||
if (migrated.changes.length > 0) {
|
||||
note(migrated.changes.join("\n"), "Doctor changes");
|
||||
}
|
||||
if (migrated.warnings.length > 0) {
|
||||
note(migrated.warnings.join("\n"), "Doctor warnings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg = await maybeRepairSandboxImages(cfg, runtime);
|
||||
|
||||
await maybeMigrateLegacyGatewayService(cfg, runtime);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { info } from "../globals.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { probeTelegram, type TelegramProbe } from "../telegram/probe.js";
|
||||
import { resolveTelegramToken } from "../telegram/token.js";
|
||||
import { resolveWhatsAppAccount } from "../web/accounts.js";
|
||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
||||
import {
|
||||
getWebAuthAgeMs,
|
||||
@@ -58,8 +59,9 @@ export async function getHealthSnapshot(
|
||||
timeoutMs?: number,
|
||||
): Promise<HealthSummary> {
|
||||
const cfg = loadConfig();
|
||||
const linked = await webAuthExists();
|
||||
const authAgeMs = getWebAuthAgeMs();
|
||||
const account = resolveWhatsAppAccount({ cfg });
|
||||
const linked = await webAuthExists(account.authDir);
|
||||
const authAgeMs = getWebAuthAgeMs(account.authDir);
|
||||
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
|
||||
const storePath = resolveStorePath(cfg.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
@@ -128,7 +130,9 @@ export async function healthCommand(
|
||||
: "Web: not linked (run clawdbot login)",
|
||||
);
|
||||
if (summary.web.linked) {
|
||||
logWebSelfId(runtime, true);
|
||||
const cfg = loadConfig();
|
||||
const account = resolveWhatsAppAccount({ cfg });
|
||||
logWebSelfId(account.authDir, runtime, true);
|
||||
}
|
||||
if (summary.web.connect) {
|
||||
const base = summary.web.connect.ok
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { DmPolicy } from "../config/types.js";
|
||||
import { loginWeb } from "../provider-web.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import { resolveWebAuthDir } from "../web/session.js";
|
||||
import { WA_WEB_AUTH_DIR } from "../web/session.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import { detectBinary } from "./onboard-helpers.js";
|
||||
import type { ProviderChoice } from "./onboard-types.js";
|
||||
@@ -29,7 +29,7 @@ async function pathExists(filePath: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function detectWhatsAppLinked(): Promise<boolean> {
|
||||
const credsPath = path.join(resolveWebAuthDir(), "creds.json");
|
||||
const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json");
|
||||
return await pathExists(credsPath);
|
||||
}
|
||||
|
||||
@@ -550,7 +550,7 @@ export async function setupProviders(
|
||||
await prompter.note(
|
||||
[
|
||||
"Scan the QR with WhatsApp on your phone.",
|
||||
`Credentials are stored under ${resolveWebAuthDir()}/ for future runs.`,
|
||||
`Credentials are stored under ${WA_WEB_AUTH_DIR}/ for future runs.`,
|
||||
].join("\n"),
|
||||
"WhatsApp linking",
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ export async function sendCommand(
|
||||
dryRun?: boolean;
|
||||
media?: string;
|
||||
gifPlayback?: boolean;
|
||||
account?: string;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
@@ -173,6 +174,7 @@ export async function sendCommand(
|
||||
message: opts.message,
|
||||
mediaUrl: opts.media,
|
||||
gifPlayback: opts.gifPlayback,
|
||||
accountId: opts.account,
|
||||
provider,
|
||||
idempotencyKey: randomIdempotencyKey(),
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import { info } from "../globals.js";
|
||||
import { buildProviderSummary } from "../infra/provider-summary.js";
|
||||
import { peekSystemEvents } from "../infra/system-events.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { resolveWhatsAppAccount } from "../web/accounts.js";
|
||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
||||
import {
|
||||
getWebAuthAgeMs,
|
||||
@@ -60,8 +61,9 @@ export type StatusSummary = {
|
||||
|
||||
export async function getStatusSummary(): Promise<StatusSummary> {
|
||||
const cfg = loadConfig();
|
||||
const linked = await webAuthExists();
|
||||
const authAgeMs = getWebAuthAgeMs();
|
||||
const account = resolveWhatsAppAccount({ cfg });
|
||||
const linked = await webAuthExists(account.authDir);
|
||||
const authAgeMs = getWebAuthAgeMs(account.authDir);
|
||||
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
|
||||
const providerSummary = await buildProviderSummary(cfg);
|
||||
const queuedSystemEvents = peekSystemEvents();
|
||||
@@ -230,7 +232,9 @@ export async function statusCommand(
|
||||
`Web session: ${summary.web.linked ? "linked" : "not linked"}${summary.web.linked ? ` (last refreshed ${formatAge(summary.web.authAgeMs)})` : ""}`,
|
||||
);
|
||||
if (summary.web.linked) {
|
||||
logWebSelfId(runtime, true);
|
||||
const cfg = loadConfig();
|
||||
const account = resolveWhatsAppAccount({ cfg });
|
||||
logWebSelfId(account.authDir, runtime, true);
|
||||
}
|
||||
runtime.log(info("System:"));
|
||||
for (const line of summary.providerSummary) {
|
||||
|
||||
Reference in New Issue
Block a user