Skip to content

fix(host-service,desktop): broken import flow, stop v1 importer from duplicating already-imported projects - #6309

Merged
Kitenite merged 13 commits into
ParadevAI:mainfrom
distroinfinity:broken-import-flow
Aug 10, 2026
Merged

Kitenite merged 13 commits into
ParadevAI:mainfrom
distroinfinity:broken-import-flow

Conversation

@distroinfinity

@distroinfinity distroinfinity commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Using the Settings → Experimental → "Import from v1" wizard, the "Bring over your projects" step had three compounding issues:

  1. Pressing Import all ran to completion but nothing visibly changed — every row still offered "Import", so it was natural to press it again (and again).
  2. Reopening the importer showed no memory of what was already imported.
  3. Every import created a brand-new duplicate project. After a few Import-all presses I ended up with 4 copies of each of my 5 projects in the sidebar.
image image

Root cause

Importing a v1 project creates the v2 project locally only (project.create {kind:"importLocal"} — "fully local, the cloud is never involved", by design). But the importer decides row state via project.findByPath with walkAllRemotes: true, and that branch had a "stale local link" probe: it found the local row, asked cloud v2Project.get(id), got NOT_FOUND (the cloud never knew this project), concluded the row was stale, and silently dropped it from the candidates. With no local-path candidate, decideProjectImport returned import — so the wizard re-imported, and createFromImportLocal unconditionally inserted a fresh row.

The probe was written for "cloud project deleted from another device", but that inference contradicts the delete saga's own principle in the same file ("Local is reality — the local deletes are the source of truth"): a surviving local row means the project exists on this device, regardless of what the cloud knows.

Confirmed empirically in a local dev stack: with 4 rows for a repo in host.db, live findByPath {walkAllRemotes:true} returned {"candidates": [], "cloudErrors": []}, with a cloud NOT_FOUND logged per probe, and each Import-all press added exactly one more row per project.

Fix

1. findByPath: a local-DB row is authoritative (host-service). A row keyed by the repo's resolved git root short-circuits in both modes — exactly like the non-walkAllRemotes branch always has. The staleness probe and the staleLocalLink field are deleted (the field had zero consumers repo-wide). The cloud remote-walk still runs when no local row exists (candidate discovery for linking is unchanged).

