mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 00:03:09 +00:00
Persist project host setup method
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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 } })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user