Persist project host setup method

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 b57f9e4914
commit 359cb1baef
14 changed files with 112 additions and 24 deletions
@@ -483,11 +483,14 @@ Already partially landed:
- inline `Run on` import-existing-folder setup for known hosts where the
selected project is not set up yet
- inline `Run on` clone setup for not-yet-set-up non-SSH local/runtime hosts
- repo-backed setup method metadata so imported/cloned setup methods survive
compatibility projection and persistence sync
Not complete yet:
- SSH clone/provision flows
- durable cloned setup metadata while `Repo` remains the compatibility source
- independent project-host setup persistence beyond the repo-backed
compatibility records
- 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
+3 -1
View File
@@ -408,11 +408,13 @@ Already partially landed:
- inline import-existing-folder setup from the composer
- inline clone setup from the composer for not-yet-set-up non-SSH
local/runtime hosts
- repo-backed setup method metadata for imported/cloned project-host setups
Still needed:
- finish SSH clone/provision flows
- durable cloned setup metadata while `Repo` remains the compatibility source
- independent project-host setup persistence beyond the repo-backed
compatibility records
- 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
+12 -9
View File
@@ -755,15 +755,18 @@ Landed so far:
lets the user paste a different URL, clones into the selected parent
directory through the existing local/runtime clone APIs, then links the
resulting checkout as the project's host setup.
- Added repo-backed setup method metadata so imported and cloned setup flows
survive persistence/projection sync as `imported-existing-folder` or `cloned`
while `Repo` remains the compatibility source of truth.
- 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, local/runtime clone setup composition, 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, setup method persistence, local/runtime clone setup composition, 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:
@@ -778,9 +781,9 @@ Important limitation:
folder today; true SSH clone should either reuse a remote git-provider exec
path or add a remote clone flow with progress, abort, safe path derivation,
cleanup, and auth parity.
- The current clone setup action records the resulting checkout by calling the
existing-folder setup path, so durable `setupMethod: cloned` semantics are not
complete while the compatibility projection remains repo-backed.
- 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.
Remaining end-to-end work:
+12 -3
View File
@@ -99,7 +99,8 @@ function buildProjectHostSetupResult(store: Store, repo: Repo): ProjectHostSetup
function alignRepoWithRequestedProject(
store: Store,
repo: Repo,
projectId: string
projectId: string,
setupMethod: ProjectHostSetupExistingFolderArgs['setupMethod'] = 'imported-existing-folder'
): ProjectHostSetupResult {
let setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
if (setup.projectId !== projectId) {
@@ -122,6 +123,13 @@ function alignRepoWithRequestedProject(
repo = updated
setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
}
const updated = store.updateRepo(repo.id, { projectHostSetupMethod: setupMethod })
if (!updated) {
throw new Error(
`Project setup repo disappeared before setup metadata could be linked: ${repo.id}`
)
}
repo = updated
return buildProjectHostSetupResult(store, repo)
}
@@ -329,7 +337,8 @@ const ProjectHostSetupExistingFolderIpcArgs = z.object({
hostId: z.string().min(1),
path: z.string().min(1),
kind: z.enum(['git', 'folder']).optional(),
displayName: z.string().min(1).optional()
displayName: z.string().min(1).optional(),
setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional()
})
const ProjectGroupScanNestedArgs = z.object({
@@ -691,7 +700,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
emitRepoAdded('folder_picker', result.alreadyExisted)
return alignRepoWithRequestedProject(store, result.repo, args.projectId)
return alignRepoWithRequestedProject(store, result.repo, args.projectId, args.setupMethod)
}
)
+15
View File
@@ -2329,6 +2329,21 @@ describe('Store', () => {
])
})
it('updateRepo preserves repo-backed project host setup method', async () => {
const store = await createStore()
store.addRepo(makeRepo())
store.updateRepo('r1', { projectHostSetupMethod: 'cloned' })
expect(store.getRepo('r1')?.projectHostSetupMethod).toBe('cloned')
expect(store.getProjectHostSetups()).toEqual([
expect.objectContaining({
id: 'r1',
setupMethod: 'cloned'
})
])
})
it('updateRepo drops repo icons that fail shared sanitization', async () => {
const store = await createStore()
store.addRepo(makeRepo())
+25 -1
View File
@@ -36,6 +36,7 @@ import type {
PersistedState,
Project,
ProjectHostSetup,
ProjectHostSetupMethod,
Repo,
ProjectGroup,
SparsePreset,
@@ -741,8 +742,19 @@ function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined {
return owner && repo ? { owner, repo } : undefined
}
function sanitizeRepoProjectHostSetupMethod(
value: unknown
): Exclude<ProjectHostSetupMethod, 'legacy-repo'> | undefined {
return value === 'imported-existing-folder' || value === 'cloned' ? value : undefined
}
function sanitizeRepoUpdatesForPersistence<
T extends Partial<Pick<Repo, 'badgeColor' | 'repoIcon' | 'upstream' | 'worktreeBasePath'>>
T extends Partial<
Pick<
Repo,
'badgeColor' | 'repoIcon' | 'upstream' | 'worktreeBasePath' | 'projectHostSetupMethod'
>
>
>(updates: T): T {
const sanitized = { ...updates }
if ('badgeColor' in sanitized) {
@@ -777,6 +789,14 @@ function sanitizeRepoUpdatesForPersistence<
delete sanitized.worktreeBasePath
}
}
if ('projectHostSetupMethod' in sanitized) {
const setupMethod = sanitizeRepoProjectHostSetupMethod(sanitized.projectHostSetupMethod)
if (setupMethod === undefined) {
delete sanitized.projectHostSetupMethod
} else {
sanitized.projectHostSetupMethod = setupMethod
}
}
return sanitized
}
@@ -2696,6 +2716,7 @@ export class Store {
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'projectGroupId'
| 'projectGroupOrder'
| 'projectHostSetupMethod'
>
> & { sourceControlAi?: Repo['sourceControlAi'] | null }
): Repo | null {
@@ -2780,11 +2801,13 @@ export class Store {
repoIcon: rawRepoIcon,
upstream: rawUpstream,
sourceControlAi: rawSourceControlAi,
projectHostSetupMethod: rawProjectHostSetupMethod,
...repoWithoutIcon
} = repo
const repoIcon = sanitizeRepoIcon(rawRepoIcon)
const upstream = sanitizeRepoUpstream(rawUpstream)
const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi)
const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod)
const gitUsername = isFolderRepo(repo)
? ''
: (this.gitUsernameCache.get(repo.path) ??
@@ -2799,6 +2822,7 @@ export class Store {
...(repoIcon !== undefined ? { repoIcon } : {}),
...(upstream !== undefined ? { upstream } : {}),
...(sourceControlAi !== undefined ? { sourceControlAi } : {}),
...(projectHostSetupMethod !== undefined ? { projectHostSetupMethod } : {}),
kind: isFolderRepo(repo) ? 'folder' : 'git',
gitUsername,
hookSettings: {
+9
View File
@@ -6484,6 +6484,15 @@ export class OrcaRuntimeService {
repo = updated
setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo)
}
const setupMethod = args.setupMethod ?? 'imported-existing-folder'
const updated = this.store.updateRepo(repo.id, { projectHostSetupMethod: setupMethod })
if (!updated) {
throw new Error(
`Project setup repo disappeared before setup metadata could be linked: ${repo.id}`
)
}
repo = updated
setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo)
const project = this.listProjects().find((entry) => entry.id === setup.projectId)
if (!project) {
throw new Error(`Project setup was created without a project record: ${setup.projectId}`)
@@ -15,7 +15,8 @@ const ProjectHostSetupExistingFolder = z.object({
}),
path: requiredString('Missing project path'),
kind: z.enum(['git', 'folder']).optional(),
displayName: OptionalString
displayName: OptionalString,
setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional()
})
export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [
+4 -2
View File
@@ -388,7 +388,8 @@ describe('repo RPC methods', () => {
projectId: 'project-1',
hostId: 'runtime:env-1',
path: '/repo',
kind: 'git'
kind: 'git',
setupMethod: 'cloned'
})
)
@@ -396,7 +397,8 @@ describe('repo RPC methods', () => {
projectId: 'project-1',
hostId: 'runtime:env-1',
path: '/repo',
kind: 'git'
kind: 'git',
setupMethod: 'cloned'
})
expect(response).toMatchObject({ ok: true, result: { result } })
})
+6 -4
View File
@@ -431,7 +431,7 @@ describe('repo slice runtime routing', () => {
path: clonedRepo.path,
displayName: clonedRepo.displayName,
setupState: 'ready',
setupMethod: 'legacy-repo',
setupMethod: 'cloned',
createdAt: 1,
updatedAt: 1
}
@@ -462,7 +462,8 @@ describe('repo slice runtime routing', () => {
hostId: 'local',
path: clonedRepo.path,
kind: 'git',
displayName: 'Project'
displayName: 'Project',
setupMethod: 'cloned'
})
})
@@ -484,7 +485,7 @@ describe('repo slice runtime routing', () => {
path: clonedRepo.path,
displayName: clonedRepo.displayName,
setupState: 'ready',
setupMethod: 'legacy-repo',
setupMethod: 'cloned',
createdAt: 1,
updatedAt: 1
}
@@ -534,7 +535,8 @@ describe('repo slice runtime routing', () => {
hostId: 'runtime:env-1',
path: clonedRepo.path,
kind: 'git',
displayName: 'Project'
displayName: 'Project',
setupMethod: 'cloned'
},
timeoutMs: 15_000
})
+2 -1
View File
@@ -747,7 +747,8 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
hostId: args.hostId,
path: repo.path,
kind: 'git',
displayName: args.displayName
displayName: args.displayName,
setupMethod: 'cloned'
})
} catch (err) {
console.error('Failed to clone project on host:', err)
@@ -71,6 +71,19 @@ describe('project host setup projection', () => {
})
})
it('preserves repo-backed setup method metadata', () => {
const projection = projectHostSetupProjectionFromRepos([
repo({
id: 'repo-1',
path: '/Users/alice/orca',
displayName: 'orca',
projectHostSetupMethod: 'cloned'
})
])
expect(projection.setups[0]?.setupMethod).toBe('cloned')
})
it('groups repo checkouts with the same provider identity under one project', () => {
const projection = projectHostSetupProjectionFromRepos([
repo({
+2 -1
View File
@@ -68,6 +68,7 @@ function mergeProjectRepo(project: Project, repo: Repo): Project {
function createSetupFromRepo(repo: Repo, projectId: string, now: number): ProjectHostSetup {
const hostId = getRepoExecutionHostId(repo)
const createdAt = repo.addedAt || now
const setupMethod = repo.projectHostSetupMethod ?? 'legacy-repo'
return {
id: repo.id,
projectId,
@@ -83,7 +84,7 @@ function createSetupFromRepo(repo: Repo, projectId: string, now: number): Projec
...(repo.gitUsername ? { gitUsername: repo.gitUsername } : {}),
...(repo.sourceControlAi ? { sourceControlAi: repo.sourceControlAi } : {}),
setupState: 'ready',
setupMethod: 'legacy-repo',
setupMethod,
createdAt,
updatedAt: createdAt
}
+3
View File
@@ -131,6 +131,7 @@ export type ProjectHostSetupExistingFolderArgs = {
path: string
kind?: RepoKind
displayName?: string
setupMethod?: Exclude<ProjectHostSetupMethod, 'legacy-repo'>
}
export type ProjectHostSetupCloneArgs = {
@@ -193,6 +194,8 @@ export type Repo = {
projectGroupOrder?: number
/** Repo-specific source-control AI overrides. Missing fields inherit global settings. */
sourceControlAi?: RepoSourceControlAiOverrides
/** Transitional source for ProjectHostSetup.setupMethod while Repo remains compatibility storage. */
projectHostSetupMethod?: Exclude<ProjectHostSetupMethod, 'legacy-repo'>
}
export type ProjectGroupCreatedFrom = 'manual' | 'folder-scan' | 'migration'