Skip to content
Draft
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-v3-dry-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@aoagents/ao-core": minor
"@aoagents/ao-cli": minor
---

Add `ao migrate` (replaces `ao migrate-storage`). Inventories the AO storage tree, detects identity-system drift (V1 bare-basename projectIds, doubled-prefix and storageKey-prefixed tmux names, numbered orchestrators, legacy workspacePaths, observability-dir leaks, stranded `~/.worktrees/` leaves, same-repo duplicate registrations, lingering `storageKey` schema fields) and prints a step-by-step V3 plan plus a structured JSON record (`--json [--output <path>]`).

Execution is gated in this release: `ao migrate --execute` and `ao migrate --rollback` print a feedback notice and exit 1. The intent is to collect dry-run output from real users before any disk writes land.

`ao migrate-storage` is removed from the CLI registry; the V1→V2 helpers stay internal in `@aoagents/ao-core` so the new `ao migrate --dry-run` can detect and report on V1 hash directories. The `ao start` legacy-storage warning now points at `ao migrate --dry-run`.

Public API additions in `@aoagents/ao-core`: `inventoryV3`, `planV3`, `formatBytes`, plus types `V3Inventory`, `V3Plan`, `V3Step`, `V3Issue`, `V3IssueKind`, `V3ProjectInventory`, `V3StrandedWorktree`, `V3LiveTmuxSession`, `V3DuplicateRepo`, `V3InventoryOptions`.
43 changes: 0 additions & 43 deletions packages/cli/src/commands/migrate-storage.ts

This file was deleted.

197 changes: 197 additions & 0 deletions packages/cli/src/commands/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import type { Command } from "commander";
import chalk from "chalk";
import { writeFileSync } from "node:fs";

import {
formatBytes,
getAoBaseDir,
getGlobalConfigPath,
inventoryV3,
planV3,
type V3Plan,
} from "@aoagents/ao-core";
import { homedir } from "node:os";
import { join } from "node:path";

import { getCliVersion } from "../options/version.js";

interface MigrateOptions {
dryRun?: boolean;
json?: boolean;
output?: string;
execute?: boolean;
rollback?: boolean;
}

const FEEDBACK_ISSUE_URL =
"https://github.com/ComposioHQ/agent-orchestrator/issues/new?title=ao+migrate+dry-run+output";

const GATED_MESSAGE = `
${chalk.bold.red("ao migrate execution is gated in v0.6.0.")}

This release ships ${chalk.cyan("--dry-run")} only so we can review real-world plan output
before the migration touches any disk on user machines.

Please share dry-run output:
1. ${chalk.dim("ao migrate --json --output ~/ao-migrate-plan.json")}
2. Open an issue with that file attached: ${FEEDBACK_ISSUE_URL}

Execution unlocks in v0.6.1.
`;

export function registerMigrate(program: Command): void {
program
.command("migrate")
.description(
"Inventory + plan storage migration to V3 (one-format identity, one prefix allocator, observability inside projects). Dry-run only in v0.6.0.",
)
.option("--dry-run", "Inventory + plan only (default)", true)
.option("--json", "Emit V3Plan as JSON to stdout")
.option("--output <path>", "Write the V3Plan record to a file instead of stdout")
.option("--execute", "[gated] Apply the plan to disk")
.option("--rollback", "[gated] Reverse a previous migration")
.action(async (opts: MigrateOptions) => {
if (opts.execute || opts.rollback) {
process.stderr.write(GATED_MESSAGE + "\n");
process.exit(1);
}

const aoBaseDir = getAoBaseDir();
const globalConfigPath = getGlobalConfigPath();
const legacyWorktreeRoot = join(homedir(), ".worktrees");

const inventory = await inventoryV3({
aoBaseDir,
globalConfigPath,
legacyWorktreeRoot,
});

const plan = planV3(inventory, getCliVersion());

if (opts.json) {
const json = JSON.stringify(plan, null, 2);
if (opts.output) {
writeFileSync(opts.output, json + "\n", "utf-8");
process.stdout.write(`Plan written to ${opts.output}\n`);
} else {
process.stdout.write(json + "\n");
}
return;
}

printHumanPlan(plan);

if (opts.output) {
writeFileSync(opts.output, JSON.stringify(plan, null, 2) + "\n", "utf-8");
process.stdout.write(
`\n${chalk.dim("Full JSON record written to:")} ${opts.output}\n`,
);
}
});
}