2. createFromImportLocal is idempotent on repo path (host-service). Importing a repo that's already a project returns the existing project (with its ensured main workspace) and reports the new additive created: false on CreateResult — duplicates become impossible from any caller. The reused row is left untouched: no rename, and the renderer now gates the v1 color/icon carry on created !== false so a re-import can't overwrite v2 customizations (older hosts without the field keep today's behavior).

3. Wizard adopts the workspaces-page pattern (desktop). Per-row import state lives in one page-level map (like adoptStates); the header button reads Import all · N, shows Importing i/n while running, and disappears when nothing is pending; rows are disabled during the batch; failed rows show inline errors with Retry and stay pending; needs-relocate lives in shared state so it surfaces its confirm UI instead of being re-queued. Lookup failures are kept out of the import-retry path (retry refetches discovery; imports never run without discovery data).

Testing

  • 12 new unit tests: caller-level findByPath tests (local row survives with cloud unaware; cloud never called on a local hit; remote walk preserved when no local row), createFromImportLocal idempotency (same id, one row, customizations preserved), appearance gating, and the wizard's pending-selection + row-action matrix (batch disabling, retry semantics). Existing harness idioms only (bun:test, in-memory sqlite + real migrations, real temp git repos).
  • bun run lint, bun run typecheck, full targeted test suite green.
  • Manual end-to-end in a local dev stack, before and after: pre-fix reproduced the exact duplicate cascade (row counts 0→1→2→3→4 across four Import-all presses); post-fix, a fresh profile imports all 5 projects with one press (rows flip to Linked as each lands, button empties out and disappears), re-pressing/reopening/refreshing re-imports nothing, and the DB holds exactly one row per repo path.
image

Behavior notes

  • A project whose cloud row was deleted from another device now reports "already imported" instead of offering a re-import — consistent with local-is-reality (the local project, workspaces, and sidebar entry still exist on the device).
  • staleLocalLink is removed from the findByPath wire shape (verified unused). created on CreateResult is additive.
  • Design/implementation docs for this fix are included under plans/done/.

Summary by cubic

Fixes the v1 “Bring over your projects” step so already-imported projects are detected and duplicates are no longer created. Makes import idempotent in packages/host-service and updates the apps/desktop wizard to show clear Import All progress, guard retries during batch, and preserve user customizations.

  • Bug Fixes
    • host-service: project.findByPath now treats a local DB row as authoritative in both modes; removes the cloud staleness probe and staleLocalLink. Still walks remotes only when no local row exists.
    • host-service: createFromImportLocal is idempotent on repo path; reuses the existing project and returns created: false. Prevents duplicate rows and gates v1 appearance carry on created !== false to preserve v2 customizations.
    • desktop: Import Projects page uses a shared per-row state map. “Import all · N” shows counts and in-flight progress, disables rows and Retry during batch, surfaces inline errors with Retry, keeps “needs relocate” in shared state, and keeps lookup failures out of import retries.

Written for commit c5333a8. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

New Features

  • Importing projects now reuses existing projects with the same repository path instead of creating duplicates.
  • The import screen shows clearer project states, pending counts, and appropriate actions for linking, relocating, retrying, or importing.
  • “Import all” processes only actionable projects and provides safer progress handling.

Bug Fixes

  • Existing project customizations, including appearance settings, are preserved during repeated imports.
  • Improved handling for lookup and import failures, including retry support.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change prevents duplicate V1 project imports by prioritizing local repository matches, reusing existing projects, preserving reused project settings, and coordinating discovery, retries, relocation, and bulk-import status in the renderer.

Changes

V1 import duplicate prevention

Layer / File(s) Summary
Local discovery and idempotent project creation
packages/host-service/src/trpc/router/project/project.ts, packages/host-service/src/trpc/router/project/handlers.ts, packages/host-service/src/trpc/router/project/project-import.test.ts
Local projects take precedence in findByPath. Local imports reuse existing projects and return a created flag.
Migration result contract and appearance handling
apps/desktop/src/renderer/lib/v1-migration/*
The migration exports ProjectImportDecision and skips appearance updates for reused projects.
Import status and action planning
apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/import-plan.*
Shared status types, pending selection, row-action planning, and focused tests define batch and row behavior.
Page-level import orchestration and controls
apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/*, apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/ImportRow/*
The page centralizes discovery and status state, limits Import All to pending projects, handles retries and relocation, and disables conflicting actions.
Implementation records
plans/done/2026-08-10-v1-import-duplicate-projects-*.md
The design and implementation plan record the changed discovery, import, migration, UI, testing, and verification behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ImportProjectsPage
  participant project.findByPath
  participant createFromImportLocal
  participant ProjectDatabase
  User->>ImportProjectsPage: Start project import
  ImportProjectsPage->>project.findByPath: Find repository path
  project.findByPath->>ProjectDatabase: Check local project
  ProjectDatabase-->>project.findByPath: Existing project or no match
  project.findByPath-->>ImportProjectsPage: Import decision
  ImportProjectsPage->>createFromImportLocal: Import pending project
  createFromImportLocal->>ProjectDatabase: Reuse or create project
  ProjectDatabase-->>createFromImportLocal: Project and created flag
  createFromImportLocal-->>ImportProjectsPage: Import result
  ImportProjectsPage-->>User: Updated project status
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title uses conventional commit format and clearly summarizes the main fix: preventing duplicate projects during v1 import.
Description check ✅ Passed The description clearly explains the problem, root cause, implementation, testing, and behavior changes, with only the template checklist omitted.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/import-plan.ts (1)

25-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tighten the states parameter type.

ReadonlyMap<string, { kind: string }> discards the ProjectImportStatus discriminant. A misspelled kind string in a future caller would compile and silently change the pending set. The call sites in ImportProjectsPage.tsx already pass Map<string, ProjectImportStatus>, and the test literals use as const kinds, so the stricter type stays compatible.

♻️ Proposed type change
 export function selectPendingProjects<T extends { id: string }>(
 	projects: readonly T[],
 	decisions: ReadonlyMap<string, ProjectImportDecision | undefined>,
-	states: ReadonlyMap<string, { kind: string }>,
+	states: ReadonlyMap<string, ProjectImportStatus>,
 ): T[] {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/import-plan.ts`
around lines 25 - 29, Update the states parameter of selectPendingProjects to
use ProjectImportStatus instead of the broad { kind: string } shape, preserving
the existing map and pending-selection behavior while enforcing the
ProjectImportStatus discriminant for callers.
apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/ImportProjectsPage.tsx (1)

67-103: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Avoid writing a ref during render; sync it in an effect.

Line 71 mutates importStatesRef.current in the render body. React can render a component without committing it, so the ref can hold state from an abandoned render. Sync the ref in an effect instead.

Also note that decisions never memoizes: findByPathQueries is a new array on every render, and projects is a new [] when projectsQuery.data is undefined. The map build is cheap, so this is a clarity issue rather than a performance issue.

♻️ Proposed ref sync
-	const importStatesRef = useRef(importStates);
-	importStatesRef.current = importStates;
+	const importStatesRef = useRef(importStates);
+	useEffect(() => {
+		importStatesRef.current = importStates;
+	}, [importStates]);

Add useEffect to the React import.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/ImportProjectsPage.tsx`
around lines 67 - 103, Move the importStatesRef.current assignment out of the
render body and synchronize it in a useEffect that depends on importStates,
adding useEffect to the React imports. Keep updateImportStatus and the ref’s
existing usage unchanged; do not alter the decisions calculation.
apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/import-plan.test.ts (1)

134-141: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the cloud-unreachable branch.

planProjectRowAction builds a distinct user-facing message when candidates is empty and cloudErrors is non-empty (import-plan.ts lines 95-104). No test exercises that branch, and no test exercises findByPathPending: true.

💚 Proposed additional cases
 	it("query errors are retryable via refetch", () => {
 		expect(
 			planProjectRowAction({
 				...base,
 				findByPathErrorMessage: "host unreachable",
 			}),
 		).toEqual({ kind: "error", message: "host unreachable", retry: "query" });
 	});
+
+	it("pending discovery renders the running state", () => {
+		expect(
+			planProjectRowAction({ ...base, findByPathPending: true }),
+		).toEqual({ kind: "running" });
+	});
+
+	it("cloud errors with no candidates surface a retryable query error", () => {
+		const cloudDown = {
+			candidates: [],
+			cloudErrors: [{ url: "https://cloud.example", message: "timeout" }],
+		} as unknown as Pick<
+			ProjectFindByPathResult,
+			"candidates" | "cloudErrors"
+		>;
+		expect(
+			planProjectRowAction({ ...base, findByPathData: cloudDown }),
+		).toEqual({
+			kind: "error",
+			message: "Couldn't reach cloud for https://cloud.example: timeout",
+			retry: "query",
+		});
+	});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/import-plan.test.ts`
around lines 134 - 141, Add tests for both uncovered planProjectRowAction
states: when candidates is empty and cloudErrors is non-empty, assert the
distinct cloud-unreachable user-facing error message; and when findByPathPending
is true, assert the expected pending action. Extend the existing test coverage
without changing the established query-error retry assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@plans/done/2026-08-10-v1-import-duplicate-projects-design.md`:
- Around line 3-5: Update
plans/done/2026-08-10-v1-import-duplicate-projects-design.md lines 3-5 to mark
the plan as shipped. In
plans/done/2026-08-10-v1-import-duplicate-projects-fix-plan.md line 7, change
the design-document reference to its plans/done/ path, and at line 1278 update
the staging command to include both files from plans/done/.

---

Nitpick comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/import-plan.test.ts`:
- Around line 134-141: Add tests for both uncovered planProjectRowAction states:
when candidates is empty and cloudErrors is non-empty, assert the distinct
cloud-unreachable user-facing error message; and when findByPathPending is true,
assert the expected pending action. Extend the existing test coverage without
changing the established query-error retry assertion.

In
`@apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/import-plan.ts`:
- Around line 25-29: Update the states parameter of selectPendingProjects to use
ProjectImportStatus instead of the broad { kind: string } shape, preserving the
existing map and pending-selection behavior while enforcing the
ProjectImportStatus discriminant for callers.

In
`@apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/ImportProjectsPage.tsx`:
- Around line 67-103: Move the importStatesRef.current assignment out of the
render body and synchronize it in a useEffect that depends on importStates,
adding useEffect to the React imports. Keep updateImportStatus and the ref’s
existing usage unchanged; do not alter the decisions calculation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 77bd7a2b-afd1-418a-81f2-dba6aa5bc7b2

📥 Commits

Reviewing files that changed from the base of the PR and between 5c55f59 and 6a13477.

📒 Files selected for processing (12)
  • apps/desktop/src/renderer/lib/v1-migration/index.ts
  • apps/desktop/src/renderer/lib/v1-migration/projects.ts
  • apps/desktop/src/renderer/lib/v1-migration/v1-migration.test.ts
  • apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/ImportProjectsPage.tsx
  • apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/import-plan.test.ts
  • apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/import-plan.ts
  • apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/ImportRow/ImportRow.tsx
  • packages/host-service/src/trpc/router/project/handlers.ts
  • packages/host-service/src/trpc/router/project/project-import.test.ts
  • packages/host-service/src/trpc/router/project/project.ts
  • plans/done/2026-08-10-v1-import-duplicate-projects-design.md
  • plans/done/2026-08-10-v1-import-duplicate-projects-fix-plan.md

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 12 files

Architecture diagram
sequenceDiagram
    participant R as "Renderer (ImportProjectsPage)"
    participant S as "Page-level importStates map"
    participant H as "HostService (tRPC project router)"
    participant D as "Local DB (projects table)"
    participant C as "Cloud API"

    Note over R,C: Local-is-reality: a local project row is authoritative

    R->>H: findByPath(repoPath, walkAllRemotes:true)
    alt Local DB row exists
        H->>D: query projects by resolved git root
        D-->>H: row present
        H-->>R: single local-path candidate (no cloud call)
    else No local row
        H->>D: query projects by resolved git root
        D-->>H: not found
        H->>C: walk GitHub remotes (findByGitHubRemote)
        C-->>H: remote candidates (or cloudErrors)
        H-->>R: candidate list + errors
    end

    Note over R,S: NEW: Import All driven by shared status map
    R->>S: selectPendingProjects (skip already-imported/running)
    S-->>R: queue with pending count
    R->>S: set running for first project
    loop each pending project
        R->>H: createFromImportLocal(name, repoPath)
        H->>D: check existing project by repoPath
        alt Existing project (idempotent reuse)
            H->>D: ensureMainWorkspaceStrict(existing.id)
            H-->>R: {projectId, created:false}
            R->>R: skip v1 appearance carry (created != false)
        else New project
            H->>D: insert project + main workspace
            H-->>R: {projectId, created:true}
            R->>H: carryV1ProjectAppearance (setColor/icon)
        end
        R->>S: mark imported, invalidate findByPath query
    end

    Note over R,S: Failure paths preserved with retry semantics
    alt Lookup (findByPath) failure
        R->>S: set idle (back to refetch)
        R-->>R: show inline error with Retry=query
    else Import failure (createFromImportLocal throws)
        R->>S: set error status (stays pending)
        R-->>R: show Retry=import, during batch shows Queued
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

…status types, extra plan coverage, shipped-plan metadata
@distroinfinity

Copy link
Copy Markdown
Contributor Author

hey @Kitenite
I hit this while moving my own machine to v2. Nothing changed on screen when I pressed Import all, so I pressed it a few more times and ended up with 4 copies of every project in my sidebar. Screenshots are in the description.

I dug in and found the cause in findByPath. Projects imported from v1 only exist in the host's local db, the cloud never hears about them. The stale link check asks the cloud about the local row, gets NOT_FOUND, and drops it. So the importer always thinks nothing was imported and creates a new project on every pass.

The fix treats the local row as the source of truth, which is the same rule the delete path already follows. It also makes importLocal create reuse an existing project for the same repo path, and gives the projects page the same Import all behavior the workspaces page already has, since that one worked well.

I tested on a local dev stack before and after the change, including a run with a fresh profile. Test notes are in the description. If the stale link check was covering a case I missed, tell me and I will rework it.

@Kitenite

Copy link
Copy Markdown
Member

thnka you so much @distroinfinity im testing this now

@Kitenite
Kitenite merged commit e76bcd7 into ParadevAI:main Aug 10, 2026
17 of 19 checks passed
@distroinfinity
distroinfinity deleted the broken-import-flow branch August 11, 2026 06:16

This branch had an error being deployed

1 failed deployment
preview — c5333a84 Deployed Aug 10, 2026 by distroinfinity via Deploy Database (Neon) #7367
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants