Skip to content

feat(desktop,host-service): local-first projects — host.db owns projects, workspace cloud sync retired - #5731

Merged
Kitenite merged 17 commits into
mainfrom
local-first-projects-desi
Jul 19, 2026
Merged

Kitenite merged 17 commits into
mainfrom
local-first-projects-desi

Conversation

@Kitenite

@Kitenite Kitenite commented Jul 17, 2026 •

Copy link
Copy Markdown
Member

Links

  • Design doc: plans/20260716-local-first-projects.md (in this PR, includes the decision log)

Summary

  • v2 goes fully local. Projects live in host.db only (name + updatedAt columns, migration 0010): create, rename, and delete commit locally with zero cloud involvement, so they work fully offline. [bug] Opening a non-GitHub repo on a second machine silently creates a duplicate project instead of joining the existing one #5649's silent my-app-2 duplicates die structurally — nothing ever walks a cloud slug.
  • Workspace cloud dual-write retires (decision 5 in the doc): workspace-cloud-sync.ts, tombstones, re-keying, and the reconciler are deleted; ensureHostRegistered was folded away (host registration via tunnel/connect.ts is untouched — relay access keeps working).
  • Desktop reads projects from a host fan-out (useHostProjects: per-host project.list + project:changed live events + IndexedDB last-seen snapshots, workspace-pattern settings). Sidebar, project settings, and the new-workspace modal are migrated off Electric v2Projects.

How It Works

  • Host project.list/update serve full identity rows; project:changed broadcasts on the existing event bus (new workspace-client payload type). A startup backfill fills name from the legacy cloud row when reachable, else the folder basename — never blocks boot.
  • project.setup gets a local-first fast path: an existing local row completes without any cloud call; the legacy cloud lookup only runs when adopting an old cloud-created project onto a new device. findByPath is local-only (a hit = this host already has it; no hit = create fresh).
  • project.remove flips its kill point: local deletes always run (offline-capable); the legacy cloud delete is best-effort cleanup for the mixed-version window.
  • Renames commit through the host everywhere (settings NameSection, sidebar inline rename) — the optimistic cloud rename is gone.

Manual QA — CDP-verified against the live dev app

Fresh dev host.db with 29 legacy projects; drills driven over CDP:

  • Migration 0010 + name backfill on boot: 28 rows filled from legacy cloud names; a transient-error row correctly kept its empty-name sentinel and filled on the next boot (retry-next-boot rule proven)
  • Create from folder: 171 ms local commit while fully online; cloud v2Project.get for the new id returns NOT_FOUND (no cloud row minted)
  • Full offline lifecycle with the API server SIGSTOP'd + navigator.onLine=false: create 179 ms, rename 18 ms, workspace+worktree create 379 ms, sidebar auto-updated in 101 ms — nothing hung, proving no hidden cloud awaits anywhere in the path
  • Rename → project:changed → sidebar DOM update in 130 ms (no refetch)
  • Settings: project list + detail render from host fan-out; NameSection rename through the real UI commits to host.db; repository field read-only ("No git remote detected")
  • Delete ×3: ~100 ms each, local rows + sidebar cleared; best-effort legacy cloud delete correctly logged "failed; continuing locally"
  • Zero cloud push failed / reconciler lines across the entire session log

Testing

  • bun test in packages/host-service — 885 tests green (3 tests asserting the retired cloud behavior updated to the new local contracts)
  • bunx tsc --noEmit in packages/host-service, packages/workspace-client, apps/desktop; bun run lint clean

Known Limitations / conscious trade-offs

  • Mobile loses workspace visibility until the future cloud layer (team cloud projects w/ sandboxes) returns — accepted in the decision walkthrough.
  • ~15 secondary desktop surfaces (task filters, command palette, history) still read the frozen Electric v2Projects collection; migrating them + the Electric v2Workspaces merge source is follow-up work before the cloud tables retire.
  • Custom project icons (cloud uploadIcon) are retired with the cloud row; thumbnails derive from the GitHub owner avatar. Repository URL in settings is read-only (derived from the git remote).

Rollout

  • Desktop-only in substance; no cloud/API deploy needed (cloud endpoints stay up for old clients). Nothing destructive; host sqlite migration 0010 is additive.

Summary by CodeRabbit

  • New Features

    • Projects are now managed locally on the host (create, update, rename, delete).
    • The project list aggregates results from available hosts, uses cached snapshots when hosts are unreachable, and stays in sync across devices in real time.
    • Deleting a project can be applied across reachable hosts, with warnings for unreachable ones.
    • Project settings now source and display host-based project/repo details.
  • Bug Fixes

    • Improved readiness gating to avoid flashing incomplete project settings.
    • Added one-time backfill for existing project names and timestamps.

@ghost

ghost commented Jul 17, 2026 •

Copy link
Copy Markdown

@coderabbitai

coderabbitai Bot commented Jul 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Local-first project ownership is implemented across host persistence, project lifecycle APIs, workspace synchronization, realtime events, cached host fan-out, and desktop project surfaces. Cloud writes are removed from new local flows, while legacy cloud cleanup remains best-effort.

Changes

Local-first project ownership

