feat(core,cli): add ao migrate (V3 inventory + plan, dry-run only)

Replaces ao migrate-storage with a single ao migrate command that
inventories the AO storage tree and prints a step-by-step V3 migration
plan plus a structured JSON record.

Detects identity-system drift accumulated since PR #1466:
  - V1 bare-basename projectIds vs V2 hashed
  - doubled-prefix tmux names (ao-ao-orchestrator)
  - storageKey-prefixed legacy tmux names in metadata
  - numbered orchestrators (ao-orchestrator-1..N)
  - legacy ~/.worktrees/ paths inside session JSON
  - root-level *-observability dir leak (153 dirs on real disks)
  - stranded ~/.worktrees/ leaves
  - same-repo dual registrations
  - lingering storageKey fields in config.yaml

Execution is gated in v0.6.0: --execute and --rollback exit 1 with a
feedback notice. The intent is to collect real-world dry-run output
before any disk writes land. Execution unlocks in v0.6.1.

The ao start legacy-storage warning now points at ao migrate --dry-run.

Public API in @aoagents/ao-core: inventoryV3, planV3, formatBytes plus
their types.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
harshitsinghbhandari 2026-05-07 19:11:02 +05:30
parent 0f539a3d4e
commit 659993f62f
8 changed files with 1852 additions and 46 deletions

View File

@ -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`.

View File

@ -1,43 +0,0 @@
import type { Command } from "commander";
import chalk from "chalk";
import { migrateStorage, rollbackStorage } from "@aoagents/ao-core";
export function registerMigrateStorage(program: Command): void {
program
.command("migrate-storage")
.description(
"Migrate storage from legacy hash-based layout to projects/{projectId}/ layout",
)
.option("--dry-run", "Report what would be done without making changes")
.option("--force", "Migrate even if active tmux sessions are detected")
.option("--rollback", "Reverse a previous migration (restores .migrated directories)")
.action(
async (opts: { dryRun?: boolean; force?: boolean; rollback?: boolean }) => {
try {
if (opts.rollback) {
await rollbackStorage({
dryRun: opts.dryRun,
log: (msg) => console.log(msg),
});
} else {
const result = await migrateStorage({
dryRun: opts.dryRun,
force: opts.force,
log: (msg) => console.log(msg),
});
if (result.projects === 0 && !opts.dryRun) {
console.log(chalk.green("\nNothing to migrate — already on V2 layout."));
} else {
console.log(chalk.green("\nMigration complete."));
}
}
} catch (err) {
console.error(
chalk.red(err instanceof Error ? err.message : String(err)),
);
process.exit(1);
}
},
);
}

View File

@ -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`,
);
}

View File

