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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions apps/desktop/src/renderer/lib/v1-migration/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,17 @@ export function isV1MigrationCompleteAtBoot(
*/
export const V1_MIGRATION_COMPLETED_EVENT = "v1-migration-completed";

export function markV1MigrationComplete(organizationId: string): void {
export function markV1MigrationComplete(
organizationId: string,
{ armFlipHandoff = true }: { armFlipHandoff?: boolean } = {},
): void {
const first = !isV1MigrationComplete(organizationId);
localStorage.setItem(KEY_PREFIX + organizationId, new Date().toISOString());
if (first) {
localStorage.setItem(PENDING_CONTINUITY_PREFIX + organizationId, "1");
localStorage.setItem(WELCOME_PREFIX + organizationId, "1");
if (armFlipHandoff) {
localStorage.setItem(PENDING_CONTINUITY_PREFIX + organizationId, "1");
localStorage.setItem(WELCOME_PREFIX + organizationId, "1");
}
try {
window.dispatchEvent(
new CustomEvent(V1_MIGRATION_COMPLETED_EVENT, {
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/src/renderer/lib/v1-migration/pass.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";
import { planV2SurfacePass } from "./pass";

describe("planV2SurfacePass", () => {
test("unmigrated v1 data on a machine that never ran the v1-surface pass gets the full pass", () => {
expect(
planV2SurfacePass({
followUpPending: false,
migrationComplete: false,
hasV1Data: true,
}),
).toBe("full");
});

test("v2-native machine with an empty local.db only backfills groups", () => {
expect(
planV2SurfacePass({
followUpPending: false,
migrationComplete: false,
hasV1Data: false,
}),
).toBe("groups-only");
});

test("completed migration is not redone, so v2 deletions stay deleted", () => {
expect(
planV2SurfacePass({
followUpPending: false,
migrationComplete: true,
hasV1Data: true,
}),
).toBe("groups-only");
});

test("pending follow-up runs the full pass even after completion", () => {
expect(
planV2SurfacePass({
followUpPending: true,
migrationComplete: true,
hasV1Data: true,
}),
).toBe("full");
});
});
21 changes: 21 additions & 0 deletions apps/desktop/src/renderer/lib/v1-migration/pass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type V2SurfacePass = "full" | "groups-only";

/**
* Which pass a boot on the v2 surface owes. A machine can land on v2 without
* ever having run the v1-surface migration (account created after the v2-only
* cutoff on an install that already held v1 data, explicit opt-in, forced
* flip), so unmigrated v1 rows must trigger the full pass here too.
*/
export function planV2SurfacePass({
followUpPending,
migrationComplete,
hasV1Data,
}: {
followUpPending: boolean;
migrationComplete: boolean;
hasV1Data: boolean;
}): V2SurfacePass {
if (followUpPending) return "full";
if (!migrationComplete && hasV1Data) return "full";
return "groups-only";
}
65 changes: 65 additions & 0 deletions apps/desktop/src/renderer/lib/v1-migration/runV1Migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
V1WorktreeRow,
} from "./ipc";
import type { V1LedgerOutcome, V1LedgerRow } from "./ledger";
import { planV2SurfacePass } from "./pass";
import { runV1Migration } from "./runV1Migration";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -371,6 +372,70 @@ describe("runV1Migration scenarios", () => {
expect(second.gateComplete).toBe(true);
});

test("pre-account v1 data on a machine already on v2: every workspace reaches the board", async () => {
const ipc = new FakeIpc();
const host = new FakeHost();
ipc.projects = [project("p1", "/repo/a")];
ipc.worktrees = [
{ id: "wt1", path: "/trees/feat-a", baseBranch: "main" },
{ id: "wt2", path: "/trees/feat-b", baseBranch: "main" },
];
ipc.workspaces = [
workspace("w-main", "p1", "main"),
workspace("w-feat-a", "p1", "feat-a", "wt1"),
workspace("w-feat-b", "p1", "feat-b", "wt2"),
];
host.diskBranches.set("/repo/a", new Set(["main", "feat-a", "feat-b"]));
// File -> Open Project on v2 already made the project and its default
// "main" workspace; the ledger is empty because no pass ever ran.
host.projects.push({ id: "v2p-open", repoPath: "/repo/a" });
host.workspaces.push({
tags: [],
id: "v2w-open",
projectId: "v2p-open",
branch: "main",
type: "local",
});
expect(
planV2SurfacePass({
followUpPending: false,
migrationComplete: false,
hasV1Data: ipc.projects.length + ipc.workspaces.length > 0,
}),
).toBe("full");

const adopted: string[] = [];
const first = await runV1Migration({
organizationId: "org",
hostClient: host.client(),
ipc,
onWorkspaceAdopted: (id) => adopted.push(id),
});

expect(first.projects.linked).toBe(1);
expect(host.projects).toHaveLength(1);
expect(first.workspaces.linked).toBe(1);
expect(first.workspaces.migrated).toBe(2);
expect(first.gateComplete).toBe(true);

const board = await host.client().workspace.list.query();
expect(
board
.filter((w) => w.projectId === "v2p-open")
.map((w) => w.branch)
.sort(),
).toEqual(["feat-a", "feat-b", "main"]);
expect(adopted).toHaveLength(2);
for (const v1Id of ["w-main", "w-feat-a", "w-feat-b"]) {
expect(ipc.ledger.get(`workspace\0${v1Id}`)?.v2Id).toBeTruthy();
}

const mutationsAfterFirst = host.mutations.length;
await run(ipc, host);
expect(host.mutations.length).toBe(mutationsAfterFirst);
expect(host.workspaces).toHaveLength(3);
});

test("custom v1 color and hide-image carry over; defaults do not", async () => {
const ipc = new FakeIpc();
const host = new FakeHost();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { electronTrpcClient } from "renderer/lib/trpc-client";
import { runV1Migration } from "renderer/lib/v1-migration";
import {
isV1FollowUpPending,
isV1ForcedFlipActive,
isV1MigrationComplete,
markV1MigrationComplete,
setV1FollowUpPending,
Expand All @@ -20,6 +19,7 @@ import {
type V1GroupTarget,
} from "renderer/lib/v1-migration/groups";
import { electronV1MigrationIpc } from "renderer/lib/v1-migration/ipc";
import { planV2SurfacePass } from "renderer/lib/v1-migration/pass";
import {
isTransientV1MigrationFailure,
nextV1MigrationRetryDelayMs,
Expand All @@ -40,7 +40,7 @@ import { appendPendingMigratedTerminals } from "renderer/stores/workspace-create
* already in place. On the v2 surface the full pass runs while there is
* outstanding work: best-effort kinds (settings/presets/terminals) that
* were still failing when the gate completed (D4), or everything on
* machines the forced-flip backstop moved before their gate completed.
* machines that reached v2 with unmigrated v1 data (see planV2SurfacePass).
* Completed v2 migrations still run the ledger-guarded group backfill, since
* older passes omitted groups entirely.
* Cross-instance single-flight via a main-process lock file. A pass that
Expand Down Expand Up @@ -108,15 +108,7 @@ export function V1AutoMigration() {
if (!organizationId || !onboarded || !activeHostUrl || !agentsSettled) {
return;
}
let groupsOnly = false;
if (isV2CloudEnabled) {
const followUp =
isV1FollowUpPending(organizationId) ||
(isV1ForcedFlipActive() && !isV1MigrationComplete(organizationId));
groupsOnly = !followUp;
} else if (migrationFlagEnabled !== true) {
return;
}
if (!isV2CloudEnabled && migrationFlagEnabled !== true) return;
if (startedOrgsRef.current.has(organizationId)) return;
startedOrgsRef.current.add(organizationId);

Expand Down Expand Up @@ -150,6 +142,26 @@ export function V1AutoMigration() {
if (!lock.acquired) return;
locked = true;

let groupsOnly = false;
if (isV2CloudEnabled) {
const followUpPending = isV1FollowUpPending(organizationId);
const migrationComplete = isV1MigrationComplete(organizationId);
let hasV1Data = false;
if (!followUpPending && !migrationComplete) {
const [v1Projects, v1Workspaces] = await Promise.all([
electronV1MigrationIpc.readV1Projects(),
electronV1MigrationIpc.readV1Workspaces(),
]);
hasV1Data = v1Projects.length + v1Workspaces.length > 0;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
groupsOnly =
planV2SurfacePass({
followUpPending,
migrationComplete,
hasV1Data,
}) === "groups-only";
}

const groupTarget: V1GroupTarget = (group, projectId, tag) => {
const sectionId = buildSidebarFolderKey(projectId, tag);
if (collections.v2SidebarSections.get(sectionId)) return;
Expand Down Expand Up @@ -221,7 +233,11 @@ export function V1AutoMigration() {
let firstCompletion = false;
if (summary.gateComplete) {
firstCompletion = !isV1MigrationComplete(organizationId);
markV1MigrationComplete(organizationId);
// Already on v2: no flip happens, so no welcome card or
// continuity restore to hand off to the next launch.
markV1MigrationComplete(organizationId, {
armFlipHandoff: !isV2CloudEnabled,
});
const bestEffortClean =
summary.settings.failed +
summary.settings.deferred +
Expand All @@ -236,7 +252,7 @@ export function V1AutoMigration() {
// flag can clear.
setV1FollowUpPending(
organizationId,
firstCompletion || !bestEffortClean,
(firstCompletion && !isV2CloudEnabled) || !bestEffortClean,
);
}

Expand Down
Loading