From 976f663b0c11dfdcecaf89d6fbebb554af5dbbb9 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 18 Jun 2026 00:50:30 +0530 Subject: [PATCH 1/4] feat(cli): cutover-aware ao update (WIP, no tests yet) Refs #2129 --- packages/cli/src/commands/update.ts | 256 ++++++++++++++++++++++++++- packages/cli/src/lib/update-check.ts | 69 ++++++++ 2 files changed, 322 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index b181d43701..ea70135aac 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -5,6 +5,7 @@ import chalk from "chalk"; import { getGlobalConfigPath, isCanonicalGlobalConfigPath, + isOrchestratorSession, isWindows, loadConfig, loadGlobalConfig, @@ -16,9 +17,12 @@ import { checkForUpdate, detectInstallMethod, getCurrentVersion, + getCutoverInstallCommand, getUpdateCommand, invalidateCache, + isLegacyVersion, readCachedUpdateInfo, + resolveCutoverTarget, resolveUpdateChannel, type InstallMethod, } from "../lib/update-check.js"; @@ -107,6 +111,21 @@ export function registerUpdate(program: Command): void { process.exit(1); } + // Cutover branch: when the rewrite is published under the npm `next` + // dist-tag and this install is still legacy, `ao update` migrates the + // user's data and installs the rewrite instead of running the normal + // channel update. No cutover target (the common case) leaves every + // existing handler below untouched. + const cutoverTarget = await resolveCutoverTarget(); + if ( + cutoverTarget && + isLegacyVersion(getCurrentVersion()) && + cutoverTarget !== getCurrentVersion() + ) { + await handleCutover(method, cutoverTarget); + return; + } + switch (method) { case "git": await handleGitUpdate(opts); @@ -133,7 +152,13 @@ export function registerUpdate(program: Command): void { async function handleCheck(): Promise { const info = await checkForUpdate({ force: true }); - console.log(JSON.stringify(info, null, 2)); + const cutoverTarget = await resolveCutoverTarget(); + const currentVersion = getCurrentVersion(); + const cutoverAvailable = + cutoverTarget !== null && + isLegacyVersion(currentVersion) && + cutoverTarget !== currentVersion; + console.log(JSON.stringify({ ...info, cutoverAvailable, cutoverTarget }, null, 2)); } // --------------------------------------------------------------------------- @@ -236,6 +261,17 @@ async function pauseAoForUpdate(plan: UpdateLifecyclePlan): Promise { console.log(chalk.dim("\nAO is running; it will be restarted after the update.")); } + await stopAoAndVerifyDown(plan); + return plan.runningBeforeUpdate; +} + +/** + * Run `ao stop --yes` and confirm AO is actually down afterwards. Shared by the + * normal-update pause and the cutover stop. Aborts the process (non-zero) if the + * stop command fails, or if AO still appears active after it — installing on top + * of a live daemon would clobber session state mid-flight. + */ +async function stopAoAndVerifyDown(plan: UpdateLifecyclePlan): Promise { const stopExit = await runAoLifecycleCommand(["stop", "--yes"], { configPath: plan.configPath, }); @@ -281,8 +317,6 @@ async function pauseAoForUpdate(plan: UpdateLifecyclePlan): Promise { console.error(chalk.dim("Run `ao stop` and retry `ao update` after AO is fully stopped.")); process.exit(1); } - - return plan.runningBeforeUpdate; } async function restartAoAfterUpdate( @@ -334,6 +368,222 @@ function runAoLifecycleCommand( }); } +// --------------------------------------------------------------------------- +// Cutover (legacy → rewrite bridge, 0.9.6) +// --------------------------------------------------------------------------- + +/** + * The migrate command (issue #2129) is built separately; `ao update` only + * orchestrates it via this contract. Exit `0` means success or an idempotent + * all-already-present re-run; non-zero means a refusal or hard error. + */ +interface MigrationSummary { + dbCreated: boolean; + schemaVersion: number; + projects: { created: number; skipped: number; failed: number }; + orchestrators: { + created: number; + skipped: number; + failed: number; + relocatedTranscripts: number; + }; +} + +interface MigrationResult { + exitCode: number; + summary: MigrationSummary | null; + output: string; +} + +/** + * Invoke `ao migrate --json` and capture its exit code + JSON summary. + * + * Migration runs BEFORE the install because the migrate command is legacy-side + * and disappears once the rewrite overwrites the binary. We spawn the sibling + * `ao migrate` command (same package on PATH) and parse its `--json` summary + * from stdout — the contract is documented on {@link MigrationSummary}. + */ +async function runMigration(): Promise { + const result = await runCommandCapture("ao", ["migrate", "--json"], { echo: true }); + return { + exitCode: result.exitCode, + summary: parseMigrationSummary(result.output), + output: result.output, + }; +} + +function parseMigrationSummary(output: string): MigrationSummary | null { + const match = output.match(/\{[\s\S]*\}/); + if (!match) return null; + try { + return JSON.parse(match[0]) as MigrationSummary; + } catch { + return null; + } +} + +/** + * The orchestrator's own state never blocks a cutover — it gets migrated. Only + * active worker sessions block. Reuses the core identity check (metadata role) + * and falls back to the id suffix the spec mandates. + */ +function isCutoverOrchestrator(session: Session): boolean { + return isOrchestratorSession(session) || session.id.endsWith("-orchestrator"); +} + +/** Stop the daemon for a cutover. Like `pauseAoForUpdate`, but never restarts. */ +async function stopAoForCutover(plan: UpdateLifecyclePlan): Promise { + const shouldStop = plan.runningBeforeUpdate || plan.activeSessions.length > 0; + if (!shouldStop) return; + console.log(chalk.dim("\nStopping AO before the cutover...")); + await stopAoAndVerifyDown(plan); +} + +/** + * Perform the legacy → rewrite cutover: guard on busy workers, confirm, stop AO, + * migrate, then install the rewrite at `target`. The step order is mandatory — + * migration must complete before the install replaces the legacy binary, and + * the legacy daemon must NOT be restarted afterward. + */ +async function handleCutover(method: InstallMethod, target: string): Promise { + const previousVersion = getCurrentVersion(); + + // 1. Worker-busy guard. Active workers block the cutover; we refuse and stop + // nothing. The orchestrator's own state never blocks — it gets migrated. + const plan = await getUpdateLifecyclePlan(); + const activeWorkers = plan.activeSessions.filter((s) => !isCutoverOrchestrator(s)); + if (activeWorkers.length > 0) { + const noun = activeWorkers.length === 1 ? "worker" : "workers"; + console.error( + chalk.red( + `\nCannot cut over to the rewrite while ${activeWorkers.length} active ${noun} ${ + activeWorkers.length === 1 ? "is" : "are" + } running.`, + ), + ); + for (const s of activeWorkers.slice(0, 5)) { + console.error(chalk.dim(` • ${s.id} (${s.status})`)); + } + if (activeWorkers.length > 5) { + console.error(chalk.dim(` … and ${activeWorkers.length - 5} more`)); + } + console.error( + chalk.dim("Wait for these sessions to finish (or stop them) and retry `ao update`."), + ); + process.exit(1); + } + + // 2. Confirmation / non-interactive gate. The cutover is irreversible and must + // never be auto-confirmed by a background dashboard spawn. + if (isApiInvoked()) { + console.error( + chalk.red( + "\nThis is a major irreversible update; run `ao update` in a terminal.", + ), + ); + process.exit(1); + } else if (isTTY()) { + console.log(chalk.yellow(`\nA new version of AO (${target}) is ready.`)); + console.log( + chalk.dim( + "This is a major, irreversible update: your data will be migrated to the new format and the legacy CLI will be replaced.", + ), + ); + const confirmed = await promptConfirm(`Migrate your data and install ${target}?`, false); + if (!confirmed) { + console.log(chalk.dim("Cutover cancelled. You are still on the current version.")); + return; + } + } else { + // Piped, non-TTY, not api-invoked: print the manual command and return + // without installing, mirroring the existing non-TTY behavior. + const cmd = getCutoverInstallCommand(method, target); + console.log(`A new version of AO (${target}) is ready. Run \`ao update\` in a terminal,`); + if (cmd) console.log(`or run: ${chalk.cyan(cmd)} (after \`ao migrate\`).`); + return; + } + + // 3. Stop the daemon (no restore). + await stopAoForCutover(plan); + + // 4. Run migration. Must succeed before we replace the legacy binary. + console.log(chalk.dim("\nMigrating your data to the new format...")); + const migration = await runMigration(); + if (migration.exitCode !== 0) { + console.error( + chalk.red(`\nMigration failed (exit ${migration.exitCode}). AO was NOT updated.`), + ); + console.error( + chalk.yellow( + "You are still on the legacy version. Resolve the migration error above and retry `ao update`.", + ), + ); + process.exit(migration.exitCode); + } + + // 5. Install the rewrite at the target. + const cmd = getCutoverInstallCommand(method, target); + if (cmd === null) { + printCutoverManualInstall(method, target); + return; + } + const installResult = await runNpmInstall(cmd); + if (installResult.exitCode !== 0) { + printInstallFailure({ + method, + command: cmd, + channel: resolveUpdateChannel(), + currentVersion: previousVersion, + exitCode: installResult.exitCode, + output: installResult.output, + }); + process.exit(1); + } + + // 6. Verify. + const verification = await verifyInstalledVersion(target, previousVersion); + if (!verification.ok) { + console.error(chalk.red(`\nAO was not verified after install.`)); + console.error(chalk.yellow(verification.message)); + console.error(chalk.dim(`Expected: ${target}`)); + console.error(chalk.dim(`Current before update: ${previousVersion}`)); + process.exit(1); + } + + // 7. Finish. Do NOT restart — the legacy daemon must not come back up. + invalidateCache(); + console.log( + chalk.green(`\nUpdated to ${verification.actualVersion}. Run \`ao start\` to launch the new version.`), + ); +} + +/** + * Print method-specific manual-install instructions when the cutover cannot + * auto-install (homebrew/git/unknown). Migration has already run, so this is a + * clean stopping point — the user finishes the install by hand. + */ +function printCutoverManualInstall(method: InstallMethod, target: string): void { + console.log( + chalk.green("\nYour data has been migrated. Finish the update by installing the new version:"), + ); + if (method === "homebrew") { + console.log(` ${chalk.cyan("brew upgrade ao")}`); + console.log( + chalk.dim(" (AO does not auto-install for brew installs — it would clobber brew's symlinks.)"), + ); + } else if (method === "git") { + console.log( + chalk.dim( + " This is a git/source install. Pull the rewrite branch and rebuild, or install the package directly:", + ), + ); + console.log(` ${chalk.cyan(`npm install -g @aoagents/ao@${target}`)}`); + } else { + console.log(` ${chalk.cyan(`npm install -g @aoagents/ao@${target}`)}`); + } + console.log(chalk.dim("\nThen run `ao start` to launch the new version.")); +} + // --------------------------------------------------------------------------- // git install // --------------------------------------------------------------------------- diff --git a/packages/cli/src/lib/update-check.ts b/packages/cli/src/lib/update-check.ts index fa0bd2df4e..44f5f01fb2 100644 --- a/packages/cli/src/lib/update-check.ts +++ b/packages/cli/src/lib/update-check.ts @@ -281,6 +281,48 @@ export function isManualOnlyInstall(method: InstallMethod): boolean { return method === "homebrew"; } +// --------------------------------------------------------------------------- +// Cutover (legacy → rewrite bridge, 0.9.6) +// --------------------------------------------------------------------------- + +/** + * True when the installed version is pre-rewrite (major.minor < 0.10). + * + * The cutover only applies to legacy installs. A user already on the rewrite + * (0.10+, or any 1.x) must never be pushed back through the migration path, so + * the cutover branch gates on this. + */ +export function isLegacyVersion(version: string): boolean { + const match = version.match(/^(\d+)\.(\d+)/); + if (!match) return false; + const major = Number(match[1]); + const minor = Number(match[2]); + return major === 0 && minor < 10; +} + +/** + * Build the exact-pinned install command for the cutover target. + * + * Unlike `getUpdateCommand` (which emits `@latest`/`@nightly`), the cutover must + * install a precise version. Returns `null` for methods AO cannot safely + * auto-install — Homebrew owns its symlinks, and git/unknown have no + * package-manager path — so the caller prints manual instructions instead. + */ +export function getCutoverInstallCommand(method: InstallMethod, version: string): string | null { + switch (method) { + case "npm-global": + return `npm install -g @aoagents/ao@${version}`; + case "pnpm-global": + return `pnpm add -g @aoagents/ao@${version}`; + case "bun-global": + return `bun add -g @aoagents/ao@${version}`; + case "homebrew": + case "git": + case "unknown": + return null; + } +} + export function isOutdatedForChannel( currentVersion: string, latestVersion: string, @@ -463,6 +505,33 @@ export async function fetchLatestVersion( } } +/** + * Resolve the version the cutover bridge should install, or `null` if there is + * no cutover target. + * + * `AO_CUTOVER_VERSION` wins (staged rollout / emergency override). Otherwise the + * rewrite is published under the npm `next` dist-tag; return that when present. + * Any network/parse failure is treated as "no cutover" so `ao update` falls back + * to its normal flow and never hard-fails on a registry hiccup — same defensive + * posture as `fetchLatestVersion`. + */ +export async function resolveCutoverTarget(): Promise { + const override = process.env["AO_CUTOVER_VERSION"]; + if (override) return override; + try { + const response = await fetch(REGISTRY_PACKAGE_URL, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { Accept: "application/json" }, + }); + if (!response.ok) return null; + const data = (await response.json()) as { "dist-tags"?: Record }; + const next = data["dist-tags"]?.["next"]; + return typeof next === "string" ? next : null; + } catch { + return null; + } +} + // --------------------------------------------------------------------------- // Orchestrator // --------------------------------------------------------------------------- From cbb714aad9c6584e99516c58b01d7bedaf138c1f Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 18 Jun 2026 00:54:42 +0530 Subject: [PATCH 2/4] test(cli): cover cutover-aware ao update Refs #2129 --- .../cli/__tests__/commands/update.test.ts | 314 +++++++++++++++++- .../cli/__tests__/lib/update-check.test.ts | 88 +++++ 2 files changed, 398 insertions(+), 4 deletions(-) diff --git a/packages/cli/__tests__/commands/update.test.ts b/packages/cli/__tests__/commands/update.test.ts index a567dc22ec..639508c605 100644 --- a/packages/cli/__tests__/commands/update.test.ts +++ b/packages/cli/__tests__/commands/update.test.ts @@ -37,11 +37,16 @@ const { }), })); -const { mockResolveUpdateChannel, mockReadCachedUpdateInfo } = vi.hoisted(() => ({ - mockResolveUpdateChannel: vi.fn(() => "manual" as "stable" | "nightly" | "manual"), - mockReadCachedUpdateInfo: vi.fn<() => { channel?: string } | null>(() => null), -})); +const { mockResolveUpdateChannel, mockReadCachedUpdateInfo, mockResolveCutoverTarget } = vi.hoisted( + () => ({ + mockResolveUpdateChannel: vi.fn(() => "manual" as "stable" | "nightly" | "manual"), + mockReadCachedUpdateInfo: vi.fn<() => { channel?: string } | null>(() => null), + mockResolveCutoverTarget: vi.fn<() => Promise>(async () => null), + }), +); +// isLegacyVersion and getCutoverInstallCommand are pure — mirror the real impl +// so cutover gating and install-command selection behave realistically. vi.mock("../../src/lib/update-check.js", () => ({ detectInstallMethod: () => mockDetectInstallMethod(), checkForUpdate: (...args: unknown[]) => mockCheckForUpdate(...args), @@ -51,6 +56,17 @@ vi.mock("../../src/lib/update-check.js", () => ({ resolveUpdateChannel: () => mockResolveUpdateChannel(), readCachedUpdateInfo: (...args: unknown[]) => mockReadCachedUpdateInfo(...args), isManualOnlyInstall: (m: string) => m === "homebrew", + resolveCutoverTarget: () => mockResolveCutoverTarget(), + isLegacyVersion: (v: string) => { + const m = v.match(/^(\d+)\.(\d+)/); + return m ? Number(m[1]) === 0 && Number(m[2]) < 10 : false; + }, + getCutoverInstallCommand: (method: string, version: string) => { + if (method === "npm-global") return `npm install -g @aoagents/ao@${version}`; + if (method === "pnpm-global") return `pnpm add -g @aoagents/ao@${version}`; + if (method === "bun-global") return `bun add -g @aoagents/ao@${version}`; + return null; + }, })); // Stub the update lifecycle planner's dependencies so handlers don't try to @@ -191,6 +207,10 @@ describe("update command", () => { mockResolveUpdateChannel.mockReturnValue("manual"); mockReadCachedUpdateInfo.mockReset(); mockReadCachedUpdateInfo.mockReturnValue(null); + // Default: no cutover target → every existing test exercises the normal + // update flow exactly as before. + mockResolveCutoverTarget.mockReset(); + mockResolveCutoverTarget.mockResolvedValue(null); mockIsWindows.mockReset(); mockIsWindows.mockReturnValue(false); // Default: project-local loadConfig succeeds with no projects, and no @@ -1118,4 +1138,290 @@ describe("update command", () => { expect(opts.shell).toBe(true); }); }); + + // ----------------------------------------------------------------------- + // Cutover (legacy → rewrite bridge, 0.9.6) + // ----------------------------------------------------------------------- + + describe("cutover", () => { + const MIGRATE_JSON = JSON.stringify({ + dbCreated: true, + schemaVersion: 1, + projects: { created: 1, skipped: 0, failed: 0 }, + orchestrators: { created: 1, skipped: 0, failed: 0, relocatedTranscripts: 0 }, + }); + + // Spawn router for a clean cutover: stop clears sessions, migrate emits the + // JSON summary (exit 0), `ao --version` reports the target, install succeeds. + function happyCutoverSpawn(target = "1.0.0") { + return (cmd: string, args: string[]) => { + if (cmd === "ao" && args[0] === "stop") { + mockSessions.value = []; + return createMockChild(0, undefined, { stdout: "" }); + } + if (cmd === "ao" && args[0] === "migrate") { + return createMockChild(0, undefined, { stdout: MIGRATE_JSON }); + } + if (cmd === "ao" && args[0] === "--version") { + return createMockChild(0, undefined, { stdout: `${target}\n` }); + } + return createMockChild(0, undefined, { stdout: "" }); + }; + } + + beforeEach(() => { + mockDetectInstallMethod.mockReturnValue("npm-global"); + mockGetCurrentVersion.mockReturnValue("0.9.6"); // legacy + mockResolveCutoverTarget.mockResolvedValue("1.0.0"); + mockResolveUpdateChannel.mockReturnValue("stable"); + // Interactive terminal, user confirms the irreversible cutover. + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + mockPromptConfirm.mockResolvedValue(true); + // No daemon / no sessions by default → no stop step. + mockGetRunning.mockResolvedValue(null); + mockExistsSync.mockReturnValue(false); + mockSessions.value = []; + }); + + // ---- Gating ------------------------------------------------------------- + + it("does NOT enter cutover when there is no target (normal flow runs)", async () => { + mockResolveCutoverTarget.mockResolvedValue(null); + mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "npm-global" })); + mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "0.3.0\n" })); + + await program.parseAsync(["node", "test", "update"]); + + // Normal npm flow installs @latest; migrate is never spawned. + const migrateCalls = mockSpawn.mock.calls.filter( + ([cmd, args]) => cmd === "ao" && Array.isArray(args) && args[0] === "migrate", + ); + expect(migrateCalls).toHaveLength(0); + }); + + it("does NOT enter cutover when the install is already post-rewrite", async () => { + mockGetCurrentVersion.mockReturnValue("0.10.0"); // not legacy + mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "npm-global" })); + mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "0.3.0\n" })); + + await program.parseAsync(["node", "test", "update"]); + + const migrateCalls = mockSpawn.mock.calls.filter( + ([cmd, args]) => cmd === "ao" && Array.isArray(args) && args[0] === "migrate", + ); + expect(migrateCalls).toHaveLength(0); + }); + + it("does NOT enter cutover when the target equals the current version", async () => { + mockGetCurrentVersion.mockReturnValue("0.9.6"); + mockResolveCutoverTarget.mockResolvedValue("0.9.6"); + mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "npm-global" })); + mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "0.3.0\n" })); + + await program.parseAsync(["node", "test", "update"]); + + const migrateCalls = mockSpawn.mock.calls.filter( + ([cmd, args]) => cmd === "ao" && Array.isArray(args) && args[0] === "migrate", + ); + expect(migrateCalls).toHaveLength(0); + }); + + // ---- Worker-busy guard -------------------------------------------------- + + it("refuses (non-zero) when an active worker is running, stopping nothing", async () => { + mockExistsSync.mockReturnValue(true); + mockLoadGlobalConfig.mockReturnValue({ projects: { "my-app": { path: "/tmp/foo" } } }); + mockLoadConfig.mockReturnValue({ + projects: { "my-app": { path: "/tmp/foo" } }, + configPath: "/tmp/test-global-config.yaml", + }); + mockSessions.value = [ + { id: "feat-1", status: "working", projectId: "my-app", metadata: {} }, + ]; + + await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow( + "process.exit(1)", + ); + + // Nothing was spawned — no stop, no migrate, no install. + expect(mockSpawn).not.toHaveBeenCalled(); + const stderr = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); + expect(stderr).toMatch(/active worker/i); + }); + + it("proceeds when only the orchestrator is active", async () => { + // Daemon running with just the orchestrator session active. + mockGetRunning + .mockResolvedValueOnce({ + pid: 12345, + configPath: "/tmp/test-global-config.yaml", + port: 3000, + startedAt: new Date().toISOString(), + projects: ["my-app"], + }) + .mockResolvedValue(null); + mockLoadConfig.mockReturnValue({ + projects: { "my-app": { path: "/tmp/foo" } }, + configPath: "/tmp/test-global-config.yaml", + }); + mockSessions.value = [ + { + id: "my-app-orchestrator", + status: "working", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }, + ]; + mockSpawn.mockImplementation(happyCutoverSpawn()); + + await program.parseAsync(["node", "test", "update"]); + + // Guard passed → migration ran and the rewrite was installed. + const migrateCalls = mockSpawn.mock.calls.filter( + ([cmd, args]) => cmd === "ao" && args[0] === "migrate", + ); + expect(migrateCalls).toHaveLength(1); + expect(mockInvalidateCache).toHaveBeenCalled(); + }); + + // ---- Non-interactive gates --------------------------------------------- + + it("refuses an api-invoked cutover and never installs", async () => { + const orig = process.env["AO_NON_INTERACTIVE_INSTALL"]; + process.env["AO_NON_INTERACTIVE_INSTALL"] = "1"; + try { + await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow( + "process.exit(1)", + ); + expect(mockSpawn).not.toHaveBeenCalled(); + const stderr = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); + expect(stderr).toMatch(/run `ao update` in a terminal/i); + } finally { + if (orig === undefined) delete process.env["AO_NON_INTERACTIVE_INSTALL"]; + else process.env["AO_NON_INTERACTIVE_INSTALL"] = orig; + } + }); + + it("prints the manual command and does not install on piped non-TTY", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); + + const logSpy = vi.mocked(console.log); + await program.parseAsync(["node", "test", "update"]); + + expect(mockSpawn).not.toHaveBeenCalled(); + expect(mockPromptConfirm).not.toHaveBeenCalled(); + const all = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(all).toMatch(/ao update/); + }); + + it("aborts and does not install when the user declines the confirm", async () => { + mockPromptConfirm.mockResolvedValue(false); + await program.parseAsync(["node", "test", "update"]); + expect(mockSpawn).not.toHaveBeenCalled(); + expect(mockInvalidateCache).not.toHaveBeenCalled(); + }); + + // ---- Migration abort ---------------------------------------------------- + + it("aborts (non-zero) and does NOT install when migration fails", async () => { + mockSpawn.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "ao" && args[0] === "migrate") { + return createMockChild(1, undefined, { stderr: "migration refused\n" }); + } + return createMockChild(0, undefined, { stdout: "" }); + }); + + await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow( + "process.exit(1)", + ); + + // Migrate spawned, but install (npm) was NOT. + const installCalls = mockSpawn.mock.calls.filter(([cmd]) => cmd === "npm"); + expect(installCalls).toHaveLength(0); + expect(mockInvalidateCache).not.toHaveBeenCalled(); + const stderr = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); + expect(stderr).toMatch(/still on the legacy version/i); + }); + + // ---- Install per method ------------------------------------------------- + + it.each([ + ["npm-global" as const, "npm", ["install", "-g", "@aoagents/ao@1.0.0"]], + ["pnpm-global" as const, "pnpm", ["add", "-g", "@aoagents/ao@1.0.0"]], + ["bun-global" as const, "bun", ["add", "-g", "@aoagents/ao@1.0.0"]], + ])("runs the exact-pin install for %s", async (method, bin, args) => { + mockDetectInstallMethod.mockReturnValue(method); + mockSpawn.mockImplementation(happyCutoverSpawn()); + + await program.parseAsync(["node", "test", "update"]); + + const installCall = mockSpawn.mock.calls.find(([cmd]) => cmd === bin); + expect(installCall).toBeDefined(); + expect(installCall?.[1]).toEqual(args); + expect(mockInvalidateCache).toHaveBeenCalled(); + }); + + it.each(["homebrew" as const, "git" as const, "unknown" as const])( + "prints instructions and does not auto-install for %s", + async (method) => { + mockDetectInstallMethod.mockReturnValue(method); + mockSpawn.mockImplementation(happyCutoverSpawn()); + + const logSpy = vi.mocked(console.log); + await program.parseAsync(["node", "test", "update"]); + + // Migration ran, but no package-manager install was spawned. + const installCalls = mockSpawn.mock.calls.filter(([cmd]) => + ["npm", "pnpm", "bun"].includes(cmd as string), + ); + expect(installCalls).toHaveLength(0); + const all = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(all).toMatch(/migrated/i); + }, + ); + + // ---- Success ------------------------------------------------------------ + + it("invalidates cache, never restarts AO, and prints success on the happy path", async () => { + mockSpawn.mockImplementation(happyCutoverSpawn()); + const logSpy = vi.mocked(console.log); + + await program.parseAsync(["node", "test", "update"]); + + expect(mockInvalidateCache).toHaveBeenCalledTimes(1); + // The legacy daemon must NOT be restarted. + const startCalls = mockSpawn.mock.calls.filter( + ([cmd, args]) => cmd === "ao" && Array.isArray(args) && args[0] === "start", + ); + expect(startCalls).toHaveLength(0); + const all = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(all).toMatch(/Updated to 1\.0\.0/); + expect(all).toMatch(/ao start/); + }); + + // ---- --check reports cutover state ------------------------------------- + + it("--check reports cutoverAvailable and cutoverTarget", async () => { + mockResolveCutoverTarget.mockResolvedValue("1.0.0"); + mockGetCurrentVersion.mockReturnValue("0.9.6"); + const logSpy = vi.mocked(console.log); + + await program.parseAsync(["node", "test", "update", "--check"]); + + const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(parsed.cutoverAvailable).toBe(true); + expect(parsed.cutoverTarget).toBe("1.0.0"); + }); + }); }); diff --git a/packages/cli/__tests__/lib/update-check.test.ts b/packages/cli/__tests__/lib/update-check.test.ts index 6b8de79267..134e674813 100644 --- a/packages/cli/__tests__/lib/update-check.test.ts +++ b/packages/cli/__tests__/lib/update-check.test.ts @@ -93,6 +93,9 @@ import { resolveUpdateChannel, resolveInstallMethodOverride, isManualOnlyInstall, + isLegacyVersion, + getCutoverInstallCommand, + resolveCutoverTarget, } from "../../src/lib/update-check.js"; // --------------------------------------------------------------------------- @@ -1315,6 +1318,91 @@ describe("update-check", () => { }); }); + // ----------------------------------------------------------------------- + // Cutover (legacy → rewrite bridge, 0.9.6) + // ----------------------------------------------------------------------- + + describe("isLegacyVersion", () => { + it("treats 0.x with minor < 10 as legacy", () => { + expect(isLegacyVersion("0.9.6")).toBe(true); + expect(isLegacyVersion("0.2.2")).toBe(true); + expect(isLegacyVersion("0.9.6-nightly-abc")).toBe(true); + }); + + it("treats 0.10+ and 1.x as post-rewrite (not legacy)", () => { + expect(isLegacyVersion("0.10.0")).toBe(false); + expect(isLegacyVersion("0.11.3")).toBe(false); + expect(isLegacyVersion("1.0.0")).toBe(false); + }); + + it("returns false for unparseable versions", () => { + expect(isLegacyVersion("")).toBe(false); + expect(isLegacyVersion("not-a-version")).toBe(false); + }); + }); + + describe("getCutoverInstallCommand", () => { + it("builds exact-pin install commands for auto-installable methods", () => { + expect(getCutoverInstallCommand("npm-global", "1.0.0")).toBe( + "npm install -g @aoagents/ao@1.0.0", + ); + expect(getCutoverInstallCommand("pnpm-global", "1.0.0")).toBe( + "pnpm add -g @aoagents/ao@1.0.0", + ); + expect(getCutoverInstallCommand("bun-global", "1.0.0")).toBe("bun add -g @aoagents/ao@1.0.0"); + }); + + it("returns null for methods that cannot auto-install", () => { + expect(getCutoverInstallCommand("homebrew", "1.0.0")).toBeNull(); + expect(getCutoverInstallCommand("git", "1.0.0")).toBeNull(); + expect(getCutoverInstallCommand("unknown", "1.0.0")).toBeNull(); + }); + }); + + describe("resolveCutoverTarget", () => { + let origEnv: string | undefined; + beforeEach(() => { + origEnv = process.env["AO_CUTOVER_VERSION"]; + delete process.env["AO_CUTOVER_VERSION"]; + }); + afterEach(() => { + if (origEnv === undefined) delete process.env["AO_CUTOVER_VERSION"]; + else process.env["AO_CUTOVER_VERSION"] = origEnv; + }); + + it("returns the AO_CUTOVER_VERSION override without hitting the registry", async () => { + process.env["AO_CUTOVER_VERSION"] = "1.2.3"; + expect(await resolveCutoverTarget()).toBe("1.2.3"); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("returns dist-tags.next from the registry", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ "dist-tags": { latest: "0.9.6", next: "1.0.0" } }), + }); + expect(await resolveCutoverTarget()).toBe("1.0.0"); + }); + + it("returns null when the next tag is absent", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ "dist-tags": { latest: "0.9.6" } }), + }); + expect(await resolveCutoverTarget()).toBeNull(); + }); + + it("returns null on network error (no cutover, falls back to normal update)", async () => { + mockFetch.mockRejectedValue(new Error("fetch failed")); + expect(await resolveCutoverTarget()).toBeNull(); + }); + + it("returns null on non-ok response", async () => { + mockFetch.mockResolvedValue({ ok: false, status: 503 }); + expect(await resolveCutoverTarget()).toBeNull(); + }); + }); + describe("detectInstallMethod with override", () => { it("uses the configured installMethod when set", () => { mockGlobalConfig.value = { installMethod: "bun-global" }; From 1b721328e2da812f347244597e533b1b988a0e45 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 18 Jun 2026 00:55:58 +0530 Subject: [PATCH 3/4] chore: improve migration UX + add changeset for cutover-aware ao update Refs #2129 --- .changeset/cutover-aware-ao-update.md | 11 +++++++++++ packages/cli/src/commands/update.ts | 15 +++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 .changeset/cutover-aware-ao-update.md diff --git a/.changeset/cutover-aware-ao-update.md b/.changeset/cutover-aware-ao-update.md new file mode 100644 index 0000000000..7934a3f0d0 --- /dev/null +++ b/.changeset/cutover-aware-ao-update.md @@ -0,0 +1,11 @@ +--- +"@aoagents/ao-cli": minor +--- + +Teach `ao update` to perform the legacy-to-rewrite cutover (bridge 0.9.6). + +When a rewrite build is published under the npm `next` dist-tag (or `AO_CUTOVER_VERSION` is set) and the current install is still legacy (`major.minor < 0.10`), `ao update` now migrates the user's data via `ao migrate` (issue #2129) and installs the rewrite at the exact pinned version instead of running the normal channel update. + +The cutover flow: refuse if any active worker session is running (the orchestrator's own state never blocks), require a terminal confirmation (never auto-confirm a dashboard/api-invoked spawn), stop the daemon without restoring, run migration before the install replaces the legacy binary, install the rewrite, verify the new version, and finish without restarting the legacy daemon. `--check` now also reports `cutoverAvailable` and `cutoverTarget`. + +When no `next` build exists (the common case), `ao update` behaves exactly as before across all install methods. diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index ea70135aac..19c97eb6dc 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -404,7 +404,9 @@ interface MigrationResult { * from stdout — the contract is documented on {@link MigrationSummary}. */ async function runMigration(): Promise { - const result = await runCommandCapture("ao", ["migrate", "--json"], { echo: true }); + // No echo: `--json` emits a machine-readable blob, not human progress. We + // surface a readable summary on success and the raw output on failure. + const result = await runCommandCapture("ao", ["migrate", "--json"]); return { exitCode: result.exitCode, summary: parseMigrationSummary(result.output), @@ -412,6 +414,13 @@ async function runMigration(): Promise { }; } +/** One-line human summary of a migration result, for the success path. */ +function formatMigrationSummary(summary: MigrationSummary | null): string { + if (!summary) return "Migration complete."; + const { projects, orchestrators } = summary; + return `Migration complete: ${projects.created} project(s), ${orchestrators.created} orchestrator(s) migrated.`; +} + function parseMigrationSummary(output: string): MigrationSummary | null { const match = output.match(/\{[\s\S]*\}/); if (!match) return null; @@ -513,13 +522,15 @@ async function handleCutover(method: InstallMethod, target: string): Promise Date: Thu, 18 Jun 2026 01:01:04 +0530 Subject: [PATCH 4/4] test(cli): add cutover exports to update-instrumentation mock Refs #2129 --- .../cli/__tests__/commands/update-instrumentation.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/__tests__/commands/update-instrumentation.test.ts b/packages/cli/__tests__/commands/update-instrumentation.test.ts index 30fb22156d..388994d796 100644 --- a/packages/cli/__tests__/commands/update-instrumentation.test.ts +++ b/packages/cli/__tests__/commands/update-instrumentation.test.ts @@ -37,6 +37,10 @@ vi.mock("../../src/lib/update-check.js", () => ({ getUpdateCommand: (...args: unknown[]) => mockGetUpdateCommand(...args), readCachedUpdateInfo: vi.fn(() => undefined), resolveUpdateChannel: vi.fn(() => "stable"), + // No cutover target → these tests exercise the normal-update instrumentation. + resolveCutoverTarget: vi.fn(async () => null), + isLegacyVersion: vi.fn(() => false), + getCutoverInstallCommand: vi.fn(() => null), })); const { mockPromptConfirm } = vi.hoisted(() => ({