@ -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 {

View File

@ -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";
@ -46,7 +46,7 @@ export function createProgram(): Command {
registerSetup(program);
registerPlugin(program);
registerProjectCommand(program);
registerMigrateStorage(program);
registerMigrate(program);
registerCompletion(program);
registerEvents(program);

View File

@ -0,0 +1,591 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { stringify as stringifyYaml } from "yaml";
import { inventoryV3, planV3 } from "../migration/v3.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createTempDir(): string {
const dir = join(
tmpdir(),
`ao-v3-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
mkdirSync(dir, { recursive: true });
return dir;
}
function writeGlobalConfig(
aoBaseDir: string,
projects: Record<string, Record<string, unknown>>,
): string {
const configPath = join(aoBaseDir, "config.yaml");
writeFileSync(
configPath,
stringifyYaml({
port: 3000,
defaults: {},
projects,
}),
"utf-8",
);
return configPath;
}
interface SessionFixture {
sessionId: string;
tmuxName?: string;
workspacePath?: string;
branch?: string;
kind?: "worker" | "orchestrator";
}
function writeSessionJson(
projectsDir: string,
projectId: string,
fixture: SessionFixture,
): void {
const sessionsDir = join(projectsDir, projectId, "sessions");
mkdirSync(sessionsDir, { recursive: true });
const meta = {
sessionId: fixture.sessionId,
kind: fixture.kind ?? "worker",
project: projectId,
tmuxName: fixture.tmuxName ?? fixture.sessionId,
branch: fixture.branch ?? `session/${fixture.sessionId}`,
workspacePath:
fixture.workspacePath ??
join(projectsDir, projectId, "worktrees", fixture.sessionId),
agent: "claude-code",
createdAt: "2026-05-07T08:43:35.402Z",
};
writeFileSync(
join(sessionsDir, `${fixture.sessionId}.json`),
JSON.stringify(meta, null, 2),
"utf-8",
);
}
function writeOrchestratorJson(
projectsDir: string,
projectId: string,
fixture: SessionFixture,
): void {
const meta = {
sessionId: fixture.sessionId,
kind: "orchestrator",
project: projectId,
tmuxName: fixture.tmuxName ?? fixture.sessionId,
branch: fixture.branch ?? `orchestrator/${fixture.sessionId}`,
runtimeHandle: {
id: fixture.tmuxName ?? fixture.sessionId,
runtimeName: "tmux",
data: {
workspacePath:
fixture.workspacePath ??
join(projectsDir, projectId, "worktrees", fixture.sessionId),
},
},
agent: "claude-code",
createdAt: "2026-04-25T17:32:56.275Z",
};
mkdirSync(join(projectsDir, projectId), { recursive: true });
writeFileSync(
join(projectsDir, projectId, "orchestrator.json"),
JSON.stringify(meta, null, 2),
"utf-8",
);
}
function writeObservabilityDir(aoBaseDir: string, hash: string): void {
const dir = join(aoBaseDir, `${hash}-observability`, "processes");
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "session-manager-12345.json"),
JSON.stringify(
{
component: "session-manager",
pid: 12345,
projectId: "some-project",
traces: [],
},
null,
2,
),
"utf-8",
);
}
// ---------------------------------------------------------------------------
// inventoryV3
// ---------------------------------------------------------------------------
describe("inventoryV3", () => {
let aoBaseDir: string;
let projectsDir: string;
beforeEach(() => {
aoBaseDir = createTempDir();
projectsDir = join(aoBaseDir, "projects");
mkdirSync(projectsDir, { recursive: true });
});
afterEach(() => {
rmSync(aoBaseDir, { recursive: true, force: true });
});
it("returns an empty inventory when aoBaseDir does not exist", async () => {
const missing = join(aoBaseDir, "missing");
const inv = await inventoryV3({ aoBaseDir: missing, skipTmux: true });
expect(inv.projects).toHaveLength(0);
expect(inv.totals.bytes).toBe(0);
expect(inv.observability.rootLevelDirCount).toBe(0);
});
it("classifies V2 hashed and V1 bare-basename projects correctly", async () => {
// V2 hashed
mkdirSync(join(projectsDir, "agent-orchestrator_a1b2c3d4e5", "sessions"), {
recursive: true,
});
// V1 bare
mkdirSync(join(projectsDir, "agent-orchestrator", "sessions"), {
recursive: true,
});
const configPath = writeGlobalConfig(aoBaseDir, {
"agent-orchestrator_a1b2c3d4e5": {
path: "/Users/x/v2/agent-orchestrator",
sessionPrefix: "ao",
repo: {
owner: "ComposioHQ",
name: "agent-orchestrator",
platform: "github",
originUrl: "https://github.com/composiohq/agent-orchestrator",
},
},
"agent-orchestrator": {
path: "/Users/x/v1/agent-orchestrator",
sessionPrefix: "ao",
storageKey: "7dc54da05c9e",
repo: {
owner: "ComposioHQ",
name: "agent-orchestrator",
platform: "github",
originUrl: "https://github.com/composiohq/agent-orchestrator",
},
},
});
const inv = await inventoryV3({
aoBaseDir,
globalConfigPath: configPath,
skipTmux: true,
});
expect(inv.projects).toHaveLength(2);
const v2 = inv.projects.find((p) => p.layout === "v2-hashed");
const v1 = inv.projects.find((p) => p.layout === "v1-bare");
expect(v2?.projectId).toBe("agent-orchestrator_a1b2c3d4e5");
expect(v1?.projectId).toBe("agent-orchestrator");
expect(v1?.rekeyTo).toMatch(/^agent-orchestrator_[0-9a-f]{10}$/);
expect(v1?.storageKeyField).toBe("7dc54da05c9e");
// Issues raised for V1
expect(v1?.issues.some((i) => i.kind === "v1-bare-basename")).toBe(true);
expect(v1?.issues.some((i) => i.kind === "storageKey-field-present")).toBe(true);
});
it("flags duplicate repos by originUrl", async () => {
mkdirSync(join(projectsDir, "agent-orchestrator", "sessions"), {
recursive: true,
});
mkdirSync(join(projectsDir, "agent-orchestrator_168566536d", "sessions"), {
recursive: true,
});
const configPath = writeGlobalConfig(aoBaseDir, {
"agent-orchestrator": {
path: "/Users/x/clones/c1/agent-orchestrator",
sessionPrefix: "ao",
repo: {
owner: "ComposioHQ",
name: "agent-orchestrator",
platform: "github",
originUrl: "https://github.com/composiohq/agent-orchestrator",
},
},
"agent-orchestrator_168566536d": {
path: "/Users/x/clones/c2/agent-orchestrator",
sessionPrefix: "ao2",
repo: {
owner: "ComposioHQ",
name: "agent-orchestrator",
platform: "github",
originUrl: "https://github.com/composiohq/agent-orchestrator",
},
},
});
const inv = await inventoryV3({
aoBaseDir,
globalConfigPath: configPath,
skipTmux: true,
});
expect(inv.duplicateRepos).toHaveLength(1);
expect(inv.duplicateRepos[0].projectIds.sort()).toEqual([
"agent-orchestrator",
"agent-orchestrator_168566536d",
]);
});
it("counts observability dir leak", async () => {
writeObservabilityDir(aoBaseDir, "0149ff87f4a5");
writeObservabilityDir(aoBaseDir, "03706227e15e");
writeObservabilityDir(aoBaseDir, "fea10426c4ba");
const inv = await inventoryV3({ aoBaseDir, skipTmux: true });
expect(inv.observability.rootLevelDirCount).toBe(3);
expect(inv.observability.bytes).toBeGreaterThan(0);
expect(inv.observability.oldestModifiedAt).toBeTypeOf("string");
});
it("detects bare hash dirs and .migrated dirs", async () => {
mkdirSync(join(aoBaseDir, "111111111114"), { recursive: true });
mkdirSync(join(aoBaseDir, "111111111114.migrated"), { recursive: true });
const inv = await inventoryV3({ aoBaseDir, skipTmux: true });
expect(inv.bareHashDirs).toEqual(["111111111114"]);
expect(inv.migratedDirs).toEqual(["111111111114.migrated"]);
});
it("flags numbered orchestrators", async () => {
const projectId = "agent-orchestrator_a1b2c3d4e5";
mkdirSync(join(projectsDir, projectId, "sessions"), { recursive: true });
writeSessionJson(projectsDir, projectId, {
sessionId: "ao-orchestrator-1",
kind: "orchestrator",
});
writeSessionJson(projectsDir, projectId, {
sessionId: "ao-orchestrator-2",
kind: "orchestrator",
});
const configPath = writeGlobalConfig(aoBaseDir, {
[projectId]: { path: "/Users/x/repo", sessionPrefix: "ao" },
});
const inv = await inventoryV3({
aoBaseDir,
globalConfigPath: configPath,
skipTmux: true,
});
const project = inv.projects[0];
expect(project.orchestratorVariants).toContain("ao-orchestrator-1");
expect(project.orchestratorVariants).toContain("ao-orchestrator-2");
expect(project.issues.filter((i) => i.kind === "numbered-orchestrator").length).toBe(2);
});
it("flags doubled-prefix tmux name in metadata", async () => {
const projectId = "agent-orchestrator_a1b2c3d4e5";
writeSessionJson(projectsDir, projectId, {
sessionId: "ao-orchestrator",
tmuxName: "ao-ao-orchestrator", // doubled
kind: "orchestrator",
});
const configPath = writeGlobalConfig(aoBaseDir, {
[projectId]: { path: "/Users/x/repo", sessionPrefix: "ao" },
});
const inv = await inventoryV3({
aoBaseDir,
globalConfigPath: configPath,
skipTmux: true,
});
const project = inv.projects[0];
expect(project.legacyTmuxNamesInMetadata).toBe(1);
expect(project.issues.some((i) => i.kind === "doubled-prefix-tmux")).toBe(true);
});
it("flags storageKey-prefixed tmux name in orchestrator.json", async () => {
const projectId = "agent-orchestrator";
writeOrchestratorJson(projectsDir, projectId, {
sessionId: "ao-orchestrator",
tmuxName: "66c66786e971-agent-orchestrator-ao-orchestrator-8",
workspacePath: "/Users/x/.worktrees/agent-orchestrator/ao-orchestrator-8",
});
const configPath = writeGlobalConfig(aoBaseDir, {
[projectId]: { path: "/Users/x/repo", sessionPrefix: "ao", storageKey: "66c66786e971" },
});
const inv = await inventoryV3({
aoBaseDir,
globalConfigPath: configPath,
skipTmux: true,
});
const project = inv.projects[0];
expect(project.liveOrchestratorTmuxName).toBe(
"66c66786e971-agent-orchestrator-ao-orchestrator-8",
);
expect(project.issues.some((i) => i.kind === "legacy-tmux-in-metadata")).toBe(true);
expect(project.issues.some((i) => i.kind === "legacy-workspace-path")).toBe(true);
});
it("flags stranded worktrees in legacy ~/.worktrees/", async () => {
const projectId = "agent-orchestrator_a1b2c3d4e5";
mkdirSync(join(projectsDir, projectId, "sessions"), { recursive: true });
const configPath = writeGlobalConfig(aoBaseDir, {
[projectId]: { path: "/Users/x/repo", sessionPrefix: "ao" },
});
// Set up legacy worktree tree
const legacyRoot = createTempDir();
mkdirSync(join(legacyRoot, "agent-orchestrator", "ao-101"), { recursive: true });
mkdirSync(join(legacyRoot, "agent-orchestrator", "ao-102"), { recursive: true });
const inv = await inventoryV3({
aoBaseDir,
globalConfigPath: configPath,
legacyWorktreeRoot: legacyRoot,
skipTmux: true,
});
expect(inv.strandedWorktrees).toHaveLength(2);
expect(inv.strandedWorktrees[0].candidateProjectId).toBe(projectId);
expect(inv.strandedWorktrees[0].candidateSessionId).toMatch(/^ao-10\d$/);
rmSync(legacyRoot, { recursive: true, force: true });
});
it("flags global config storageKey fields", async () => {
mkdirSync(join(projectsDir, "p1"), { recursive: true });
mkdirSync(join(projectsDir, "p2_a1b2c3d4e5"), { recursive: true });
const configPath = writeGlobalConfig(aoBaseDir, {
p1: { path: "/x", sessionPrefix: "p1", storageKey: "aaaaaaaaaaaa" },
p2_a1b2c3d4e5: { path: "/y", sessionPrefix: "p2", storageKey: "bbbbbbbbbbbb" },
});
const inv = await inventoryV3({
aoBaseDir,
globalConfigPath: configPath,
skipTmux: true,
});
const storageKeyIssues = inv.globalConfigIssues.filter(
(i) => i.kind === "storageKey-field-present",
);
expect(storageKeyIssues).toHaveLength(2);
});
it("flags registry entries that have no on-disk project dir", async () => {
const configPath = writeGlobalConfig(aoBaseDir, {
"missing-on-disk": { path: "/x", sessionPrefix: "m" },
});
const inv = await inventoryV3({
aoBaseDir,
globalConfigPath: configPath,
skipTmux: true,
});
const stranded = inv.globalConfigIssues.filter(
(i) => i.kind === "stranded-legacy-hash-dir",
);
expect(stranded).toHaveLength(1);
expect(stranded[0].ref).toBe("missing-on-disk");
});
});
// ---------------------------------------------------------------------------
// planV3
// ---------------------------------------------------------------------------
describe("planV3", () => {
let aoBaseDir: string;
let projectsDir: string;
beforeEach(() => {
aoBaseDir = createTempDir();
projectsDir = join(aoBaseDir, "projects");
mkdirSync(projectsDir, { recursive: true });
});
afterEach(() => {
rmSync(aoBaseDir, { recursive: true, force: true });
});
it("returns minimal plan for clean V2-only disk", async () => {
mkdirSync(join(projectsDir, "p1_a1b2c3d4e5", "sessions"), { recursive: true });
const configPath = writeGlobalConfig(aoBaseDir, {
p1_a1b2c3d4e5: { path: "/x", sessionPrefix: "p1" },
});
const inv = await inventoryV3({
aoBaseDir,
globalConfigPath: configPath,
skipTmux: true,
});
const plan = planV3(inv, "0.6.0");
expect(plan.totals.projectsToRekey).toBe(0);
expect(plan.totals.sessionsToRewrite).toBe(0);
expect(plan.totals.tmuxRenames).toBe(0);
expect(plan.totals.worktreeAdoptions).toBe(0);
expect(plan.totals.observabilityDirsToCollapse).toBe(0);
// Always includes identity.json + counter steps
expect(plan.steps.find((s) => s.id === "write-identity-json")).toBeDefined();
expect(plan.steps.find((s) => s.id === "reconcile-counter")).toBeDefined();
expect(plan.steps.find((s) => s.id === "dead-export-manifest")).toBeDefined();
// Should NOT include conditional steps when there's nothing to do
expect(plan.steps.find((s) => s.id === "rekey-v1-entries")).toBeUndefined();
expect(plan.steps.find((s) => s.id === "rename-tmux-sessions")).toBeUndefined();
expect(plan.steps.find((s) => s.id === "collapse-observability")).toBeUndefined();
});
it("emits rekey + same-repo merge steps for the user's actual disk shape", async () => {
// Simulate the user's real situation: V1 bare 'agent-orchestrator' + V2 hashed sibling
mkdirSync(join(projectsDir, "agent-orchestrator", "sessions"), { recursive: true });
mkdirSync(join(projectsDir, "agent-orchestrator_168566536d", "sessions"), {
recursive: true,
});
const configPath = writeGlobalConfig(aoBaseDir, {
"agent-orchestrator": {
path: "/Users/x/clones/clone-1/agent-orchestrator",
sessionPrefix: "ao",
storageKey: "7dc54da05c9e",
repo: {
owner: "ComposioHQ",
name: "agent-orchestrator",
platform: "github",
originUrl: "https://github.com/composiohq/agent-orchestrator",
},
},
"agent-orchestrator_168566536d": {
path: "/Users/x/clones/clone-2/agent-orchestrator",
sessionPrefix: "ao2",
repo: {
owner: "ComposioHQ",
name: "agent-orchestrator",
platform: "github",
originUrl: "https://github.com/composiohq/agent-orchestrator",
},
},
});
const inv = await inventoryV3({
aoBaseDir,
globalConfigPath: configPath,
skipTmux: true,
});
const plan = planV3(inv, "0.6.0");
// V1 entry needs re-key
expect(plan.totals.projectsToRekey).toBe(1);
const rekey = plan.steps.find((s) => s.id === "rekey-v1-entries");
expect(rekey?.count).toBe(1);
expect(rekey?.details[0]).toMatch(/^agent-orchestrator → agent-orchestrator_[0-9a-f]{10}$/);
// Same-repo merge surfaced
expect(plan.steps.find((s) => s.id === "same-repo-merge")).toBeDefined();
expect(plan.warnings.some((w) => w.includes("same-repo duplicate"))).toBe(true);
// storageKey strip step
expect(plan.totals.storageKeyFieldsToStrip).toBe(1);
expect(plan.steps.find((s) => s.id === "strip-storage-key")).toBeDefined();
});
it("includes orchestrator-normalize step when numbered orchestrators present", async () => {
const projectId = "agent-orchestrator_a1b2c3d4e5";
writeSessionJson(projectsDir, projectId, {
sessionId: "ao-orchestrator-1",
kind: "orchestrator",
});
writeSessionJson(projectsDir, projectId, {
sessionId: "ao-orchestrator-2",
kind: "orchestrator",
});
writeSessionJson(projectsDir, projectId, {
sessionId: "ao-orchestrator-3",
kind: "orchestrator",
});
const configPath = writeGlobalConfig(aoBaseDir, {
[projectId]: { path: "/x", sessionPrefix: "ao" },
});
const inv = await inventoryV3({
aoBaseDir,
globalConfigPath: configPath,
skipTmux: true,
});
const plan = planV3(inv, "0.6.0");
expect(plan.totals.orchestratorsToNormalize).toBe(3);
expect(plan.steps.find((s) => s.id === "normalize-orchestrators")).toBeDefined();
});
it("includes observability-collapse step when leak present", async () => {
writeObservabilityDir(aoBaseDir, "0149ff87f4a5");
writeObservabilityDir(aoBaseDir, "03706227e15e");
const inv = await inventoryV3({ aoBaseDir, skipTmux: true });
const plan = planV3(inv, "0.6.0");
expect(plan.totals.observabilityDirsToCollapse).toBe(2);
const step = plan.steps.find((s) => s.id === "collapse-observability");
expect(step).toBeDefined();
expect(step?.count).toBe(2);
});
it("includes adopt-worktrees step when stranded worktrees present", async () => {
const projectId = "agent-orchestrator_a1b2c3d4e5";
mkdirSync(join(projectsDir, projectId, "sessions"), { recursive: true });
const configPath = writeGlobalConfig(aoBaseDir, {
[projectId]: { path: "/x", sessionPrefix: "ao" },
});
const legacyRoot = createTempDir();
mkdirSync(join(legacyRoot, "agent-orchestrator", "ao-101"), { recursive: true });
const inv = await inventoryV3({
aoBaseDir,
globalConfigPath: configPath,
legacyWorktreeRoot: legacyRoot,
skipTmux: true,
});
const plan = planV3(inv, "0.6.0");
expect(plan.totals.worktreeAdoptions).toBe(1);
expect(plan.steps.find((s) => s.id === "adopt-stranded-worktrees")).toBeDefined();
rmSync(legacyRoot, { recursive: true, force: true });
});
it("schemaVersion + aoVersion present on the plan", async () => {
const inv = await inventoryV3({ aoBaseDir, skipTmux: true });
const plan = planV3(inv, "0.6.0");
expect(plan.schemaVersion).toBe(3);
expect(plan.aoVersion).toBe("0.6.0");
expect(plan.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(plan.inventory.schemaVersion).toBe(3);
});
});

View File

@ -360,6 +360,7 @@ export {
} from "./portfolio-routing.js";
// Storage V2 migration — one-time converter from hash-based to projectId-based layout
// (Internal helpers; the V1→V3 path is now driven by ao migrate.)
export {
migrateStorage,
rollbackStorage,
@ -373,6 +374,21 @@ export type {
HashDirEntry,
} from "./migration/storage-v2.js";
// Storage V3 migration — inventory + plan generator (dry-run only in v0.6.0)
export { inventoryV3, planV3, formatBytes } from "./migration/v3.js";
export type {
V3Inventory,
V3Plan,
V3Step,
V3Issue,
V3IssueKind,
V3ProjectInventory,
V3StrandedWorktree,
V3LiveTmuxSession,
V3DuplicateRepo,
InventoryOptions as V3InventoryOptions,
} from "./migration/v3.js";
export { atomicWriteFileSync } from "./atomic-write.js";
// Activity event logging — structured diagnostic event trail

File diff suppressed because it is too large Load Diff