fix(doctor): add headless flags + auto-migrate sessions

This commit is contained in:
Peter Steinberger
2026-01-07 04:43:05 +01:00
parent 9c9ae5aa54
commit 6ffece68b0
9 changed files with 350 additions and 76 deletions

View File

@@ -50,6 +50,8 @@
- Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding). - Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding).
- Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`). - Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`).
- Doctor: normalize default workspace path to `~/clawd` (avoid `~/clawdbot`). - Doctor: normalize default workspace path to `~/clawd` (avoid `~/clawdbot`).
- Doctor: add `--yes` and `--non-interactive` for headless/automation runs (`--non-interactive` only applies safe migrations).
- Gateway/CLI: auto-migrate legacy sessions + agent state layouts on startup (safe; WhatsApp auth still requires `clawdbot doctor`).
- Workspace: only create `BOOTSTRAP.md` for brand-new workspaces (dont recreate after deletion). - Workspace: only create `BOOTSTRAP.md` for brand-new workspaces (dont recreate after deletion).
- Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings. - Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings.
- Build: install Bun in the Dockerfile so `pnpm build` can run Bun scripts. Thanks @loukotal for PR #284. - Build: install Bun in the Dockerfile so `pnpm build` can run Bun scripts. Thanks @loukotal for PR #284.

View File

@@ -48,7 +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. The Gateway/CLI also auto-migrates the legacy sessions + agent dir on startup so history/auth/models land in the per-agent path without a manual doctor run. WhatsApp auth is intentionally only migrated via `clawdbot doctor`.
## Usage ## Usage
@@ -56,6 +56,20 @@ The Gateway/CLI also auto-migrates the legacy agent dir on startup so auth/model
clawdbot doctor clawdbot doctor
``` ```
### Headless / automation
```bash
clawdbot doctor --yes
```
Accept defaults without prompting (including restart/service/sandbox repair steps when applicable).
```bash
clawdbot doctor --non-interactive
```
Run without prompts and only apply safe migrations (config normalization + on-disk state moves). Skips restart/service/sandbox actions that require human confirmation.
If you want to review changes before writing, open the config file first: If you want to review changes before writing, open the config file first:
```bash ```bash

View File