Layer / File(s) Summary
Project schema, storage, and event contracts
packages/host-service/drizzle/..., packages/host-service/src/db/..., packages/host-service/src/events/..., packages/workspace-client/src/...
Project identity fields, local project storage helpers, project:changed events, and client dispatch support are added.
Local project creation, setup, and deletion
packages/host-service/src/trpc/router/project/..., apps/desktop/src/renderer/hooks/useEnsureV2Project/...
Project creation, setup, lookup, update, removal, and rollback use local rows as authoritative state.
Project backfill and startup wiring
packages/host-service/src/runtime/project-backfill.ts, packages/host-service/src/app.ts, packages/host-service/src/runtime/workspace-backfill.ts
Legacy project names are backfilled at startup, and workspace cloud-sync startup and shutdown wiring is removed.
Workspace local-only persistence
packages/host-service/src/trpc/router/workspace-creation/..., packages/host-service/src/trpc/router/workspaces/..., packages/host-service/src/workspaces/..., packages/host-service/src/trpc/router/workspace-cleanup/...
Workspace registration, adoption, updates, naming, deletion, and cleanup stop using cloud mirroring and tombstone reconciliation.
Desktop host-project fan-out and caching
apps/desktop/src/renderer/hooks/host-projects/..., apps/desktop/src/renderer/hooks/host-service/..., packages/workspace-client/src/...
Host targets are queried and merged, snapshots are persisted, and project change events update caches and snapshots.
Desktop project workflows and settings
apps/desktop/src/renderer/routes/_authenticated/..., apps/desktop/src/renderer/hooks/...
Dashboard and settings surfaces use host projects for listing, setup, rename, repository display, routing, and multi-host deletion.
Validation and local-first decisions
packages/host-service/test/..., plans/...
Integration expectations are updated for local ownership, and the local-first architecture and follow-up decisions are documented.

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

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.11% 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
Title check ✅ Passed The title is a clear conventional-commit summary of the main local-first projects and workspace sync retirement changes.
Description check ✅ Passed The description covers what changed, why, testing, and trade-offs, though it does not follow the template headings exactly.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch local-first-projects-desi

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.

@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a documentation-only design decision record for the local-first projects architecture, settling v2 projects as host-only (no cloud writes) with a deferred cloud entity that can be bolted on later via five stated invariants.

  • The document correctly supersedes the 07-12 auto-mirror draft and retires workspace cloud dual-write, recording all five decisions from the 2026-07-16 walkthrough in a decision log.
  • It identifies the pre-build audit list (mobile screens, automations router, task dispatch) as work still required before the cloud-row freeze lands, but leaves that work untracked beyond prose — no linked issue or ticket is referenced for the audit.
  • File placement (plans/) and PR title format (docs(plans):) both comply with AGENTS.md rules 6 and 10.

Confidence Score: 5/5

Documentation-only change with no code modifications; safe to merge.

The change is a single markdown design doc with no code, schema, or config touched. Content is internally consistent, placement follows repo conventions, and the five future-seam invariants are well-reasoned. The one loose end — the pre-freeze audit of cloud-row consumers — is called out explicitly in the doc as 'audit before build,' so it is intentionally deferred rather than overlooked.

No files require special attention.

Important Files Changed

Filename Overview
plans/20260716-local-first-projects.md New design doc for local-first projects (v2 fully local, cloud entity deferred); correctly placed in plans/ per AGENTS.md rule 6; content is internally consistent with clear Now/Future/Deploy/Decision-log sections

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Create Project] --> B[persistFromResolved]
    B --> C[host.db local insert\n= commit point]
    C --> D[project:changed bus event]
    D --> E[useHostProjects fan-out\nworkspace pattern]
    E --> F[Sidebar / Settings\noff Electric v2Projects]

    G[Cloud v2_projects table] -->|frozen — no new writes\nfrom new clients| H[Old clients only\nmixed-version window]
    H -->|adoption telemetry| I[Retire cloud endpoints]

    subgraph Future seam
        J[cloudProjectId nullable column\nadded when entity ships]
        K[Org-owned cloud project entity\nteam sandboxes etc.]
        L[additive dual-write\nLWW reconciler]
        K --> J
        J --> L
    end

    C -.->|stable uuid, never re-keyed| J
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Create Project] --> B[persistFromResolved]
    B --> C[host.db local insert\n= commit point]
    C --> D[project:changed bus event]
    D --> E[useHostProjects fan-out\nworkspace pattern]
    E --> F[Sidebar / Settings\noff Electric v2Projects]

    G[Cloud v2_projects table] -->|frozen — no new writes\nfrom new clients| H[Old clients only\nmixed-version window]
    H -->|adoption telemetry| I[Retire cloud endpoints]

    subgraph Future seam
        J[cloudProjectId nullable column\nadded when entity ships]
        K[Org-owned cloud project entity\nteam sandboxes etc.]
        L[additive dual-write\nLWW reconciler]
        K --> J
        J --> L
    end

    C -.->|stable uuid, never re-keyed| J
Loading

Reviews (1): Last reviewed commit: "docs(plans): local-first projects design..." | Re-trigger Greptile

@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: 3

