diff --git a/notes/project-first-host-model-change-inventory.md b/notes/project-first-host-model-change-inventory.md index 2e8033f197a..c8edaabbeec 100644 --- a/notes/project-first-host-model-change-inventory.md +++ b/notes/project-first-host-model-change-inventory.md @@ -474,6 +474,8 @@ Already partially landed: - project-host-aware workspace creation target resolver - optional workspace metadata for `projectId`, `hostId`, and `projectHostSetupId` +- discovery-time backfill for missing workspace `projectId`, `hostId`, and + `projectHostSetupId` on existing git and folder workspaces - setup-existing-folder API plumbing through local IPC/preload/runtime paths - project settings existing-folder setup form for known local, SSH, and active runtime hosts diff --git a/notes/project-first-host-model-discussion.md b/notes/project-first-host-model-discussion.md index 6e496293c94..5d25d00abbb 100644 --- a/notes/project-first-host-model-discussion.md +++ b/notes/project-first-host-model-discussion.md @@ -513,6 +513,8 @@ Already partially landed: - project-host-aware workspace creation target resolver - optional workspace metadata for `projectId`, `hostId`, and `projectHostSetupId` +- discovery-time backfill for missing workspace `projectId`, `hostId`, and + `projectHostSetupId` on existing git and folder workspaces - setup-existing-folder API plumbing through local IPC/preload/runtime paths - project settings existing-folder setup form for known local, SSH, and active runtime hosts diff --git a/notes/project-first-host-model-handoff.md b/notes/project-first-host-model-handoff.md index d3724d45595..3f8b3006b5b 100644 --- a/notes/project-first-host-model-handoff.md +++ b/notes/project-first-host-model-handoff.md @@ -396,6 +396,8 @@ Already partially landed: - project-host-aware workspace creation target resolver - optional workspace metadata for `projectId`, `hostId`, and `projectHostSetupId` +- discovery-time backfill for missing workspace `projectId`, `hostId`, and + `projectHostSetupId` on existing git and folder workspaces - setup-existing-folder API plumbing through local IPC/preload/runtime paths - project settings existing-folder setup form for known local, SSH, and active runtime hosts diff --git a/notes/project-first-host-model.md b/notes/project-first-host-model.md index 56553c9d322..a8eb47e841b 100644 --- a/notes/project-first-host-model.md +++ b/notes/project-first-host-model.md @@ -732,6 +732,10 @@ Landed so far: fields to `Worktree`/`WorktreeMeta`, threaded them through worktree merge paths, and stamped them for new local, SSH, folder, and runtime-created workspaces when project-host setup data is available. +- Added discovery-time backfill for existing git and folder workspaces that + already have metadata but are missing `projectId`, `hostId`, or + `projectHostSetupId`. The backfill only fills missing ownership fields during + authoritative workspace listing and preserves existing ownership when present. - Added first-class setup-on-existing-folder plumbing through local IPC, preload, runtime RPC, runtime service, and the renderer repo slice. The new `projectHostSetup.setupExistingFolder` path returns `{ project, setup, repo }` @@ -773,10 +777,11 @@ Important limitation: - This is not the full migration yet. `Repo` remains the source of truth for the compatibility records, and workspace creation still maps the resolved project-host setup back into the existing repo-centric `createWorktree` API. - Existing workspaces without the new ownership metadata still infer host/project - through their repo compatibility record. Settings now expose setup-specific - host panes and existing-folder setup, but still use repo compatibility records - underneath. + Existing workspaces can now backfill project/setup/host ownership when Orca + rediscovers them under an authoritative repo, but untouched metadata-only rows + and older runtimes still need the repo compatibility fallback. Settings now + expose setup-specific host panes and existing-folder setup, but still use repo + compatibility records underneath. - SSH clone setup is implemented through the relay git provider, but it does 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 @@ -794,9 +799,8 @@ Remaining end-to-end work: scopes - validate the default project-first sidebar view in Electron and continue refining disconnected-host affordances -- backfill explicit workspace project/setup/host ownership for existing - metadata-only workspaces where safe, while keeping repo compatibility fallback - for older servers and profiles +- broaden workspace ownership migration beyond discovery-time backfill while + keeping repo compatibility fallback for older servers and profiles - audit runtime routing, cache keys, request cancellation, and stale-response handling for host/setup-local ownership - add project-first CLI/API commands with compatibility aliases for existing diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index f58b343a3b4..bb30f47a3ae 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -228,6 +228,7 @@ describe('registerWorktreeHandlers', () => { getWorktreeMeta: vi.fn(), getAllWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), + getProjectHostSetups: vi.fn(), removeWorktreeMeta: vi.fn(), getAllWorktreeLineage: vi.fn(), removeWorktreeLineage: vi.fn() @@ -292,6 +293,7 @@ describe('registerWorktreeHandlers', () => { store.getWorktreeMeta, store.getAllWorktreeMeta, store.setWorktreeMeta, + store.getProjectHostSetups, store.removeWorktreeMeta, store.getAllWorktreeLineage, store.removeWorktreeLineage, @@ -338,6 +340,20 @@ describe('registerWorktreeHandlers', () => { store.getWorktreeMeta.mockReturnValue(undefined) store.getAllWorktreeMeta.mockReturnValue({}) store.setWorktreeMeta.mockReturnValue({}) + store.getProjectHostSetups.mockReturnValue([ + { + id: 'repo-1', + projectId: 'repo:repo-1', + hostId: 'local', + repoId: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 0, + updatedAt: 0 + } + ]) store.getAllWorktreeLineage.mockReturnValue({}) getGitUsernameMock.mockReturnValue('') getDefaultBaseRefMock.mockReturnValue('origin/main') @@ -3387,7 +3403,12 @@ describe('registerWorktreeHandlers', () => { } ]) store.getWorktreeMeta.mockReturnValue(undefined) - const stampedMeta = { lastActivityAt: 1_700_000_000_000 } + const stampedMeta = { + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1', + lastActivityAt: 1_700_000_000_000 + } store.setWorktreeMeta.mockReturnValue(stampedMeta) const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { @@ -3397,7 +3418,12 @@ describe('registerWorktreeHandlers', () => { expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::/workspace/discovered-wt', - expect.objectContaining({ lastActivityAt: expect.any(Number) }) + expect.objectContaining({ + lastActivityAt: expect.any(Number), + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) ) expect(listed[0]).toMatchObject({ id: 'repo-1::/workspace/discovered-wt', @@ -3405,9 +3431,10 @@ describe('registerWorktreeHandlers', () => { }) }) - it('does not re-stamp lastActivityAt when a worktree already has persisted meta', async () => { + it('backfills project-host ownership without re-stamping lastActivityAt for existing meta', async () => { // Why: only the *first* discovery should stamp. Re-stamping on every list - // would overwrite real activity and reshuffle the sidebar on refresh. + // would overwrite real activity and reshuffle the sidebar on refresh. Host + // ownership can still be filled because it is derived from the repo setup. listWorktreesMock.mockResolvedValue([ { path: '/workspace/existing-wt', @@ -3429,14 +3456,64 @@ describe('registerWorktreeHandlers', () => { sortOrder: 0, lastActivityAt: 42 }) + store.setWorktreeMeta.mockReturnValue({ + instanceId: 'existing-instance', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1', + lastActivityAt: 42 + }) const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { id: string lastActivityAt: number + projectId?: string + hostId?: string + projectHostSetupId?: string }[] - expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/existing-wt', { + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) expect(listed[0].lastActivityAt).toBe(42) + expect(listed[0]).toMatchObject({ + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) + }) + + it('does not rewrite discovery metadata when instance and project-host ownership already exist', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/existing-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue({ + instanceId: 'existing-instance', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1', + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 42 + }) + + await handlers['worktrees:list'](null, { repoId: 'repo-1' }) + + expect(store.setWorktreeMeta).not.toHaveBeenCalled() }) it('backfills instanceId on discovery for persisted metadata from older profiles', async () => { @@ -3462,6 +3539,9 @@ describe('registerWorktreeHandlers', () => { }) store.setWorktreeMeta.mockReturnValue({ instanceId: 'new-instance', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1', lastActivityAt: 42 }) @@ -3472,9 +3552,19 @@ describe('registerWorktreeHandlers', () => { expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::/workspace/existing-wt', - expect.objectContaining({ instanceId: expect.any(String) }) + expect.objectContaining({ + instanceId: expect.any(String), + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) ) - expect(listed[0].instanceId).toBe('new-instance') + expect(listed[0]).toMatchObject({ + instanceId: 'new-instance', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) }) it('stamps lastActivityAt on first discovery for folder-mode repos', async () => { @@ -3500,13 +3590,23 @@ describe('registerWorktreeHandlers', () => { kind: 'folder' }) store.getWorktreeMeta.mockReturnValue(undefined) - store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 }) + store.setWorktreeMeta.mockReturnValue({ + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1', + lastActivityAt: 1_700_000_000_000 + }) await handlers['worktrees:list'](null, { repoId: 'repo-1' }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::/workspace/folder', - expect.objectContaining({ lastActivityAt: expect.any(Number) }) + expect.objectContaining({ + lastActivityAt: expect.any(Number), + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) ) }) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 331a4e4a30a..60dc65d9fec 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -130,24 +130,50 @@ function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: strin deleteWorktreeHistoryDir(worktreeId) } +function getMissingProjectHostSetupMeta( + store: Store, + repo: Repo, + existing?: WorktreeMeta +): Partial> { + const ownership = getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + return { + ...(existing?.projectId === undefined ? { projectId: ownership.projectId } : {}), + ...(existing?.hostId === undefined ? { hostId: ownership.hostId } : {}), + ...(existing?.projectHostSetupId === undefined + ? { projectHostSetupId: ownership.projectHostSetupId } + : {}) + } +} + // Why: worktrees discovered on disk (not created via Orca's UI) have no // persisted WorktreeMeta, so mergeWorktree falls back to `lastActivityAt: 0`. // That makes them sort to the bottom of "Recent" even though the user just -// added the repo / folder. Stamp discovery time the first time we see a -// worktree so its very existence counts as a recency signal. Subsequent -// list calls find the persisted meta and skip the stamp. -function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string): WorktreeMeta { +// added the repo / folder. The same authoritative discovery pass is also the +// safest time to backfill project-host setup ownership for upgraded profiles. +function resolveWorktreeMetaWithDiscoveryBackfill( + store: Store, + repo: Repo, + worktreeId: string +): WorktreeMeta { const existing = store.getWorktreeMeta(worktreeId) + const missingOwnership = getMissingProjectHostSetupMeta(store, repo, existing) if (existing) { - if (!existing.instanceId) { + const updates = { + ...(!existing.instanceId ? { instanceId: randomUUID() } : {}), + ...missingOwnership + } + if (Object.keys(updates).length > 0) { // Why: profiles created before lineage shipped already have WorktreeMeta // rows. Backfill on authoritative discovery so upgraded workspaces can - // immediately participate in instance-validated lineage. - return store.setWorktreeMeta(worktreeId, { instanceId: randomUUID() }) + // immediately participate in instance-validated lineage and host routing. + return store.setWorktreeMeta(worktreeId, updates) } return existing } - return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() }) + return store.setWorktreeMeta(worktreeId, { + lastActivityAt: Date.now(), + ...missingOwnership + }) } async function isAlreadyRemovedWorktreePath(repo: Repo, worktreePath: string): Promise { @@ -451,7 +477,7 @@ function buildDetectedGitWorktrees( return detected } - meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId) + meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, worktreeId) return toDetectedWorktree({ repo, worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), @@ -468,7 +494,7 @@ function stampAndMergeVisibleDetectedWorktree( repo: Repo, detected: DetectedWorktree ) { - const meta = resolveWorktreeMetaWithDiscoveryStamp(store, detected.id) + const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id) return mergeWorktree(repo.id, detected, meta, repo.displayName) } @@ -541,12 +567,16 @@ function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] { return ids .map((worktreeId) => { const existing = allMeta[worktreeId] - const meta = existing?.instanceId - ? existing - : store.setWorktreeMeta(worktreeId, { - instanceId: getFolderWorkspaceInstanceIdentity(repo, worktreeId), - ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) - }) + const missingOwnership = getMissingProjectHostSetupMeta(store, repo, existing) + const meta = + existing?.instanceId && Object.keys(missingOwnership).length === 0 + ? existing + : store.setWorktreeMeta(worktreeId, { + instanceId: + existing?.instanceId ?? getFolderWorkspaceInstanceIdentity(repo, worktreeId), + ...missingOwnership, + ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) + }) return mergeFolderWorkspace(repo, worktreeId, meta) }) .sort((a, b) => { @@ -579,7 +609,11 @@ function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] { .filter((worktree) => worktree.visible) .map((worktree) => { const meta = store.getWorktreeMeta(worktree.id) - return mergeFolderWorkspace(repo, worktree.id, meta ?? store.setWorktreeMeta(worktree.id, {})) + return mergeFolderWorkspace( + repo, + worktree.id, + meta ?? store.setWorktreeMeta(worktree.id, getMissingProjectHostSetupMeta(store, repo)) + ) }) }