State Migrations

Focus Deep-Dive

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

Primary role

Detect legacy state directories and config formats, then transform them to the current schema without data loss.

Main inputs

File system paths under ~/.claw: session dirs, agent dirs, WhatsApp auth, pairing allowlists, and JSON5 config files.

Key decisions

Whether legacy artifacts exist, whether the copy plan is safe (no conflicts), and whether to execute or abort the migration.

Outputs / side effects

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

DecisionWhat controls itWhy it matters
Legacy detection triggerPresence 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 checkTarget path existence check before any moveIf 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. interactiveCLAW_MIGRATE_AUTO environment variableCI environments set this to skip the preview prompt; interactive users see the plan before anything moves.
Config version detectionversion field in the parsed JSON5 configDetermines 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,
  };
}
Key detail: All five checks run in parallel via 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: {} };
  }
}
JSON5 caveat: The standard 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.

Safety guarantee: Legacy originals are never deleted. After a successful migration, they remain in place with a .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;
}
Security note: Environment substitution only applies to values, never to keys. This prevents injection of unexpected config keys via environment variables.

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

ScenarioBehaviorCode Location
Legacy dir is a symlinkSymlinks are resolved to real paths before planning; cross-device symlinks abort the migrationstore/state-migrations.ts:78
JSON5 parse failurereadSessionStoreJson5 returns { ok: false, store: {} }; migration proceeds without that filestore/state-migrations.ts:112
Target path already existsCopy plan marks the entry as conflict; entire migration aborts with list of conflicting pathsstore/state-migrations.ts:145
Partial move failureCompleted moves are reversed in LIFO order; .migrated suffixes are removed from successfully renamed sourcesstore/state-migrations.ts:189
Unknown config versionConfig loader returns a structured error with the unknown version number; startup continues with defaultsconfig/legacy-migrate.ts:34

Configuration

Knob / ParameterDefaultDescription
CLAW_MIGRATE_AUTOfalseWhen set to true, skips the interactive preview prompt and executes the copy plan immediately.
CLAW_LEGACY_DIR~/.clawOverride the base directory scanned for legacy state artifacts.
CLAW_MIGRATE_DRY_RUNfalseWhen set, computes and prints the copy plan without executing any moves.
CLAW_MIGRATE_KEEP_ORIGINALStrueWhen false, legacy originals are deleted after successful verification instead of being suffixed with .migrated.

See also:

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
└── ...