feat: multi-agent routing + multi-account providers

This commit is contained in:
Peter Steinberger
2026-01-06 18:25:37 +00:00
parent 50d4b17417
commit dbfa316d19
129 changed files with 3760 additions and 1126 deletions

View File

@@ -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,

View File

@@ -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,

View 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();
});
});

View 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,
],
};
}

View File

@@ -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({

View File

@@ -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);

View File

@@ -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

View File

@@ -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",
);

View File

@@ -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(),
},

View File

@@ -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) {