diff --git a/notes/project-first-host-model-change-inventory.md b/notes/project-first-host-model-change-inventory.md index d925e5c4f57..8be4deece45 100644 --- a/notes/project-first-host-model-change-inventory.md +++ b/notes/project-first-host-model-change-inventory.md @@ -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 diff --git a/notes/project-first-host-model-handoff.md b/notes/project-first-host-model-handoff.md index 65ace713f35..8766bdc22fc 100644 --- a/notes/project-first-host-model-handoff.md +++ b/notes/project-first-host-model-handoff.md @@ -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 diff --git a/notes/project-first-host-model.md b/notes/project-first-host-model.md index 12df69c887a..21b8937144c 100644 --- a/notes/project-first-host-model.md +++ b/notes/project-first-host-model.md @@ -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: diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 98f34d59ac8..6214e9cd679 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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) } ) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 27c1ab0c78c..a325c3f70ad 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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()) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index e6d2bf1118f..680e332479a 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 | undefined { + return value === 'imported-existing-folder' || value === 'cloned' ? value : undefined +} + function sanitizeRepoUpdatesForPersistence< - T extends Partial> + 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: { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8a30bdf496a..2961549073d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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}`) diff --git a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts index 858f09d6eaf..c8ee8bcfd0f 100644 --- a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts @@ -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[] = [ diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index e5d269c2c68..9a11e934a0f 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -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 } }) }) diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 2394ffc1aeb..0362a4e71cd 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -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 }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 3454e786583..fbd3b047235 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -747,7 +747,8 @@ export const createRepoSlice: StateCreator = (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) diff --git a/src/shared/project-host-setup-projection.test.ts b/src/shared/project-host-setup-projection.test.ts index b4198265c17..465cac1e8bd 100644 --- a/src/shared/project-host-setup-projection.test.ts +++ b/src/shared/project-host-setup-projection.test.ts @@ -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({ diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index fd553c9d7bf..4e3a1b17705 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -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 } diff --git a/src/shared/types.ts b/src/shared/types.ts index 7d0cb1e0c2d..4ecbb573482 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -131,6 +131,7 @@ export type ProjectHostSetupExistingFolderArgs = { path: string kind?: RepoKind displayName?: string + setupMethod?: Exclude } 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 } export type ProjectGroupCreatedFrom = 'manual' | 'folder-scan' | 'migration'