@@ -19,7 +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 { autoMigrateLegacyState } 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";
@@ -132,7 +132,7 @@ export function buildProgram() {
program.hook("preAction", async (_thisCommand, actionCommand) => { program.hook("preAction", async (_thisCommand, actionCommand) => {
if (actionCommand.name() === "doctor") return; if (actionCommand.name() === "doctor") return;
const cfg = loadConfig(); const cfg = loadConfig();
await autoMigrateLegacyAgentDir({ cfg }); await autoMigrateLegacyState({ cfg });
}); });
const examples = [ const examples = [
[ [
@@ -307,10 +307,18 @@ export function buildProgram() {
"Disable workspace memory system suggestions", "Disable workspace memory system suggestions",
false, false,
) )
.option("--yes", "Accept defaults without prompting", false)
.option(
"--non-interactive",
"Run without prompts (safe migrations only)",
false,
)
.action(async (opts) => { .action(async (opts) => {
try { try {
await doctorCommand(defaultRuntime, { await doctorCommand(defaultRuntime, {
workspaceSuggestions: opts.workspaceSuggestions, workspaceSuggestions: opts.workspaceSuggestions,
yes: Boolean(opts.yes),
nonInteractive: Boolean(opts.nonInteractive),
}); });
} catch (err) { } catch (err) {
defaultRuntime.error(String(err)); defaultRuntime.error(String(err));

View File

@@ -6,9 +6,9 @@ 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, autoMigrateLegacyState,
detectLegacyStateMigrations, detectLegacyStateMigrations,
resetAutoMigrateLegacyAgentDirForTest, resetAutoMigrateLegacyStateForTest,
runLegacyStateMigrations, runLegacyStateMigrations,
} from "./doctor-state-migrations.js"; } from "./doctor-state-migrations.js";
@@ -23,7 +23,7 @@ async function makeTempRoot() {
} }
afterEach(async () => { afterEach(async () => {
resetAutoMigrateLegacyAgentDirForTest(); resetAutoMigrateLegacyStateForTest();
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;
@@ -111,7 +111,7 @@ describe("doctor legacy state migrations", () => {
const log = { info: vi.fn(), warn: vi.fn() }; const log = { info: vi.fn(), warn: vi.fn() };
const result = await autoMigrateLegacyAgentDir({ const result = await autoMigrateLegacyState({
cfg, cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv, env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
log, log,
@@ -123,6 +123,35 @@ describe("doctor legacy state migrations", () => {
expect(log.info).toHaveBeenCalled(); expect(log.info).toHaveBeenCalled();
}); });
it("auto-migrates legacy sessions on startup", 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 },
});
fs.writeFileSync(path.join(legacySessionsDir, "a.jsonl"), "a", "utf-8");
const log = { info: vi.fn(), warn: vi.fn() };
const result = await autoMigrateLegacyState({
cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
log,
now: () => 123,
});
expect(result.migrated).toBe(true);
expect(log.info).toHaveBeenCalled();
const targetDir = path.join(root, "agents", "main", "sessions");
expect(fs.existsSync(path.join(targetDir, "a.jsonl"))).toBe(true);
expect(fs.existsSync(path.join(legacySessionsDir, "a.jsonl"))).toBe(false);
expect(fs.existsSync(path.join(targetDir, "sessions.json"))).toBe(true);
});
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 = {};

View File

@@ -1,8 +1,10 @@
export type { LegacyStateDetection } from "../infra/state-migrations.js"; export type { LegacyStateDetection } from "../infra/state-migrations.js";
export { export {
autoMigrateLegacyAgentDir, autoMigrateLegacyAgentDir,
autoMigrateLegacyState,
detectLegacyStateMigrations, detectLegacyStateMigrations,
migrateLegacyAgentDir, migrateLegacyAgentDir,
resetAutoMigrateLegacyAgentDirForTest, resetAutoMigrateLegacyAgentDirForTest,
resetAutoMigrateLegacyStateForTest,
runLegacyStateMigrations, runLegacyStateMigrations,
} from "../infra/state-migrations.js"; } from "../infra/state-migrations.js";

View File

@@ -1,4 +1,26 @@
import { describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let originalIsTTY: boolean | undefined;
function setStdinTty(value: boolean | undefined) {
try {
Object.defineProperty(process.stdin, "isTTY", {
value,
configurable: true,
});
} catch {
// ignore
}
}
beforeEach(() => {
originalIsTTY = process.stdin.isTTY;
setStdinTty(true);
});
afterEach(() => {
setStdinTty(originalIsTTY);
});
const readConfigFileSnapshot = vi.fn(); const readConfigFileSnapshot = vi.fn();
const confirm = vi.fn().mockResolvedValue(true); const confirm = vi.fn().mockResolvedValue(true);
@@ -443,4 +465,153 @@ describe("doctor", () => {
expect(docker.image).toBe("clawdis-sandbox-common:bookworm-slim"); expect(docker.image).toBe("clawdis-sandbox-common:bookworm-slim");
expect(runCommandWithTimeout).not.toHaveBeenCalled(); expect(runCommandWithTimeout).not.toHaveBeenCalled();
}); });
it("runs legacy state migrations in non-interactive mode without prompting", async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config: {},
issues: [],
legacyIssues: [],
});
const { doctorCommand } = await import("./doctor.js");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const { detectLegacyStateMigrations, runLegacyStateMigrations } =
await import("./doctor-state-migrations.js");
detectLegacyStateMigrations.mockResolvedValueOnce({
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: true,
},
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: ["- Legacy sessions detected"],
});
runLegacyStateMigrations.mockResolvedValueOnce({
changes: ["migrated"],
warnings: [],
});
confirm.mockClear();
await doctorCommand(runtime, { nonInteractive: true });
expect(runLegacyStateMigrations).toHaveBeenCalledTimes(1);
expect(confirm).not.toHaveBeenCalled();
});
it("runs legacy state migrations in yes mode without prompting", async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config: {},
issues: [],
legacyIssues: [],
});
const { doctorCommand } = await import("./doctor.js");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const { detectLegacyStateMigrations, runLegacyStateMigrations } =
await import("./doctor-state-migrations.js");
detectLegacyStateMigrations.mockResolvedValueOnce({
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: true,
},
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: ["- Legacy sessions detected"],
});
runLegacyStateMigrations.mockResolvedValueOnce({
changes: ["migrated"],
warnings: [],
});
runLegacyStateMigrations.mockClear();
confirm.mockClear();
await doctorCommand(runtime, { yes: true });
expect(runLegacyStateMigrations).toHaveBeenCalledTimes(1);
expect(confirm).not.toHaveBeenCalled();
});
it("skips gateway restarts in non-interactive mode", async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config: {},
issues: [],
legacyIssues: [],
});
const { healthCommand } = await import("./health.js");
healthCommand.mockRejectedValueOnce(new Error("gateway closed"));
serviceIsLoaded.mockResolvedValueOnce(true);
serviceRestart.mockClear();
confirm.mockClear();
const { doctorCommand } = await import("./doctor.js");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await doctorCommand(runtime, { nonInteractive: true });
expect(serviceRestart).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
});
}); });

