Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/ao-migrate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@aoagents/ao-cli": minor
---

Add `ao migrate`: an offline command (run with the rewrite daemon stopped) that
ports the legacy flat-file project registry and each project's single
non-terminated orchestrator session into the rewrite's SQLite database, creating
the DB from vendored goose migrations (pinned to ReverbCode @ 43ae7eb) when
absent. Relocates claude-code orchestrator transcripts so they resume with
context. Idempotent, with `--dry-run` and `--json` for the `ao update` cutover
contract (locked exit codes + summary). Workers are not migrated; they respawn
fresh in the rewrite. Refs #2129.
143 changes: 143 additions & 0 deletions packages/cli/__tests__/commands/migrate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { LoadedConfig, ProjectConfig } from "@aoagents/ao-core";
import {
runMigrate,
resolveDataDir,
resolveDbPath,
type ProjectRowDeps,
} from "../../src/commands/migrate.js";
import { loadBetterSqlite3 } from "../../src/lib/migrate-db.js";

let sqlite3Available = true;
try {
loadBetterSqlite3();
} catch {
sqlite3Available = false;
}

function project(overrides: Partial<ProjectConfig> = {}): ProjectConfig {
return { name: "Project", path: "/repos/p", defaultBranch: "main", ...overrides } as ProjectConfig;
}

function loaded(
projects: Record<string, ProjectConfig>,
degraded: LoadedConfig["degradedProjects"] = {},
): LoadedConfig {
return { projects, degradedProjects: degraded } as unknown as LoadedConfig;
}

// Inject all environment lookups so the test needs neither git nor a registry.
const deps: Partial<ProjectRowDeps> = {
repoOriginUrl: () => "https://example.com/repo.git",
registeredAt: () => "2026-01-02T03:04:05.000Z",
configFileMtime: () => null,
};

const NOW = "2026-06-18T00:00:00.000Z";

describe("resolveDataDir", () => {
const saved = process.env.AO_DATA_DIR;
afterEach(() => {
if (saved === undefined) delete process.env.AO_DATA_DIR;
else process.env.AO_DATA_DIR = saved;
});

it("honors a non-empty AO_DATA_DIR", () => {
process.env.AO_DATA_DIR = "/custom/dir";
expect(resolveDataDir()).toBe("/custom/dir");
expect(resolveDbPath(resolveDataDir())).toBe("/custom/dir/ao.db");
});
it("falls back to ~/.ao/data when unset", () => {
delete process.env.AO_DATA_DIR;
expect(resolveDataDir().endsWith(join(".ao", "data"))).toBe(true);
});
});

describe.skipIf(!sqlite3Available)("runMigrate", () => {
let dataDir: string;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), "ao-migrate-cmd-"));
});
afterEach(() => {
rmSync(dataDir, { recursive: true, force: true });
});

// Unique ids so the orchestrator reader (real ~/.agent-orchestrator) finds none.
const ids = () => ({
[`ao-migrate-test-${process.pid}-a`]: project({ path: "/repos/a", name: "A" }),
[`ao-migrate-test-${process.pid}-b`]: project({ path: "/repos/b", name: "B" }),
});

it("dry run computes the plan and writes nothing", async () => {
const result = await runMigrate({
dryRun: true,
config: loaded(ids(), {
broken: { projectId: "broken", path: "/x", resolveError: "gone" },
}),
dataDir,
deps,
now: NOW,
});

expect(result.dryRun).toBe(true);
expect(result.exitCode).toBe(0);
expect(result.summary.dbCreated).toBe(true);
expect(result.summary.schemaVersion).toBe(12);
expect(result.summary.projects).toEqual({ created: 2, skipped: 1, failed: 0 });
expect(result.summary.orchestrators).toMatchObject({ created: 0, skipped: 0, failed: 0 });
// No DB written.
expect(existsSync(resolveDbPath(dataDir))).toBe(false);
});

it("creates the DB and inserts projects on a real run", async () => {
const result = await runMigrate({ config: loaded(ids()), dataDir, deps, now: NOW });

expect(result.exitCode).toBe(0);
expect(result.summary.dbCreated).toBe(true);
expect(result.summary.schemaVersion).toBe(12);
expect(result.summary.projects).toEqual({ created: 2, skipped: 0, failed: 0 });
expect(existsSync(resolveDbPath(dataDir))).toBe(true);
});

