Detect legacy state directories and config formats, then transform them to the current schema without data loss.
State Migrations
Legacy state detection and automated migration pipeline
When OpenClaw’s on-disk layout evolves, the migration system ensures existing installations upgrade seamlessly without losing data or configuration.
📚 What is this & why does it matter?
As OpenClaw evolves, its on-disk layout changes—config keys are renamed, directories are restructured, and session store schemas gain new fields. State migrations detect legacy formats at startup and transform them into the current schema atomically. Without migrations, users would have to manually recreate their configuration after every major update.
💡 Real-world analogy
Think of moving to a new apartment. The movers (migration scripts) inventory everything in the old place, create a labeled floor plan for the new apartment, and move boxes one by one. If anything goes wrong mid-move, the old apartment is still intact—nothing is deleted until every box is verified in its new location. The inventory list (copy plan) is printed first so you can review it before a single box is lifted.
Contents
Overview
File system paths under ~/.claw: session dirs, agent dirs, WhatsApp auth, pairing allowlists, and JSON5 config files.
Whether legacy artifacts exist, whether the copy plan is safe (no conflicts), and whether to execute or abort the migration.
Migrated files in new locations, updated config objects, and a migration log. Legacy originals are preserved until verification succeeds.
stateDiagram-v2
[*] --> Idle
Idle --> Scanning : startup trigger
Scanning --> NothingFound : no legacy dirs
NothingFound --> Idle
Scanning --> PlanReady : legacy detected
PlanReady --> Preview : compute copy plan
Preview --> Executing : user confirms or auto-approve
Preview --> Aborted : conflict detected
Aborted --> Idle
Executing --> Verifying : all moves complete
Verifying --> Done : checksums match
Verifying --> Rollback : mismatch detected
Rollback --> Idle : restore originals
Done --> [*]
The state diagram above shows the full migration lifecycle. The system starts in Idle, scans for legacy directories on startup, computes a copy plan, previews it, executes atomic moves, and verifies the result. If verification fails, a rollback restores the originals.
Decision Points
| Decision | What controls it | Why it matters |
|---|---|---|
| Legacy detection trigger | Presence of ~/.claw/sessions/, ~/.claw/agents/, or ~/.claw/whatsapp-auth/ | Scanning only fires when at least one legacy sentinel directory exists, avoiding unnecessary I/O on clean installs. |
| Copy plan conflict check | Target path existence check before any move | If the target already exists and differs from the source, the plan is marked unsafe and the migration aborts with a diagnostic message. |
| Auto-approve vs. interactive | CLAW_MIGRATE_AUTO environment variable | CI environments set this to skip the preview prompt; interactive users see the plan before anything moves. |
| Config version detection | version field in the parsed JSON5 config | Determines which transformation chain to apply: v0 → v1 requires key renames, v1 → v2 adds new required fields with defaults. |
How It Works
The migration pipeline is implemented across two main files: state-migrations.ts (200+ lines) handles directory-level moves, and legacy-migrate.ts in the config module handles config format transformation. Both follow the same detect–plan–execute–verify pattern.
Legacy Detection
The LegacyStateDetection type captures the result of the initial filesystem scan. Each field maps to a specific legacy directory or file pattern.
/** Result of scanning for legacy state artifacts */
interface LegacyStateDetection {
hasLegacySessions: boolean;
hasLegacyAgents: boolean;
hasWhatsAppAuth: boolean;
hasPairingAllowlist: boolean;
legacyConfigPath: string | null;
legacyConfigVersion: number | null;
}
/** Session entry compatible with both old and new formats */
interface SessionEntryLike {
sessionId: string;
updatedAt: number;
[key: string]: unknown;
}
async function detectLegacyState(
homedir: string
): Promise<LegacyStateDetection> {
const clawDir = path.join(homedir, ".claw");
const [sessions, agents, waAuth, allowlist, config] = await Promise.all([
directoryExists(path.join(clawDir, "sessions")),
directoryExists(path.join(clawDir, "agents")),
directoryExists(path.join(clawDir, "whatsapp-auth")),
fileExists(path.join(clawDir, "pairing-allowlist.json")),
findLegacyConfig(clawDir),
]);
return {
hasLegacySessions: sessions,
hasLegacyAgents: agents,
hasWhatsAppAuth: waAuth,
hasPairingAllowlist: allowlist,
legacyConfigPath: config?.path ?? null,
legacyConfigVersion: config?.version ?? null,
};
}
Promise.all, keeping the detection phase under 50ms on typical SSDs even when none of the legacy paths exist.
Session Store Parsing
Legacy session stores use JSON5 format (allowing comments and trailing commas). The readSessionStoreJson5 function handles this gracefully:
interface SessionStoreResult {
ok: boolean;
store: Record<string, SessionEntry>;
}
async function readSessionStoreJson5(
filePath: string
): Promise<SessionStoreResult> {
try {
const raw = await fs.readFile(filePath, "utf-8");
const parsed = JSON5.parse(raw);
if (typeof parsed !== "object" || parsed === null) {
return { ok: false, store: {} };
}
const store: Record<string, SessionEntry> = {};
for (const [key, value] of Object.entries(parsed)) {
if (isSessionEntryLike(value)) {
store[key] = normalizeSessionEntry(value);
}
}
return { ok: true, store };
} catch {
return { ok: false, store: {} };
}
}
JSON.parse will reject files with comments or trailing commas. The migration reader must use a JSON5 parser to handle legacy files that were hand-edited by users.
Copy Plan Computation
Once legacy artifacts are detected, the pipeline computes a deterministic copy plan—a list of source/target pairs with conflict annotations. The plan is fully computed before any filesystem mutation occurs.
Config Validation Pipeline
Config migration follows a five-stage pipeline that transforms raw config text into a validated, typed object:
Migration Detection Algorithm
flowchart TD
A["Read ~/.claw directory listing"] --> B{"sessions/ exists?"}
B -- Yes --> C["Mark hasLegacySessions"]
B -- No --> D{"agents/ exists?"}
C --> D
D -- Yes --> E["Mark hasLegacyAgents"]
D -- No --> F{"whatsapp-auth/ exists?"}
E --> F
F -- Yes --> G["Mark hasWhatsAppAuth"]
F -- No --> H{"pairing-allowlist.json exists?"}
G --> H
H -- Yes --> I["Mark hasPairingAllowlist"]
H -- No --> J{"Config file found?"}
I --> J
J -- Yes --> K["Parse config, extract version"]
J -- No --> L["No legacy config"]
K --> M{"Any legacy flag set?"}
L --> M
M -- Yes --> N["Compute copy plan"]
M -- No --> O["Return: nothing to migrate"]
N --> P["Check for conflicts"]
P --> Q{"Conflicts found?"}
Q -- Yes --> R["Abort with diagnostic"]
Q -- No --> S["Preview plan to user"]
S --> T["Execute atomic moves"]
T --> U["Verify checksums"]
U --> V["Migration complete"]
The flowchart above traces the full detection-to-execution path. Each diamond is a decision point where the algorithm can short-circuit. The atomic move phase uses rename() calls that are crash-safe on POSIX filesystems—either the rename completes or the source remains untouched.
.migrated suffix appended, allowing manual inspection and rollback if needed.
Annotated Code Walkthrough
Walk through the config validation pipeline that runs after raw config text is read from disk.
Parse the raw config file using JSON5 to tolerate comments and trailing commas left by hand-editing:
import JSON5 from "json5";
import { z } from "zod";
async function loadConfig(
filePath: string
): Promise<ResolvedConfig> {
const raw = await fs.readFile(filePath, "utf-8");
const parsed = JSON5.parse(raw);
// Stage 1: JSON5 parse complete
return validateConfig(parsed);
}
Substitute environment variable references (${ENV_VAR}) in string values before schema validation:
function substituteEnvVars(
obj: Record<string, unknown>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (typeof value === "string") {
result[key] = value.replace(
/\$\{(\w+)\}/g,
(_, name) => process.env[name] ?? ""
);
} else if (typeof value === "object" && value !== null) {
result[key] = substituteEnvVars(
value as Record<string, unknown>
);
} else {
result[key] = value;
}
}
return result;
}
Run Zod validation with coercion to apply defaults and normalize types, then check for dangerous keys:
const ConfigSchema = z.object({
version: z.literal(2),
dataDir: z.string().default("~/.openclaw/data"),
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
maxRetries: z.number().int().min(0).max(100).default(5),
dangerousKeys: z.array(z.string()).optional(),
});
function validateConfig(
parsed: unknown
): ResolvedConfig {
const substituted = substituteEnvVars(
parsed as Record<string, unknown>
);
const result = ConfigSchema.parse(substituted);
if (result.dangerousKeys?.length) {
console.warn(
"Config contains dangerous keys:",
result.dangerousKeys
);
}
return result as ResolvedConfig;
}
The legacy-migrate module transforms old config format keys into the current naming convention:
const KEY_RENAMES: Record<string, string> = {
"data_dir": "dataDir",
"log_level": "logLevel",
"max_retries": "maxRetries",
"wa_auth_path": "whatsappAuthPath",
};
function migrateLegacyConfig(
old: Record<string, unknown>
): Record<string, unknown> {
const migrated: Record<string, unknown> = { version: 2 };
for (const [key, value] of Object.entries(old)) {
const newKey = KEY_RENAMES[key] ?? key;
migrated[newKey] = value;
}
return migrated;
}
Edge Cases & Error Handling
| Scenario | Behavior | Code Location |
|---|---|---|
| Legacy dir is a symlink | Symlinks are resolved to real paths before planning; cross-device symlinks abort the migration | store/state-migrations.ts:78 |
| JSON5 parse failure | readSessionStoreJson5 returns { ok: false, store: {} }; migration proceeds without that file | store/state-migrations.ts:112 |
| Target path already exists | Copy plan marks the entry as conflict; entire migration aborts with list of conflicting paths | store/state-migrations.ts:145 |
| Partial move failure | Completed moves are reversed in LIFO order; .migrated suffixes are removed from successfully renamed sources | store/state-migrations.ts:189 |
| Unknown config version | Config loader returns a structured error with the unknown version number; startup continues with defaults | config/legacy-migrate.ts:34 |
Configuration
| Knob / Parameter | Default | Description |
|---|---|---|
CLAW_MIGRATE_AUTO | false | When set to true, skips the interactive preview prompt and executes the copy plan immediately. |
CLAW_LEGACY_DIR | ~/.claw | Override the base directory scanned for legacy state artifacts. |
CLAW_MIGRATE_DRY_RUN | false | When set, computes and prints the copy plan without executing any moves. |
CLAW_MIGRATE_KEEP_ORIGINALS | true | When false, legacy originals are deleted after successful verification instead of being suffixed with .migrated. |
Related Topics
See also:
- store overview — the parent sub-module page
- Data Models — the type definitions that migrations must produce
- Query Builder — atomic I/O primitives used during the move phase
Directory Map
Source files for this topic
src/infra/store/
├── state-migrations.ts ← LegacyStateDetection, scan, plan, execute, verify (200+ lines)
├── session-store.ts ← readSessionStoreJson5(), SessionEntry normalization
└── ...
src/infra/config/
├── legacy-migrate.ts ← KEY_RENAMES map, migrateLegacyConfig()
├── config-schema.ts ← Zod ConfigSchema, env substitution
└── ...