View File

@@ -346,8 +346,46 @@ async function runSandboxScript(
type DoctorOptions = { type DoctorOptions = {
workspaceSuggestions?: boolean; workspaceSuggestions?: boolean;
yes?: boolean;
nonInteractive?: boolean;
}; };
type DoctorPrompter = {
confirm: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmSkipInNonInteractive: (
params: Parameters<typeof confirm>[0],
) => Promise<boolean>;
select: <T>(params: Parameters<typeof select>[0], fallback: T) => Promise<T>;
};
function createDoctorPrompter(params: {
runtime: RuntimeEnv;
options: DoctorOptions;
}): DoctorPrompter {
const yes = params.options.yes === true;
const requestedNonInteractive = params.options.nonInteractive === true;
const isTty = Boolean(process.stdin.isTTY);
const nonInteractive = requestedNonInteractive || (!isTty && !yes);
const canPrompt = isTty && !yes && !nonInteractive;
const confirmDefault = async (p: Parameters<typeof confirm>[0]) => {
if (!canPrompt) return Boolean(p.initialValue ?? false);
return guardCancel(await confirm(p), params.runtime) === true;
};
return {
confirm: confirmDefault,
confirmSkipInNonInteractive: async (p) => {
if (nonInteractive) return false;
return confirmDefault(p);
},
select: async <T>(p: Parameters<typeof select>[0], fallback: T) => {
if (!canPrompt) return fallback;
return guardCancel(await select(p), params.runtime) as T;
},
};
}
const MEMORY_SYSTEM_PROMPT = [ const MEMORY_SYSTEM_PROMPT = [
"Memory system not found in workspace.", "Memory system not found in workspace.",
"Paste this into your agent:", "Paste this into your agent:",
@@ -463,6 +501,7 @@ type SandboxImageCheck = {
async function handleMissingSandboxImage( async function handleMissingSandboxImage(
params: SandboxImageCheck, params: SandboxImageCheck,
runtime: RuntimeEnv, runtime: RuntimeEnv,
prompter: DoctorPrompter,
) { ) {
const exists = await dockerImageExists(params.image); const exists = await dockerImageExists(params.image);
if (exists) return; if (exists) return;
@@ -477,13 +516,10 @@ async function handleMissingSandboxImage(
let built = false; let built = false;
if (params.buildScript) { if (params.buildScript) {
const build = guardCancel( const build = await prompter.confirmSkipInNonInteractive({
await confirm({ message: `Build ${params.label} sandbox image now?`,
message: `Build ${params.label} sandbox image now?`, initialValue: true,
initialValue: true, });
}),
runtime,
);
if (build) { if (build) {
built = await runSandboxScript(params.buildScript, runtime); built = await runSandboxScript(params.buildScript, runtime);
} }
@@ -496,13 +532,10 @@ async function handleMissingSandboxImage(
const legacyExists = await dockerImageExists(legacyImage); const legacyExists = await dockerImageExists(legacyImage);
if (!legacyExists) return; if (!legacyExists) return;
const fallback = guardCancel( const fallback = await prompter.confirmSkipInNonInteractive({
await confirm({ message: `Switch config to legacy image ${legacyImage}?`,
message: `Switch config to legacy image ${legacyImage}?`, initialValue: false,
initialValue: false, });
}),
runtime,
);
if (!fallback) return; if (!fallback) return;
params.updateConfig(legacyImage); params.updateConfig(legacyImage);
@@ -511,6 +544,7 @@ async function handleMissingSandboxImage(
async function maybeRepairSandboxImages( async function maybeRepairSandboxImages(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
runtime: RuntimeEnv, runtime: RuntimeEnv,
prompter: DoctorPrompter,
): Promise<ClawdbotConfig> { ): Promise<ClawdbotConfig> {
const sandbox = cfg.agent?.sandbox; const sandbox = cfg.agent?.sandbox;
const mode = sandbox?.mode ?? "off"; const mode = sandbox?.mode ?? "off";
@@ -542,6 +576,7 @@ async function maybeRepairSandboxImages(
}, },
}, },
runtime, runtime,
prompter,
); );
if (sandbox.browser?.enabled) { if (sandbox.browser?.enabled) {
@@ -556,6 +591,7 @@ async function maybeRepairSandboxImages(
}, },
}, },
runtime, runtime,
prompter,
); );
} }
@@ -717,6 +753,7 @@ async function maybeMigrateLegacyConfigFile(runtime: RuntimeEnv) {
async function maybeMigrateLegacyGatewayService( async function maybeMigrateLegacyGatewayService(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
runtime: RuntimeEnv, runtime: RuntimeEnv,
prompter: DoctorPrompter,
) { ) {
const legacyServices = await findLegacyGatewayServices(process.env); const legacyServices = await findLegacyGatewayServices(process.env);
if (legacyServices.length === 0) return; if (legacyServices.length === 0) return;
@@ -728,13 +765,10 @@ async function maybeMigrateLegacyGatewayService(
"Legacy Clawdis services detected", "Legacy Clawdis services detected",
); );
const migrate = guardCancel( const migrate = await prompter.confirmSkipInNonInteractive({
await confirm({ message: "Migrate legacy Clawdis services to Clawdbot now?",
message: "Migrate legacy Clawdis services to Clawdbot now?", initialValue: true,
initialValue: true, });
}),
runtime,
);
if (!migrate) return; if (!migrate) return;
try { try {
@@ -764,23 +798,20 @@ async function maybeMigrateLegacyGatewayService(
return; return;
} }
const install = guardCancel( const install = await prompter.confirmSkipInNonInteractive({
await confirm({ message: "Install Clawdbot gateway service now?",
message: "Install Clawdbot gateway service now?", initialValue: true,
initialValue: true, });
}),
runtime,
);
if (!install) return; if (!install) return;
const daemonRuntime = guardCancel( const daemonRuntime = await prompter.select<GatewayDaemonRuntime>(
await select({ {
message: "Gateway daemon runtime", message: "Gateway daemon runtime",
options: GATEWAY_DAEMON_RUNTIME_OPTIONS, options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME, initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME,
}), },
runtime, DEFAULT_GATEWAY_DAEMON_RUNTIME,
) as GatewayDaemonRuntime; );
const devMode = const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts"); process.argv[1]?.endsWith(".ts");
@@ -811,6 +842,7 @@ export async function doctorCommand(
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
options: DoctorOptions = {}, options: DoctorOptions = {},
) { ) {
const prompter = createDoctorPrompter({ runtime, options });
printWizardHeader(runtime); printWizardHeader(runtime);
intro("Clawdbot doctor"); intro("Clawdbot doctor");
@@ -833,13 +865,10 @@ export async function doctorCommand(
.join("\n"), .join("\n"),
"Legacy config keys detected", "Legacy config keys detected",
); );
const migrate = guardCancel( const migrate = await prompter.confirm({
await confirm({ message: "Migrate legacy config entries now?",
message: "Migrate legacy config entries now?", initialValue: true,
initialValue: true, });
}),
runtime,
);
if (migrate) { if (migrate) {
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. // Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
const { config: migrated, changes } = migrateLegacyConfig( const { config: migrated, changes } = migrateLegacyConfig(
@@ -863,13 +892,10 @@ export async function doctorCommand(
const legacyState = await detectLegacyStateMigrations({ cfg }); const legacyState = await detectLegacyStateMigrations({ cfg });
if (legacyState.preview.length > 0) { if (legacyState.preview.length > 0) {
note(legacyState.preview.join("\n"), "Legacy state detected"); note(legacyState.preview.join("\n"), "Legacy state detected");
const migrate = guardCancel( const migrate = await prompter.confirm({
await confirm({ message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?", initialValue: true,
initialValue: true, });
}),
runtime,
);
if (migrate) { if (migrate) {
const migrated = await runLegacyStateMigrations({ const migrated = await runLegacyStateMigrations({
detected: legacyState, detected: legacyState,
@@ -883,13 +909,17 @@ export async function doctorCommand(
} }
} }
cfg = await maybeRepairSandboxImages(cfg, runtime); cfg = await maybeRepairSandboxImages(cfg, runtime, prompter);
await maybeMigrateLegacyGatewayService(cfg, runtime); await maybeMigrateLegacyGatewayService(cfg, runtime, prompter);
await noteSecurityWarnings(cfg); await noteSecurityWarnings(cfg);
if (process.platform === "linux" && resolveMode(cfg) === "local") { if (
options.nonInteractive !== true &&
process.platform === "linux" &&
resolveMode(cfg) === "local"
) {
const service = resolveGatewayService(); const service = resolveGatewayService();
let loaded = false; let loaded = false;
try { try {
@@ -901,7 +931,7 @@ export async function doctorCommand(
await ensureSystemdUserLingerInteractive({ await ensureSystemdUserLingerInteractive({
runtime, runtime,
prompter: { prompter: {
confirm: async (p) => guardCancel(await confirm(p), runtime) === true, confirm: async (p) => prompter.confirm(p),
note, note,
}, },
reason: reason:
@@ -955,13 +985,10 @@ export async function doctorCommand(
"Gateway", "Gateway",
); );
} }
const restart = guardCancel( const restart = await prompter.confirmSkipInNonInteractive({
await confirm({ message: "Restart gateway daemon now?",
message: "Restart gateway daemon now?", initialValue: true,
initialValue: true, });
}),
runtime,
);
if (restart) { if (restart) {
await service.restart({ stdout: process.stdout }); await service.restart({ stdout: process.stdout });
await sleep(1500); await sleep(1500);

View File

@@ -54,7 +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 { autoMigrateLegacyState } from "../infra/state-migrations.js";
import { enqueueSystemEvent } from "../infra/system-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js";
import { import {
listSystemPresence, listSystemPresence,
@@ -389,7 +389,7 @@ export async function startGatewayServer(
} }
const cfgAtStart = loadConfig(); const cfgAtStart = loadConfig();
await autoMigrateLegacyAgentDir({ cfg: cfgAtStart, log }); await autoMigrateLegacyState({ 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) {

View File

@@ -174,10 +174,14 @@ function removeDirIfEmpty(dir: string) {
} }
} }
export function resetAutoMigrateLegacyAgentDirForTest() { export function resetAutoMigrateLegacyStateForTest() {
autoMigrateChecked = false; autoMigrateChecked = false;
} }
export function resetAutoMigrateLegacyAgentDirForTest() {
resetAutoMigrateLegacyStateForTest();
}
export async function detectLegacyStateMigrations(params: { export async function detectLegacyStateMigrations(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
@@ -478,6 +482,21 @@ export async function autoMigrateLegacyAgentDir(params: {
skipped: boolean; skipped: boolean;
changes: string[]; changes: string[];
warnings: string[]; warnings: string[];
}> {
return await autoMigrateLegacyState(params);
}
export async function autoMigrateLegacyState(params: {
cfg: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
homedir?: () => string;
log?: MigrationLogger;
now?: () => number;
}): Promise<{
migrated: boolean;
skipped: boolean;
changes: string[];
warnings: string[];
}> { }> {
if (autoMigrateChecked) { if (autoMigrateChecked) {
return { migrated: false, skipped: true, changes: [], warnings: [] }; return { migrated: false, skipped: true, changes: [], warnings: [] };
@@ -494,25 +513,27 @@ export async function autoMigrateLegacyAgentDir(params: {
env, env,
homedir: params.homedir, homedir: params.homedir,
}); });
if (!detected.agentDir.hasLegacy) { if (!detected.sessions.hasLegacy && !detected.agentDir.hasLegacy) {
return { migrated: false, skipped: false, changes: [], warnings: [] }; return { migrated: false, skipped: false, changes: [], warnings: [] };
} }
const { changes, warnings } = await migrateLegacyAgentDir( const now = params.now ?? (() => Date.now());
detected, const sessions = await migrateLegacySessions(detected, now);
params.now ?? (() => Date.now()), const agentDir = await migrateLegacyAgentDir(detected, now);
); const changes = [...sessions.changes, ...agentDir.changes];
const warnings = [...sessions.warnings, ...agentDir.warnings];
const logger = params.log ?? createSubsystemLogger("state-migrations"); const logger = params.log ?? createSubsystemLogger("state-migrations");
if (changes.length > 0) { if (changes.length > 0) {
logger.info( logger.info(
`Auto-migrated legacy agent dir:\n${changes `Auto-migrated legacy state:\n${changes
.map((entry) => `- ${entry}`) .map((entry) => `- ${entry}`)
.join("\n")}`, .join("\n")}`,
); );
} }
if (warnings.length > 0) { if (warnings.length > 0) {
logger.warn( logger.warn(
`Legacy agent dir migration warnings:\n${warnings `Legacy state migration warnings:\n${warnings
.map((entry) => `- ${entry}`) .map((entry) => `- ${entry}`)
.join("\n")}`, .join("\n")}`,
); );