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
+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'