it("counts degraded and invalid-id projects as skipped, never inserted", async () => {
const result = await runMigrate({
config: loaded(
{ ...ids(), "bad/id": project({ path: "/repos/bad" }) },
{ broken: { projectId: "broken", path: "/x", resolveError: "gone" } },
),
dataDir,
deps,
now: NOW,
});
// 2 valid created; degraded + invalid-id => 2 skipped.
expect(result.summary.projects).toEqual({ created: 2, skipped: 2, failed: 0 });
expect(result.notes.some((n) => n.id === "broken")).toBe(true);
expect(result.notes.some((n) => n.id === "bad/id")).toBe(true);
});

it("is idempotent: a re-run inserts nothing and counts ON CONFLICT no-ops as skipped", async () => {
const config = loaded(ids());
await runMigrate({ config, dataDir, deps, now: NOW });
const second = await runMigrate({ config, dataDir, deps, now: NOW });

expect(second.summary.dbCreated).toBe(false);
expect(second.summary.projects).toEqual({ created: 0, skipped: 2, failed: 0 });
expect(second.exitCode).toBe(0);
});

it("refuses (exit 1) when the DB schema is older than vendored", async () => {
// First create the DB, then strip versions to simulate an older schema.
await runMigrate({ config: loaded(ids()), dataDir, deps, now: NOW });
const Database = loadBetterSqlite3();
const db = new Database(resolveDbPath(dataDir));
db.prepare("DELETE FROM goose_db_version WHERE version_id >= 12").run();
db.close();

const result = await runMigrate({ config: loaded(ids()), dataDir, deps, now: NOW });
expect(result.exitCode).toBe(1);
expect(result.refusal?.code).toBe("SCHEMA_TOO_OLD");
});
});
88 changes: 88 additions & 0 deletions packages/cli/__tests__/lib/migrate-claude.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { toClaudeProjectPath } from "@aoagents/ao-plugin-agent-claude-code";
import { planTranscriptCopy, relocateTranscript } from "../../src/lib/migrate-claude.js";

const UUID = "abcdabcd-1111-2222-3333-444455556666";

describe("planTranscriptCopy", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "ao-migrate-claude-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});

it("computes the source slug from the worktree and the dest from the orchestrator template", async () => {
const dataDir = join(dir, "data");
const worktree = join(dir, "legacy-worktree");
mkdirSync(worktree, { recursive: true }); // exists -> realpath resolves it
const claudeProjectsDir = join(dir, "claude-projects");

const plan = await planTranscriptCopy({
dataDir,
projectId: "app",
prefix: "app",
worktree,
uuid: UUID,
claudeProjectsDir,
});

// Destination uses the LITERAL orchestrator-worktree template (no realpath).
const destTemplate = join(dataDir, "worktrees", "app", "orchestrator", "app-orchestrator");
expect(plan.destPath).toBe(
join(claudeProjectsDir, toClaudeProjectPath(destTemplate), `${UUID}.jsonl`),
);
expect(plan.sourcePath.endsWith(`${UUID}.jsonl`)).toBe(true);
expect(plan.sourcePath.startsWith(claudeProjectsDir)).toBe(true);
});
});

describe("relocateTranscript", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "ao-migrate-claude-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});

function plan(): { sourcePath: string; destPath: string; projectId: string; uuid: string } {
return {
projectId: "app",
uuid: UUID,
sourcePath: join(dir, "src", `${UUID}.jsonl`),
destPath: join(dir, "dest", "nested", `${UUID}.jsonl`),
};
}

it("copies the transcript, creating the destination dir", () => {
const p = plan();
mkdirSync(join(dir, "src"), { recursive: true });
writeFileSync(p.sourcePath, '{"type":"summary"}\n');

expect(relocateTranscript(p)).toBe("copied");
expect(existsSync(p.destPath)).toBe(true);
expect(readFileSync(p.destPath, "utf-8")).toBe('{"type":"summary"}\n');
});

it("is a no-op when the destination already exists", () => {
const p = plan();
mkdirSync(join(dir, "src"), { recursive: true });
mkdirSync(join(dir, "dest", "nested"), { recursive: true });
writeFileSync(p.sourcePath, "new\n");
writeFileSync(p.destPath, "existing\n");

expect(relocateTranscript(p)).toBe("already-present");
expect(readFileSync(p.destPath, "utf-8")).toBe("existing\n"); // not clobbered
});

it("skips silently when the source is missing", () => {
const p = plan();
expect(relocateTranscript(p)).toBe("source-missing");
expect(existsSync(p.destPath)).toBe(false);
});
});
Loading
Loading