Preserve independent project host setups

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 12:33:59 -07:00
co-authored by Orca
parent 4e9bc9b6c6
commit 13baeba95d
7 changed files with 166 additions and 27 deletions
@@ -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
@@ -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
+5 -2
View File
@@ -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
+5 -2
View File
@@ -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
+18 -10
View File
@@ -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
+90
View File
@@ -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> = {}): Repo => ({
...overrides
})
const makeProject = (overrides: Partial<Project> = {}): Project => ({
id: 'project-1',
displayName: 'Project',
badgeColor: '#737373',
sourceRepoIds: [],
createdAt: 1,
updatedAt: 1,
...overrides
})
const makeProjectHostSetup = (overrides: Partial<ProjectHostSetup> = {}): 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> = {}): 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())
+37 -8
View File
@@ -1590,21 +1590,50 @@ function migrationUnsupportedEntriesEqual(
function projectHostSetupCompatibilityStateEqual(
state: Pick<PersistedState, 'projects' | 'projectHostSetups'>,
projection: Pick<PersistedState, 'projects' | 'projectHostSetups'>
nextState: Pick<PersistedState, 'projects' | 'projectHostSetups'>
): 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<string>
): boolean {
const repoId = typeof setup.repoId === 'string' ? setup.repoId : ''
return repoId.length > 0 && (currentRepoIds.has(repoId) || setup.id === repoId)
}
function mergeProjectHostSetupCompatibilityState(
state: Pick<PersistedState, 'projects' | 'projectHostSetups'>,
repos: readonly Repo[]
): Pick<PersistedState, 'projects' | 'projectHostSetups'> {
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
}