mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 00:02:54 +00:00
Backfill workspace project host ownership
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user