Backfill workspace project host ownership

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 184a5e2858
commit a5bbad37d3
6 changed files with 177 additions and 33 deletions
@@ -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
@@ -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
@@ -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
+11 -7
View File
@@ -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
+109 -9
View File
@@ -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'
})
)
})
+51 -17
View File
@@ -130,24 +130,50 @@ function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: strin
deleteWorktreeHistoryDir(worktreeId)
}
function getMissingProjectHostSetupMeta(
store: Store,
repo: Repo,
existing?: WorktreeMeta
): Partial<Pick<WorktreeMeta, 'projectId' | 'hostId' | 'projectHostSetupId'>> {
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<boolean> {
@@ -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))
)
})
}