🤖 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/20260716-local-first-projects.md`:
- Around line 30-35: Update the host startup lifecycle in app.ts to remove or
disable the runWorkspaceBackfill, runMainWorkspaceSweep, and
startWorkspaceCloudSync call sites so no retired workspace sync or backfill can
perform cloud writes during the mixed-version window. Remove any now-unused
imports or startup wiring while preserving the remaining host initialization
flow.
- Around line 20-22: The project name backfill described in the Host schema
migration must not finalize a folder-basename fallback when the cloud row is
unreachable. Persist the backfill as incomplete and retry it on a later
connected startup, or define and consistently apply one authoritative precedence
so the resulting name is independent of transient connectivity.
- Around line 37-41: Update the cloud-freeze plan to require concrete
replacement migrations for every listed cloud-row consumer—mobile
home/filter/new-chat screens, the automations router, task-to-workspace
dispatch, and web workspace views—before the freeze is enabled. Define the local
or relay-backed data source and behavior for each consumer, and mark the freeze
as blocked until all migrations are completed; retain mobile workspace
visibility loss only as the explicitly accepted trade-off.
🪄 Autofix (Beta)

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

Run ID: ec55ce72-f765-4507-b2f3-65f1aa20ebad

📥 Commits

Reviewing files that changed from the base of the PR and between b13a92c and 2fb3ac3.

📒 Files selected for processing (1)
  • plans/20260716-local-first-projects.md

Comment on lines +20 to +22
- **Host schema**: `projects` gains `name`, `updatedAt` (drizzle-kit).
One-time backfill: name from cloud row if reachable, else folder basename;
never blocks startup.

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Make the name backfill deterministic and retryable.

If startup cannot reach the cloud, this one-time backfill permanently chooses the folder basename; a later connected startup will then retain a different name than the cloud row. Persist the fallback as incomplete or define a single authoritative precedence so project names do not depend on transient network state.

🤖 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 `@plans/20260716-local-first-projects.md` around lines 20 - 22, The project
name backfill described in the Host schema migration must not finalize a
folder-basename fallback when the cloud row is unreachable. Persist the backfill
as incomplete and retry it on a later connected startup, or define and
consistently apply one authoritative precedence so the resulting name is
independent of transient connectivity.

Comment on lines +30 to +35
- **Workspace sync retires**: `workspace-cloud-sync.ts` + backfill stop
pushing; Electric `v2Workspaces`/`v2Projects` merge sources removed from
desktop. Local `cloudSyncedAt`-style fields don't get added at all.
- **Cloud tables freeze**: `v2_projects` / `v2_workspaces` and their
endpoints stay up for old clients during the mixed-version window, take no
new writes from new clients, and are retired on adoption telemetry.

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Include the host startup lifecycle in the sync-retirement change.

The supplied packages/host-service/src/app.ts still invokes runWorkspaceBackfill, runMainWorkspaceSweep, and startWorkspaceCloudSync. Unless those call sites are removed or made provably non-writing, the retired sync can continue creating cloud writes during the mixed-version window.

🤖 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 `@plans/20260716-local-first-projects.md` around lines 30 - 35, Update the host
startup lifecycle in app.ts to remove or disable the runWorkspaceBackfill,
runMainWorkspaceSweep, and startWorkspaceCloudSync call sites so no retired
workspace sync or backfill can perform cloud writes during the mixed-version
window. Remove any now-unused imports or startup wiring while preserving the
remaining host initialization flow.

Comment on lines +37 to +41
Consumers currently reading cloud rows that need a migration story before the
freeze bites (audit before build): mobile home/filter/new-chat screens,
automations router, task→workspace dispatch, web workspace views. Mobile
loses workspace visibility until either relay-backed host fan-out or the
future cloud layer — accepted trade-off of this decision.

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Gate the cloud freeze on concrete migrations for every listed consumer.

The document freezes cloud tables while acknowledging that automations, task dispatch, and web workspace views still read cloud rows. New local writes will not reach those consumers, so they can become stale or nonfunctional even if mobile visibility loss is accepted. Define and complete each consumer’s replacement behavior before enabling the freeze.

🤖 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 `@plans/20260716-local-first-projects.md` around lines 37 - 41, Update the
cloud-freeze plan to require concrete replacement migrations for every listed
cloud-row consumer—mobile home/filter/new-chat screens, the automations router,
task-to-workspace dispatch, and web workspace views—before the freeze is
enabled. Define the local or relay-backed data source and behavior for each
consumer, and mark the freeze as blocked until all migrations are completed;
retain mobile workspace visibility loss only as the explicitly accepted
trade-off.

@github-actions

github-actions Bot commented Jul 17, 2026 •

Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

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

3 issues found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="plans/20260716-local-first-projects.md">

<violation number="1" location="plans/20260716-local-first-projects.md:21">
P2: The one-time backfill as described is non-deterministic: if the first startup after migration happens offline, the project permanently gets the folder basename; if online, it gets the cloud name. This means the same user on the same machine gets a different project name depending on transient network state at upgrade time. Consider either marking the offline-derived name as provisional (re-attempted on next connected startup) or defining a single authoritative source (e.g., always folder basename, ignoring cloud) so the result is idempotent regardless of connectivity.</violation>

<violation number="2" location="plans/20260716-local-first-projects.md:24">
P1: New local-only projects cannot be targeted by the current automation and cloud workspace flows because those routers still require a matching `v2_projects` row. The plan should define a relay/host lookup or an explicit deferred/disabled behavior and acceptance for these consumers before freezing the cloud tables, rather than leaving the migration as an audit item.</violation>

<violation number="3" location="plans/20260716-local-first-projects.md:30">
P2: The retirement plan covers `workspace-cloud-sync.ts` and the Electric merge sources, but doesn't explicitly address the host startup lifecycle (`app.ts`) which currently invokes `runWorkspaceBackfill`, `runMainWorkspaceSweep`, and `startWorkspaceCloudSync`. If those call sites aren't removed or gated, the retired sync can still produce cloud writes during the mixed-version window. Consider adding the `app.ts` call-site removal to the "Now" checklist.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as Desktop Client
    participant HostDB as host.db (SQLite)
    participant HostRouter as Host Router
    participant Renderer as Renderer (Sidebar/Settings)
    participant Electric as Electric SQL (Cloud)
    participant CloudDB as Cloud DB (v2_projects)

    Note over Client,CloudDB: CURRENT ARCHITECTURE (pre-v2 local-first)

    Client->>HostDB: persistFromResolved() - create project
    HostDB->>CloudDB: blocking cloud create + rollback
    CloudDB-->>HostDB: cloud row created
    HostDB-->>Client: project created (dual-write)

    Client->>Electric: subscribe v2Projects (workspace mode)
    Electric->>CloudDB: query cloud projects
    CloudDB-->>Electric: project data
    Electric-->>Renderer: project list (Electric source)

    alt #5649 occurs
        Client->>HostDB: create project offline/partial
        HostDB->>CloudDB: cloud create fails or duplicates
        CloudDB-->>Client: my-app-2 duplicate
    end

    Renderer->>HostRouter: project.list (current bus)
    HostRouter->>HostDB: local query
    HostDB-->>HostRouter: local projects
    HostRouter-->>Renderer: local projects (for fallback)

    Note over Client,CloudDB: Mobile/automations still read cloud rows directly
Loading

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

Re-trigger cubic

One-time backfill: name from cloud row if reachable, else folder basename;
never blocks startup.
- **Create path**: `persistFromResolved` drops the blocking cloud create +
rollback — local insert is the commit point. Import = local create, always.

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.

P1: New local-only projects cannot be targeted by the current automation and cloud workspace flows because those routers still require a matching v2_projects row. The plan should define a relay/host lookup or an explicit deferred/disabled behavior and acceptance for these consumers before freezing the cloud tables, rather than leaving the migration as an audit item.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plans/20260716-local-first-projects.md, line 24:

<comment>New local-only projects cannot be targeted by the current automation and cloud workspace flows because those routers still require a matching `v2_projects` row. The plan should define a relay/host lookup or an explicit deferred/disabled behavior and acceptance for these consumers before freezing the cloud tables, rather than leaving the migration as an audit item.</comment>

<file context>
@@ -0,0 +1,79 @@
+  One-time backfill: name from cloud row if reachable, else folder basename;
+  never blocks startup.
+- **Create path**: `persistFromResolved` drops the blocking cloud create +
+  rollback — local insert is the commit point. Import = local create, always.
+- **Host router**: `project.list` / `project.update` / `project:changed` on
+  the existing bus. Bare `protectedProcedure`; relay stays the authz boundary.
</file context>

- **Renderer**: `useHostProjects` fan-out (workspace pattern: `networkMode:
"always"`, event-patched cache, 30s heal). Sidebar + settings move off
Electric `v2Projects`.
- **Workspace sync retires**: `workspace-cloud-sync.ts` + backfill stop

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.

P2: The retirement plan covers workspace-cloud-sync.ts and the Electric merge sources, but doesn't explicitly address the host startup lifecycle (app.ts) which currently invokes runWorkspaceBackfill, runMainWorkspaceSweep, and startWorkspaceCloudSync. If those call sites aren't removed or gated, the retired sync can still produce cloud writes during the mixed-version window. Consider adding the app.ts call-site removal to the "Now" checklist.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plans/20260716-local-first-projects.md, line 30:

<comment>The retirement plan covers `workspace-cloud-sync.ts` and the Electric merge sources, but doesn't explicitly address the host startup lifecycle (`app.ts`) which currently invokes `runWorkspaceBackfill`, `runMainWorkspaceSweep`, and `startWorkspaceCloudSync`. If those call sites aren't removed or gated, the retired sync can still produce cloud writes during the mixed-version window. Consider adding the `app.ts` call-site removal to the "Now" checklist.</comment>

<file context>
@@ -0,0 +1,79 @@
+- **Renderer**: `useHostProjects` fan-out (workspace pattern: `networkMode:
+  "always"`, event-patched cache, 30s heal). Sidebar + settings move off
+  Electric `v2Projects`.
+- **Workspace sync retires**: `workspace-cloud-sync.ts` + backfill stop
+  pushing; Electric `v2Workspaces`/`v2Projects` merge sources removed from
+  desktop. Local `cloudSyncedAt`-style fields don't get added at all.
</file context>

## Now

- **Host schema**: `projects` gains `name`, `updatedAt` (drizzle-kit).
One-time backfill: name from cloud row if reachable, else folder basename;

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.

P2: The one-time backfill as described is non-deterministic: if the first startup after migration happens offline, the project permanently gets the folder basename; if online, it gets the cloud name. This means the same user on the same machine gets a different project name depending on transient network state at upgrade time. Consider either marking the offline-derived name as provisional (re-attempted on next connected startup) or defining a single authoritative source (e.g., always folder basename, ignoring cloud) so the result is idempotent regardless of connectivity.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plans/20260716-local-first-projects.md, line 21:

<comment>The one-time backfill as described is non-deterministic: if the first startup after migration happens offline, the project permanently gets the folder basename; if online, it gets the cloud name. This means the same user on the same machine gets a different project name depending on transient network state at upgrade time. Consider either marking the offline-derived name as provisional (re-attempted on next connected startup) or defining a single authoritative source (e.g., always folder basename, ignoring cloud) so the result is idempotent regardless of connectivity.</comment>

<file context>
@@ -0,0 +1,79 @@
+## Now
+
+- **Host schema**: `projects` gains `name`, `updatedAt` (drizzle-kit).
+  One-time backfill: name from cloud row if reachable, else folder basename;
+  never blocks startup.
+- **Create path**: `persistFromResolved` drops the blocking cloud create +
</file context>

Kitenite added 2 commits July 17, 2026 07:18
… local create/rename/delete, retire workspace cloud sync
…igrate sidebar, settings, new-workspace modal off Electric v2Projects
@Kitenite Kitenite changed the title docs(plans): local-first projects design — v2 fully local, cloud entity later feat(desktop,host-service): local-first projects — host.db owns projects, workspace cloud sync retired Jul 17, 2026

@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: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx (1)

25-41: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Wait for host-project readiness before falling back to v1.

An empty v2Match while fan-out is pending does not prove this is a v1 project, so the page can render the wrong settings component. Return null when there are no cached matches and isReady is false.

As per coding guidelines, “Use readiness only when there is no data: show loading/skeleton/null when not ready, and empty/not-found when ready.”

🤖 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/settings/projects/`$projectId/page.tsx
around lines 25 - 41, Use the readiness state from useHostProjects in the
routing decision: when v2Match is empty and host-project data is not ready,
return null instead of falling back to ProjectSettings. Preserve the existing
V2ProjectSettings path for matches and the v1 fallback once readiness is true.

