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:
parent
0f539a3d4e
commit
659993f62f
|
|
@ -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`.
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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`,
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
Loading…
Reference in New Issue