From 13baeba95d7596fc69cc9bf856f7dff91355df1f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 10 Jun 2026 18:52:00 -0700 Subject: [PATCH] Preserve independent project host setups Co-authored-by: Orca --- ...oject-first-host-model-change-inventory.md | 7 +- .../project-first-host-model-conversation.md | 9 +- notes/project-first-host-model-discussion.md | 7 +- notes/project-first-host-model-handoff.md | 7 +- notes/project-first-host-model.md | 28 +++--- src/main/persistence.test.ts | 90 +++++++++++++++++++ src/main/persistence.ts | 45 ++++++++-- 7 files changed, 166 insertions(+), 27 deletions(-) diff --git a/notes/project-first-host-model-change-inventory.md b/notes/project-first-host-model-change-inventory.md index fb46f839dde..0d1c34b9bfc 100644 --- a/notes/project-first-host-model-change-inventory.md +++ b/notes/project-first-host-model-change-inventory.md @@ -493,12 +493,15 @@ Already partially landed: compatibility projection and persistence sync - project-host setup runtime capability advertisement, read fallback, and remote mutation gating for older runtime servers +- persistence merge that preserves independently persisted project/setup rows + across load, repo updates, and repo reorders while refreshing repo-backed + compatibility rows from repos Not complete yet: - SSH clone streamed-progress parity and provisioning flows -- independent project-host setup persistence beyond the repo-backed - compatibility records +- first-class independent project-host setup creation/update/delete APIs beyond + the current repo-backed import/clone compatibility paths - bulk setup flows and setup for hosts that are not already known to the client - project settings split into global and host-specific ownership - host settings/capability UI aligned with project setup diff --git a/notes/project-first-host-model-conversation.md b/notes/project-first-host-model-conversation.md index 647f17bbe47..3ff4b18b725 100644 --- a/notes/project-first-host-model-conversation.md +++ b/notes/project-first-host-model-conversation.md @@ -497,11 +497,14 @@ Already partially landed: compatibility projection and persistence sync - project-host setup runtime capability advertisement, read fallback, and remote mutation gating for older runtime servers +- persistence merge that preserves independently persisted project/setup rows + across load, repo updates, and repo reorders while refreshing repo-backed + compatibility rows from repos Not complete yet: -- independent project-host setup persistence beyond repo-backed compatibility - records +- first-class independent project-host setup creation/update/delete APIs beyond + the current repo-backed import/clone compatibility paths - bulk setup flows and setup for hosts that are not already known to the client - SSH streamed clone progress parity - full project settings split into global and host-specific ownership @@ -531,7 +534,7 @@ tests, and version-skew handling. The largest remaining areas are: -1. independent `ProjectHostSetup` persistence +1. independent `ProjectHostSetup` creation/update/delete APIs 2. project-first creation/setup UI completion 3. project and host settings ownership split 4. cache/request ownership audit diff --git a/notes/project-first-host-model-discussion.md b/notes/project-first-host-model-discussion.md index 39d1f8e5c6c..7314e1861be 100644 --- a/notes/project-first-host-model-discussion.md +++ b/notes/project-first-host-model-discussion.md @@ -532,14 +532,17 @@ Already partially landed: compatibility projection and persistence sync - project-host setup runtime capability advertisement, read fallback, and remote mutation gating for older runtime servers +- persistence merge that preserves independently persisted project/setup rows + across load, repo updates, and repo reorders while refreshing repo-backed + compatibility rows from repos Not complete yet: - SSH clone streamed-progress parity - provisioning flows and inline provisioning actions from the composer - bulk setup flows and setup for hosts that are not already known to the client -- independent project-host setup persistence beyond repo-backed compatibility - records +- first-class independent project-host setup creation/update/delete APIs beyond + the current repo-backed import/clone compatibility paths - full project settings split into global and host-specific ownership - host settings/capability UI aligned with project setup - complete cache/request ownership audit diff --git a/notes/project-first-host-model-handoff.md b/notes/project-first-host-model-handoff.md index a131c13a146..b0698e75706 100644 --- a/notes/project-first-host-model-handoff.md +++ b/notes/project-first-host-model-handoff.md @@ -415,12 +415,15 @@ Already partially landed: - repo-backed setup method metadata for imported/cloned project-host setups - project-host setup runtime capability advertisement, read fallback, and remote mutation gating for older runtime servers +- persistence merge that preserves independently persisted project/setup rows + across load, repo updates, and repo reorders while refreshing repo-backed + compatibility rows from repos Still needed: - finish SSH clone streamed-progress parity and provisioning flows -- independent project-host setup persistence beyond the repo-backed - compatibility records +- first-class independent project-host setup creation/update/delete APIs beyond + the current repo-backed import/clone compatibility paths - bulk setup flows and setup for newly added hosts - project settings split into global and host-specific ownership - host settings/capability UI aligned with project setup diff --git a/notes/project-first-host-model.md b/notes/project-first-host-model.md index 476afe7347a..d47cf394a67 100644 --- a/notes/project-first-host-model.md +++ b/notes/project-first-host-model.md @@ -766,16 +766,20 @@ Landed so far: advertise `project-host-setup.v1`; new clients fall back to repo-derived project/setup projection for reads when that capability is missing and block remote setup/clone mutations before contacting an older server. +- Added a persistence merge for project-host setup state. Repo-backed + compatibility rows are still refreshed from repos, but independently + persisted projects/setups now survive load, repo update, and repo reorder + paths instead of being overwritten by the repo projection. - Added tests for local repos, SSH repos, same-provider multi-host grouping, no-identity same-name non-grouping, selector cache behavior, persistence backfill, repo mutation synchronization, renderer hydration, runtime RPC - routing, project-host setup capability gating, setup method persistence, - local/runtime/SSH clone setup composition, remote clone IPC, and GitHub clone - URL inference. Sidebar row-builder tests now cover project-first multi-host - grouping and same-name repo separation without project identity. Workspace - target tests cover local-only fallback, focused-host setup selection, explicit - project-plus-host resolution, same-name non-merging, and unavailable setup - reasons. + routing, project-host setup capability gating, independent setup preservation, + setup method persistence, local/runtime/SSH clone setup composition, remote + clone IPC, and GitHub clone URL inference. Sidebar row-builder tests now cover + project-first multi-host grouping and same-name repo separation without + project identity. Workspace target tests cover local-only fallback, + focused-host setup selection, explicit project-plus-host resolution, same-name + non-merging, and unavailable setup reasons. Important limitation: @@ -791,15 +795,19 @@ Important limitation: not yet have local-clone parity for progress events. Abort now propagates to the relay `git.exec` request, and failed/aborted SSH clones clean up only when Orca can prove the target did not already exist before the clone. -- Project-host setup records are still regenerated from repo compatibility - records. The setup method now persists through that projection, but the final - independent setup table is still future work. +- Repo-backed project-host setup records are still regenerated from repo + compatibility records. Independently persisted setup records are now preserved + across load and repo mutations, but the UI/API still mostly creates setups + through repo-backed import/clone paths. A fully independent setup creation and + mutation table is still future work. Remaining end-to-end work: - broaden setup-on-host flows beyond known local, SSH, and active runtime hosts - finish SSH clone streamed-progress parity, provisioning, and bulk setup-on-host flows +- add first-class independent project-host setup creation/update/delete APIs + instead of routing most setup changes through repo import/update compatibility - split settings into explicit client, host, project, and project-host setup scopes - validate the default project-first sidebar view in Electron and continue diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index a325c3f70ad..33671b2ffff 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -16,7 +16,9 @@ import { join } from 'path' import { tmpdir } from 'os' import type { PersistedState, + Project, ProjectGroup, + ProjectHostSetup, Repo, TerminalPaneLayoutNode, TerminalTab, @@ -27,6 +29,7 @@ import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' import { TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT } from '../shared/terminal-scrollback-limits' import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history' import { + getDefaultPersistedState, getDefaultWorkspaceSession, ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION @@ -161,6 +164,30 @@ const makeRepo = (overrides: Partial = {}): Repo => ({ ...overrides }) +const makeProject = (overrides: Partial = {}): Project => ({ + id: 'project-1', + displayName: 'Project', + badgeColor: '#737373', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1, + ...overrides +}) + +const makeProjectHostSetup = (overrides: Partial = {}): ProjectHostSetup => ({ + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: '', + path: '/repo', + displayName: 'Project', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1, + ...overrides +}) + const makeTerminalTab = (overrides: Partial = {}): TerminalTab => ({ id: 'tab1', ptyId: 'pty1', @@ -319,6 +346,38 @@ describe('Store', () => { expect(persisted.projectHostSetups).toEqual(store.getProjectHostSetups()) }) + it('preserves independent project host setup records on load', async () => { + const independentProject = makeProject({ + id: 'cloud-project', + displayName: 'Cloud Project' + }) + const independentSetup = makeProjectHostSetup({ + id: 'cloud-project::gpu-vm', + projectId: independentProject.id, + hostId: 'runtime:gpu-vm', + repoId: '', + path: '/srv/cloud-project', + displayName: 'GPU VM' + }) + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + repos: [makeRepo({ id: 'r1', path: '/repo', displayName: 'Repo' })], + projects: [independentProject], + projectHostSetups: [independentSetup] + }) + + const store = await createStore() + + expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r1', 'cloud-project']) + expect(store.getProjectHostSetups().map((setup) => setup.id)).toEqual([ + 'r1', + 'cloud-project::gpu-vm' + ]) + store.flush() + const persisted = readDataFile() as PersistedState + expect(persisted.projectHostSetups).toContainEqual(independentSetup) + }) + it('returns default settings when no data file exists', async () => { const store = await createStore() const settings = store.getSettings() @@ -2329,6 +2388,37 @@ describe('Store', () => { ]) }) + it('repo mutations preserve independent project host setup records', async () => { + const independentProject = makeProject({ + id: 'cloud-project', + displayName: 'Cloud Project' + }) + const independentSetup = makeProjectHostSetup({ + id: 'cloud-project::gpu-vm', + projectId: independentProject.id, + hostId: 'runtime:gpu-vm', + repoId: '', + path: '/srv/cloud-project', + displayName: 'GPU VM' + }) + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + repos: [makeRepo({ id: 'r1' })], + projects: [independentProject], + projectHostSetups: [independentSetup] + }) + const store = await createStore() + + store.updateRepo('r1', { displayName: 'renamed' }) + store.reorderRepos(['r1']) + + expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r1', 'cloud-project']) + expect(store.getProjectHostSetups()).toEqual([ + expect.objectContaining({ id: 'r1', displayName: 'renamed' }), + independentSetup + ]) + }) + it('updateRepo preserves repo-backed project host setup method', async () => { const store = await createStore() store.addRepo(makeRepo()) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 680e332479a..fdb1a9dc565 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1590,21 +1590,50 @@ function migrationUnsupportedEntriesEqual( function projectHostSetupCompatibilityStateEqual( state: Pick, - projection: Pick + nextState: Pick ): boolean { return ( - JSON.stringify(state.projects ?? []) === JSON.stringify(projection.projects) && - JSON.stringify(state.projectHostSetups ?? []) === JSON.stringify(projection.projectHostSetups) + JSON.stringify(state.projects ?? []) === JSON.stringify(nextState.projects) && + JSON.stringify(state.projectHostSetups ?? []) === JSON.stringify(nextState.projectHostSetups) ) } -function buildProjectHostSetupCompatibilityState( +function isRepoBackedProjectHostSetup( + setup: ProjectHostSetup, + currentRepoIds: ReadonlySet +): boolean { + const repoId = typeof setup.repoId === 'string' ? setup.repoId : '' + return repoId.length > 0 && (currentRepoIds.has(repoId) || setup.id === repoId) +} + +function mergeProjectHostSetupCompatibilityState( + state: Pick, repos: readonly Repo[] ): Pick { const projection = projectHostSetupProjectionFromRepos(repos) + const currentRepoIds = new Set(repos.map((repo) => repo.id)) + const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) + const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id)) + // Why: legacy/repo-backed setup rows use the repo id as the setup id. Keep + // only independent setup rows here so repo deletion does not leave ghosts. + const independentSetups = (state.projectHostSetups ?? []).filter((setup) => { + if (projectedSetupIds.has(setup.id)) { + return false + } + return !isRepoBackedProjectHostSetup(setup, currentRepoIds) + }) + const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId)) + const independentProjects = (state.projects ?? []) + .filter( + (project) => independentProjectIds.has(project.id) && !projectedProjectIds.has(project.id) + ) + .map((project) => ({ + ...project, + sourceRepoIds: project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId)) + })) return { - projects: projection.projects, - projectHostSetups: projection.setups + projects: [...projection.projects, ...independentProjects], + projectHostSetups: [...projection.setups, ...independentSetups] } } @@ -2300,7 +2329,7 @@ export class Store { } const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) - const projectHostSetupCompatibility = buildProjectHostSetupCompatibilityState(repos) + const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { this.loadNeedsSave = true } @@ -2791,7 +2820,7 @@ export class Store { } private syncProjectHostSetupCompatibilityState(): void { - const compatibilityState = buildProjectHostSetupCompatibilityState(this.state.repos) + const compatibilityState = mergeProjectHostSetupCompatibilityState(this.state, this.state.repos) this.state.projects = compatibilityState.projects this.state.projectHostSetups = compatibilityState.projectHostSetups }