Source: Coding guidelines

packages/host-service/src/trpc/router/project/handlers.ts (1)

20-31: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject . and .. as generated directory names.

Both values survive this sanitizer but resolve to the selected directory or its parent rather than a new child project directory. Explicitly reject these path-navigation components.

Proposed fix
-	if (!slug) {
+	if (!slug || slug === "." || slug === "..") {
 		throw new TRPCError({
 			code: "BAD_REQUEST",
 			message: "Project name must produce a non-empty directory name",
🤖 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 `@packages/host-service/src/trpc/router/project/handlers.ts` around lines 20 -
31, Update dirNameForEmpty to reject generated slugs equal to "." or ".." in
addition to empty slugs, throwing the existing BAD_REQUEST TRPCError before
returning the directory name. Preserve the current sanitization behavior for all
other valid names.
packages/host-service/src/trpc/router/project/project.ts (1)

698-713: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not report success when the authoritative local deletion fails.

The catch logs any workspace/project deletion failure, but Line 713 still returns success. This can leave a partially deleted project while consumers assume removal completed. Make the local deletes atomic, emit events after commit, and propagate failure to the caller.

🤖 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 `@packages/host-service/src/trpc/router/project/project.ts` around lines 698 -
713, Update the local deletion flow in the project removal handler around
deleteLocalWorkspace and the projects delete so all deletes execute atomically
in a transaction, with change events emitted only after the transaction commits.
If the transaction fails, propagate the error to the caller instead of logging
and returning success; retain the success response only after the authoritative
local deletion completes.
🧹 Nitpick comments (1)
apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/NameSection/NameSection.tsx (1)

33-43: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Show a user-visible error toast on rename failure.

When a project rename fails, resetting the input and logging a warning to the console does not inform the user of the failure. Consider displaying a toast notification, similar to how failures are handled in the sidebar's rename action.

♻️ Proposed refactor

First, add the import at the top of the file:

+import { toast } from "`@superset/ui/sonner`";

Then, update the catch block to display the toast:

 		void getHostServiceClientByUrl(hostUrl)
 			.project.update.mutate({ projectId, name: trimmed })
 			.catch((err) => {
 				console.warn("[project-rename] host commit failed", err);
+				toast.error(`Rename failed: ${err instanceof Error ? err.message : String(err)}`);
 				setValue(currentName);
 			});
🤖 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/settings/v2-project/`$projectId/components/V2ProjectSettings/components/NameSection/NameSection.tsx
around lines 33 - 43, Update the rename failure handling in NameSection’s
project.update.mutate catch block to display the existing user-facing toast used
by the sidebar rename action, while preserving the current input reset and
warning log behavior.
🤖 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
`@apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.ts`:
- Around line 128-139: Update the host-projects cache update around setQueryData
and applyProjectChangedEvent so deletion events are handled when rows is
undefined. Remove the deleted project from the persisted snapshot or
invalidate/refetch the host-projects query, ensuring stale snapshot data cannot
restore it.
- Around line 49-57: Gate the v2/v1 settings split on host-project readiness: in
apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.ts:49-57,
preserve the useLiveQuery host lookup while ensuring its completion drives
isReady; in
apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.ts:171-177,
keep that readiness exposed by useHostProjects; and in
apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx:25-41,
use useHostProjects().isReady before selecting the v2 settings page, falling
back only after discovery is ready.

In
`@apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx`:
- Around line 31-40: Preserve the preferred serving-host identity through both
project navigation paths. In ProjectsSettingsSidebar.tsx lines 31-40, add the
preferred hostId to each v2 project row; at lines 81-85, pass it in the link
search parameters. In page.tsx lines 17-24, retain hostId in the mapped project;
at lines 44-50, include it in automatic-navigation search parameters.

In
`@apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/`$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx:
- Around line 141-150: Update the host-specific settings around NameSection and
RepositorySection to use hostProject.name and hostProject.repoUrl instead of the
merged project fields. Keep these values scoped strictly to targetHostUrl, with
no cross-host fallback for rename or repository setup operations.

In `@packages/host-service/drizzle/meta/0010_snapshot.json`:
- Around line 329-336: Update the projects repo-path index definition in the
Drizzle schema to enforce uniqueness for repo_path, add a migration that safely
handles or removes existing duplicate rows before applying the constraint, and
regenerate the 0010_snapshot metadata so projects_repo_path_idx is marked
unique.

In `@packages/host-service/src/projects/local-project-store.ts`:
- Around line 14-24: Update toProjectSnapshot so the fallback project name uses
the platform path helper basename(row.repoPath) instead of manually splitting on
"/"; preserve the existing row.name and row.id fallback order.

In `@packages/host-service/src/runtime/project-backfill.ts`:
- Around line 41-73: Make the update in the pending-project sweep conditional on
both projects.id matching row.id and projects.name still being empty, preventing
stale cloud data from overwriting a concurrent rename. Use the conditional
update result to emit the “updated” event and increment filled only when a row
was actually changed; retain the existing lookup and fallback behavior.

In `@packages/host-service/src/trpc/router/project/project.ts`:
- Around line 658-670: Update the project removal flow around the local project
lookup and legacy cloud deletion so it loads the local row first and returns
immediately when no local project exists. Only invoke
ctx.api.v2Project.delete.mutate and set _cloudDeleted after confirming local
ownership, while preserving the existing local removal behavior for present
projects.

---

Outside diff comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/settings/projects/`$projectId/page.tsx:
- Around line 25-41: Use the readiness state from useHostProjects in the routing
decision: when v2Match is empty and host-project data is not ready, return null
instead of falling back to ProjectSettings. Preserve the existing
V2ProjectSettings path for matches and the v1 fallback once readiness is true.

In `@packages/host-service/src/trpc/router/project/handlers.ts`:
- Around line 20-31: Update dirNameForEmpty to reject generated slugs equal to
"." or ".." in addition to empty slugs, throwing the existing BAD_REQUEST
TRPCError before returning the directory name. Preserve the current sanitization
behavior for all other valid names.

In `@packages/host-service/src/trpc/router/project/project.ts`:
- Around line 698-713: Update the local deletion flow in the project removal
handler around deleteLocalWorkspace and the projects delete so all deletes
execute atomically in a transaction, with change events emitted only after the
transaction commits. If the transaction fails, propagate the error to the caller
instead of logging and returning success; retain the success response only after
the authoritative local deletion completes.

---

Nitpick comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/`$projectId/components/V2ProjectSettings/components/NameSection/NameSection.tsx:
- Around line 33-43: Update the rename failure handling in NameSection’s
project.update.mutate catch block to display the existing user-facing toast used
by the sidebar rename action, while preserving the current input reset and
warning log behavior.
🪄 Autofix (Beta)

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

Run ID: 95d3a90d-be87-457c-b53c-7643bf6c476f

📥 Commits

Reviewing files that changed from the base of the PR and between 2fb3ac3 and 78077b7.

📒 Files selected for processing (44)
  • apps/desktop/src/renderer/hooks/host-projects/useHostProjects/index.ts
  • apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.ts
  • apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.utils.ts
  • apps/desktop/src/renderer/hooks/useEnsureV2Project/useEnsureV2Project.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.test.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts
  • apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/NameSection/NameSection.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/RepositorySection/RepositorySection.tsx
  • packages/host-service/drizzle/0010_project_identity_fields.sql
  • packages/host-service/drizzle/meta/0010_snapshot.json
  • packages/host-service/drizzle/meta/_journal.json
  • packages/host-service/src/app.ts
  • packages/host-service/src/db/schema.ts
  • packages/host-service/src/events/event-bus.ts
  • packages/host-service/src/events/types.ts
  • packages/host-service/src/projects/local-project-store.ts
  • packages/host-service/src/runtime/project-backfill.ts
  • packages/host-service/src/runtime/workspace-backfill.ts
  • packages/host-service/src/runtime/workspace-cloud-sync.ts
  • packages/host-service/src/trpc/router/project/handlers.ts
  • packages/host-service/src/trpc/router/project/project.ts
  • packages/host-service/src/trpc/router/project/utils/ensure-main-workspace.ts
  • packages/host-service/src/trpc/router/project/utils/persist-project.ts
  • packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts
  • packages/host-service/src/trpc/router/workspace-creation/procedures/adopt.ts
  • packages/host-service/src/trpc/router/workspace-creation/shared/adopt-existing-worktree.ts
  • packages/host-service/src/trpc/router/workspace-creation/utils/ai-workspace-names.ts
  • packages/host-service/src/trpc/router/workspace/workspace.ts
  • packages/host-service/src/trpc/router/workspaces/workspaces.ts
  • packages/host-service/src/workspaces/local-workspace-store.ts
  • packages/host-service/test/integration/project.integration.test.ts
  • packages/host-service/test/integration/workspace-cleanup.integration.test.ts
  • packages/host-service/test/integration/workspace-create-pr.integration.test.ts
  • packages/host-service/test/workspace-cloud-sync.test.ts
  • packages/workspace-client/src/index.ts
  • packages/workspace-client/src/lib/eventBus.ts
💤 Files with no reviewable changes (8)
  • packages/host-service/test/workspace-cloud-sync.test.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.test.ts
  • packages/host-service/src/runtime/workspace-backfill.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts
  • packages/host-service/src/runtime/workspace-cloud-sync.ts
  • packages/host-service/src/trpc/router/workspace/workspace.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts
  • packages/host-service/src/trpc/router/workspace-creation/procedures/adopt.ts

Comment on lines +49 to +57
const { data: hosts = [] } = useLiveQuery(
(q) =>
q.from({ hosts: collections.v2Hosts }).select(({ hosts }) => ({
organizationId: hosts.organizationId,
machineId: hosts.machineId,
isOnline: hosts.isOnline,
})),
[collections],
);

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== useHostProjects.ts ==\n'
sed -n '1,260p' apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.ts

printf '\n== project settings page ==\n'
sed -n '1,220p' apps/desktop/src/renderer/routes/_authenticated/settings/projects/\$projectId/page.tsx

printf '\n== related host project usages ==\n'
rg -n "useHostProjects\\(|isReady|v2Match|ProjectSettings|V2ProjectSettings|hostUrl === null|snapshots.has" apps/desktop/src/renderer -g '!**/dist/**'

Repository: superset-sh/superset

Length of output: 22955


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '1,260p' apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.ts
sed -n '1,220p' apps/desktop/src/renderer/routes/_authenticated/settings/projects/\$projectId/page.tsx

Repository: superset-sh/superset

Length of output: 7137


🏁 Script executed:

#!/bin/bash
set -euo pipefail
printf '\n== useHostProjects.ts ==\n'
cat -n apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.ts | sed -n '1,260p'
printf '\n== page.tsx ==\n'
cat -n apps/desktop/src/renderer/routes/_authenticated/settings/projects/\$projectId/page.tsx | sed -n '1,220p'

Repository: superset-sh/superset

Length of output: 8733


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "export function useHostProjects|const isReady =|useLiveQuery\\(" apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.ts apps/desktop/src/renderer/routes/_authenticated/settings/projects/\$projectId/page.tsx

Repository: superset-sh/superset

Length of output: 546


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== useHostProjects.utils.ts ==\n'
cat -n apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.utils.ts | sed -n '1,260p'

printf '\n== settings/projects/page.tsx ==\n'
cat -n apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx | sed -n '1,220p'

printf '\n== V2ProjectSettings.tsx ==\n'
cat -n apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/\$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx | sed -n '1,220p'

Repository: superset-sh/superset

Length of output: 19755


🏁 Script executed:

#!/bin/bash
set -euo pipefail
cat -n apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.utils.ts | sed -n '1,260p'
echo '---'
cat -n apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx | sed -n '1,220p'
echo '---'
cat -n apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/\$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx | sed -n '1,220p'

Repository: superset-sh/superset

Length of output: 19668


Gate the v2/v1 split on host-project readiness. apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx#L25-L41 can fall back to <ProjectSettings /> before host discovery finishes, so a v2 project may render the wrong settings page on first load. Use useHostProjects().isReady here, and keep that readiness flowing from the useLiveQuery host lookup in apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.ts#L49-L57, L171-L177.

📍 Affects 2 files
  • apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.ts#L49-L57 (this comment)
  • apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.ts#L171-L177
  • apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx#L25-L41
🤖 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/hooks/host-projects/useHostProjects/useHostProjects.ts`
around lines 49 - 57, Gate the v2/v1 settings split on host-project readiness:
in
apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.ts:49-57,
preserve the useLiveQuery host lookup while ensuring its completion drives
isReady; in
apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.ts:171-177,
keep that readiness exposed by useHostProjects; and in
apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx:25-41,
use useHostProjects().isReady before selecting the v2 settings page, falling
back only after discovery is ready.

Source: Coding guidelines

Comment on lines +31 to +40
const v2Projects = useMemo(
() =>
hostProjects.map((project) => ({
id: project.projectKey,
name: project.name,
iconUrl: project.repoOwner
? `https://github.com/${project.repoOwner}.png?size=64`
: null,
})),
[hostProjects],

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep serving-host identity when navigating to local-first projects. Both navigation paths reduce HostProjectItem to project identity, causing remote-only projects to open against the local host.

  • apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx#L31-L40: retain a preferred hostId in each v2 row.
  • apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx#L81-L85: include that host in the link search parameters.
  • apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx#L17-L24: retain a preferred hostId in the mapped project.
  • apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx#L44-L50: include that host in automatic navigation.
📍 Affects 2 files
  • apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx#L31-L40 (this comment)
  • apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx#L81-L85
  • apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx#L17-L24
  • apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx#L44-L50
🤖 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/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx`
around lines 31 - 40, Preserve the preferred serving-host identity through both
project navigation paths. In ProjectsSettingsSidebar.tsx lines 31-40, add the
preferred hostId to each v2 project row; at lines 81-85, pass it in the link
search parameters. In page.tsx lines 17-24, retain hostId in the mapped project;
at lines 44-50, include it in automatic-navigation search parameters.

Comment on lines +329 to +336
"indexes": {
"projects_repo_path_idx": {
"name": "projects_repo_path_idx",
"columns": [
"repo_path"
],
"isUnique": false
}

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Enforce one local project per repository path in SQLite.

This index remains non-unique, although project.setup assumes each on-disk path maps to at most one project. Concurrent imports can both pass application checks and persist duplicate projects. Add a unique constraint with migration handling for existing duplicates, then regenerate this snapshot.

🤖 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 `@packages/host-service/drizzle/meta/0010_snapshot.json` around lines 329 -
336, Update the projects repo-path index definition in the Drizzle schema to
enforce uniqueness for repo_path, add a migration that safely handles or removes
existing duplicate rows before applying the constraint, and regenerate the
0010_snapshot metadata so projects_repo_path_idx is marked unique.

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

7 issues found across 44 files (changes from recent commits).

You’re at about 90% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/host-service/src/db/schema.ts">

<violation number="1" location="packages/host-service/src/db/schema.ts:75">
P2: The startup backfill contradicts this comment's “single indexed query” claim: every host boot scans the entire `projects` table for `name = ''`. Adding an index on `projects.name` (or changing the backfill strategy) keeps this migration from becoming an avoidable full-table scan.</violation>

<violation number="2" location="packages/host-service/src/db/schema.ts:77">
P2: `updatedAt` does not actually track all writes to a project row: the existing `setWorktreeBaseDir` and `setBranchPrefix` mutations leave it unchanged. That makes the new timestamp stale for consumers that use it to order or detect local project changes; routing these mutations through the timestamped project store or setting `updatedAt: Date.now()` here would keep the field consistent.</violation>
</file>

<file name="apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts">

<violation number="1" location="apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts:23">
P2: `useHostProjects()` returns an `isReady` flag, but it's not used here. Before host discovery finishes, `hostProjects` is empty, so `v2Match` resolves to `[]` — causing a v2 project to briefly render the legacy `<ProjectSettings />` component on first load. Destructure and check `isReady` before falling back, or return a loading state until host projects are resolved.</violation>
</file>

<file name="apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx">

<violation number="1" location="apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx:30">
P2: Clicking a project that exists only on a remote host opens its settings against the local host, showing “Not set up on this device” and disabling host actions until the user manually switches hosts. Since this hook now supplies projects from every host, retaining a serving host ID in each row and passing it as `search.hostId` would preserve the selected project's origin.</violation>
</file>

<file name="packages/host-service/src/trpc/router/project/project.ts">

<violation number="1" location="packages/host-service/src/trpc/router/project/project.ts:702">
P2: When the legacy cloud delete fails, removing each local workspace through this call does not record its cloud ID for retry, so old cloud workspace mirrors can remain orphaned permanently. The delete path could enqueue tombstones when `_cloudDeleted` is false, or remove the legacy cleanup contract and its misleading retry documentation.</violation>
</file>

<file name="apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts">

<violation number="1" location="apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts:185">
P3: Sidebar project metadata is now duplicated from `DashboardNewWorkspaceModalContent` instead of using a shared mapper, including the GitHub avatar URL construction. A shared host-project presentation mapper would keep the sidebar and project picker consistent as these fields evolve.</violation>
</file>

<file name="packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts">

<violation number="1" location="packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts:341">
P2: Successful deletes now leave a cloud-delete tombstone behind: `deleteLocalWorkspace(...)` still queues one by default, but the success path no longer clears it. Keeping the clear after a successful legacy cleanup (or explicitly disabling tombstone queuing for this local-only path) prevents the reconciler from repeatedly deleting an already-deleted cloud row.</violation>
</file>

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

Re-trigger cubic

})),
[collections, activeOrganizationId],
// Projects are fully local — identity comes from the host fan-out.
const { projects: hostProjects } = useHostProjects();

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.

P2: Clicking a project that exists only on a remote host opens its settings against the local host, showing “Not set up on this device” and disabling host actions until the user manually switches hosts. Since this hook now supplies projects from every host, retaining a serving host ID in each row and passing it as search.hostId would preserve the selected project's origin.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx, line 30:

<comment>Clicking a project that exists only on a remote host opens its settings against the local host, showing “Not set up on this device” and disabling host actions until the user manually switches hosts. Since this hook now supplies projects from every host, retaining a serving host ID in each row and passing it as `search.hostId` would preserve the selected project's origin.</comment>

<file context>
@@ -28,29 +23,21 @@ interface ProjectsSettingsSidebarProps {
-				})),
-		[collections, activeOrganizationId],
+	// Projects are fully local — identity comes from the host fan-out.
+	const { projects: hostProjects } = useHostProjects();
+	const v2Projects = useMemo(
+		() =>
</file context>

// tombstones the id. The cloud delete is a best-effort mirror push —
// unreachable cloud means the reconciler replays the tombstone later.
// ─── Step 3: Local delete (authoritative) ─────────────────────
// The local row is the commit point and the only record. The cloud

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.

P2: Successful deletes now leave a cloud-delete tombstone behind: deleteLocalWorkspace(...) still queues one by default, but the success path no longer clears it. Keeping the clear after a successful legacy cleanup (or explicitly disabling tombstone queuing for this local-only path) prevents the reconciler from repeatedly deleting an already-deleted cloud row.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts, line 341:

<comment>Successful deletes now leave a cloud-delete tombstone behind: `deleteLocalWorkspace(...)` still queues one by default, but the success path no longer clears it. Keeping the clear after a successful legacy cleanup (or explicitly disabling tombstone queuing for this local-only path) prevents the reconciler from repeatedly deleting an already-deleted cloud row.</comment>

<file context>
@@ -340,18 +337,17 @@ async function runDestroy(
-	// tombstones the id. The cloud delete is a best-effort mirror push —
-	// unreachable cloud means the reconciler replays the tombstone later.
+	// ─── Step 3: Local delete (authoritative) ─────────────────────
+	// The local row is the commit point and the only record. The cloud
+	// delete is best-effort legacy cleanup for rows mirrored before
+	// workspaces went fully local.
</file context>

const navigate = useNavigate();
const { v2Projects: projectActions } = useOptimisticCollectionActions();
// Renames commit on a host serving the project — host.db owns the name.
const { projects: hostProjects } = useHostProjects();

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.

P2: useHostProjects() returns an isReady flag, but it's not used here. Before host discovery finishes, hostProjects is empty, so v2Match resolves to [] — causing a v2 project to briefly render the legacy <ProjectSettings /> component on first load. Destructure and check isReady before falling back, or return a loading state until host projects are resolved.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts, line 23:

<comment>`useHostProjects()` returns an `isReady` flag, but it's not used here. Before host discovery finishes, `hostProjects` is empty, so `v2Match` resolves to `[]` — causing a v2 project to briefly render the legacy `<ProjectSettings />` component on first load. Destructure and check `isReady` before falling back, or return a loading state until host projects are resolved.</comment>

<file context>
@@ -17,7 +19,17 @@ export function useDashboardSidebarProjectSectionActions({
 	const navigate = useNavigate();
-	const { v2Projects: projectActions } = useOptimisticCollectionActions();
+	// Renames commit on a host serving the project — host.db owns the name.
+	const { projects: hostProjects } = useHostProjects();
+	const servingHostId = useMemo(
+		() =>
</file context>

name: project.name,
githubOwner: project.repoOwner,
githubRepoName: project.repoName,
iconUrl: project.repoOwner

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.

P3: Sidebar project metadata is now duplicated from DashboardNewWorkspaceModalContent instead of using a shared mapper, including the GitHub avatar URL construction. A shared host-project presentation mapper would keep the sidebar and project picker consistent as these fields evolve.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts, line 185:

<comment>Sidebar project metadata is now duplicated from `DashboardNewWorkspaceModalContent` instead of using a shared mapper, including the GitHub avatar URL construction. A shared host-project presentation mapper would keep the sidebar and project picker consistent as these fields evolve.</comment>

<file context>
@@ -150,44 +150,48 @@ export function useDashboardSidebarData() {
+					name: project.name,
+					githubOwner: project.repoOwner,
+					githubRepoName: project.repoName,
+					iconUrl: project.repoOwner
+						? `https://github.com/${project.repoOwner}.png?size=64`
+						: null,
</file context>

…move ownership check, richer project events, snapshot delete cleanup
@Kitenite

Copy link
Copy Markdown
Member Author

Review triage — fixed in 1f90ad4:

  • Backfill rename race: the sweep now CASes on the empty-name sentinel (WHERE id = ? AND name = ''), so a rename landing during the cloud lookup is never overwritten; skipped rows aren't counted or broadcast.
  • project.remove ownership: local-row check moved ahead of the legacy cloud delete; an id this host doesn't serve is a pure no-op.
  • Deleted-before-hydration resurrection: deleted events now also purge the IndexedDB snapshot and the hydrated snapshot state, so an un-hydrated cache can't bring a deleted project back on next launch.
  • toProjectSnapshot path split: uses node:path basename like the other call sites.
  • Event payload: ProjectSnapshot now carries repoOwner/repoName/worktreeBaseDir, so live-created projects render avatars/links immediately instead of waiting for the 30s refetch.
  • Settings v1 flash: the $projectId page gates the legacy fallback on isReady (cache-first rule).
  • Cross-host merged name in NameSection: project.get now returns name; the rename input seeds from the targeted host's own row and refetches it after commit.

Rejected (with reasons):

  • "workspace-cleanup leaves tombstones queued" — stale review; tombstone queueing was deleted entirely in this PR.
  • "bump updatedAt in setWorktreeBaseDir/setBranchPrefix" — anti-fix: updatedAt drives the cross-replica name merge; bumping it on machine-local settings would let a worktree-dir change promote that replica's stale name.
  • "backfill is non-deterministic offline" — misreads the code: basename is only written on a confirmed NOT_FOUND; transient errors keep the sentinel and retry next boot (verified live: a failed row filled on the following boot).
  • "index the name='' scan" — the table is tens of rows per host.

Deferred to the tracked follow-up: unique index on repoPath (needs dedup migration), serving-host hostId in settings navigation, automations targeting local-only projects (freeze audit).

@Kitenite
Kitenite merged commit 637aa9e into main Jul 19, 2026
19 checks passed

This branch was previously deployed

1 inactive deployment
preview — af5a4dde Deployed Jul 19, 2026 by Kitenite via Deploy Web #6664
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.

1 participant