function printHumanPlan(plan: V3Plan): void {
const out = process.stdout;

out.write(`\n${chalk.bold("ao migrate v3")} ${chalk.dim(`(dry-run · ${plan.aoVersion})`)}\n`);
out.write(`${chalk.dim("Scanned:")} ${plan.inventory.aoBaseDir}\n`);
out.write(`${chalk.dim("At:")} ${plan.generatedAt}\n\n`);

// Inventory summary
out.write(`${chalk.bold("Inventory")}\n`);
out.write(` Projects: ${plan.inventory.projects.length}\n`);
const v1 = plan.inventory.projects.filter((p) => p.layout === "v1-bare").length;
const v2 = plan.inventory.projects.filter((p) => p.layout === "v2-hashed").length;
out.write(` V1 bare-basename: ${v1}\n`);
out.write(` V2 hashed: ${v2}\n`);
out.write(` Sessions: ${plan.inventory.totals.sessions}\n`);
out.write(` Worktrees: ${plan.inventory.totals.worktrees}\n`);
out.write(
` Observability dirs: ${plan.inventory.observability.rootLevelDirCount}` +
` (${formatBytes(plan.inventory.observability.bytes)})\n`,
);
out.write(` Stranded worktrees: ${plan.inventory.strandedWorktrees.length}\n`);
out.write(` Bare hash dirs: ${plan.inventory.bareHashDirs.length}\n`);
out.write(` .migrated dirs: ${plan.inventory.migratedDirs.length}\n`);
out.write(` Live tmux sessions: ${plan.inventory.liveTmuxSessions.length}\n`);
out.write(` Same-repo duplicates: ${plan.inventory.duplicateRepos.length}\n`);
out.write(` V1 hash dirs (legacy): ${plan.inventory.v1HashDirs.length}\n`);
out.write(` Total bytes: ${formatBytes(plan.inventory.totals.bytes)}\n\n`);

// Issues by project
const projectsWithIssues = plan.inventory.projects.filter((p) => p.issues.length > 0);
if (projectsWithIssues.length > 0) {
out.write(`${chalk.bold("Per-project issues")}\n`);
for (const p of projectsWithIssues) {
out.write(` ${chalk.cyan(p.projectId)} ${chalk.dim(`[${p.layout}]`)}\n`);
for (const issue of p.issues) {
out.write(` ${chalk.yellow("•")} ${issue.detail}\n`);
}
}
out.write("\n");
}

// Global config issues
if (plan.inventory.globalConfigIssues.length > 0) {
out.write(`${chalk.bold("Global config issues")}\n`);
for (const issue of plan.inventory.globalConfigIssues) {
out.write(` ${chalk.yellow("•")} ${issue.detail}\n`);
}
out.write("\n");
}

// Plan steps
out.write(`${chalk.bold("Plan")} ${chalk.dim("(would execute these in order if unlocked)")}\n`);
if (plan.steps.length === 0) {
out.write(
` ${chalk.green("Nothing to do — disk is already V3-compliant.")}\n\n`,
);
} else {
for (const step of plan.steps) {
out.write(` ${chalk.bold(step.order + ".")} ${step.title} ${chalk.dim(`(${step.count})`)}\n`);
out.write(` ${chalk.dim(step.description)}\n`);
if (step.details.length > 0 && step.details.length <= 8) {
for (const detail of step.details) {
out.write(` - ${detail}\n`);
}
} else if (step.details.length > 8) {
for (const detail of step.details.slice(0, 6)) {
out.write(` - ${detail}\n`);
}
out.write(` ${chalk.dim(`… ${step.details.length - 6} more`)}\n`);
}
}
out.write("\n");
}

// Totals
out.write(`${chalk.bold("Totals")}\n`);
out.write(` Projects to re-key: ${plan.totals.projectsToRekey}\n`);
out.write(` Sessions to rewrite: ${plan.totals.sessionsToRewrite}\n`);
out.write(` Tmux renames: ${plan.totals.tmuxRenames}\n`);
out.write(` Worktree adoptions: ${plan.totals.worktreeAdoptions}\n`);
out.write(` Orchestrators to normalize: ${plan.totals.orchestratorsToNormalize}\n`);
out.write(` Observability dirs to GC: ${plan.totals.observabilityDirsToCollapse}\n`);
out.write(` Bare hash dirs to remove: ${plan.totals.bareHashDirsToRemove}\n`);
out.write(` storageKey fields to strip: ${plan.totals.storageKeyFieldsToStrip}\n`);
out.write(
` Estimated bytes freed: ~${formatBytes(plan.totals.estimatedBytesFreed)}\n\n`,
);

// Warnings
if (plan.warnings.length > 0) {
out.write(`${chalk.bold.yellow("Warnings")}\n`);
for (const w of plan.warnings) {
out.write(` ${chalk.yellow("⚠")} ${w}\n`);
}
out.write("\n");
}

// Footer
out.write(`${chalk.dim("─".repeat(60))}\n`);
out.write(`${chalk.bold("Execution is gated in v0.6.0.")}\n`);
out.write(
`Share this plan at: ${chalk.cyan(FEEDBACK_ISSUE_URL)}\n` +
`${chalk.dim("Execution unlocks in v0.6.1.")}\n\n`,
);
}
2 changes: 1 addition & 1 deletion packages/cli/src/lib/startup-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export function warnAboutLegacyStorage(): void {
chalk.yellow(
`\n ⚠ Found ${nonEmptyDirCount} legacy storage director${nonEmptyDirCount === 1 ? "y" : "ies"} that need${nonEmptyDirCount === 1 ? "s" : ""} migration.\n` +
` Sessions stored in the old format won't appear until migrated.\n` +
` Run ${chalk.bold("ao migrate-storage")} to upgrade (use ${chalk.bold("--dry-run")} to preview).\n`,
` Run ${chalk.bold("ao migrate --dry-run")} to preview the V3 plan. Execution unlocks in v0.6.1.\n`,
),
);
} catch {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { registerUpdate } from "./commands/update.js";
import { registerSetup } from "./commands/setup.js";
import { registerPlugin } from "./commands/plugin.js";
import { registerProjectCommand } from "./commands/project.js";
import { registerMigrateStorage } from "./commands/migrate-storage.js";
import { registerMigrate } from "./commands/migrate.js";
import { registerCompletion } from "./commands/completion.js";
import { registerEvents } from "./commands/events.js";
import { getConfigInstruction } from "./lib/config-instruction.js";
Expand Down Expand Up @@ -46,7 +46,7 @@ export function createProgram(): Command {
registerSetup(program);
registerPlugin(program);
registerProjectCommand(program);
registerMigrateStorage(program);
registerMigrate(program);
registerCompletion(program);
registerEvents(program);

Expand Down
Loading
Loading