From 25801d2ef532affb1ced1fb2b503df3e5fd4d7a4 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 22 Jun 2026 12:42:27 -0700 Subject: [PATCH] Add host-qualified worktree identities Co-authored-by: Orca --- ...rktree-host-identity-rollout-validation.md | 54 ++++ src/main/daemon/pty-session-id.test.ts | 19 ++ src/main/ipc/remote-workspace.ts | 16 +- src/main/ipc/workspace-cleanup.test.ts | 5 +- src/main/ipc/workspace-cleanup.ts | 49 ++- src/main/ipc/worktree-metadata-merge.ts | 15 +- src/main/ipc/worktree-remote.ts | 30 +- src/main/ipc/worktrees.test.ts | 182 ++++++++--- src/main/ipc/worktrees.ts | 138 +++++++- .../memory/hydrate-local-pty-registry.test.ts | 12 +- src/main/memory/hydrate-local-pty-registry.ts | 22 +- src/main/runtime/orca-runtime.test.ts | 174 +++++++--- src/main/runtime/orca-runtime.ts | 297 ++++++++++++++---- src/main/runtime/worktree-teardown.ts | 28 +- src/main/usage-worktree-metadata.ts | 8 +- src/main/workspace-space-analysis.ts | 8 +- .../components/settings/McpConfigSection.tsx | 23 +- .../src/store/slices/worktrees.test.ts | 75 ++++- src/renderer/src/store/slices/worktrees.ts | 70 ++++- src/shared/pty-session-id-format.ts | 26 ++ src/shared/worktree-id.test.ts | 121 +++++++ src/shared/worktree-id.ts | 124 +++++++- 22 files changed, 1287 insertions(+), 209 deletions(-) create mode 100644 docs/worktree-host-identity-rollout-validation.md diff --git a/docs/worktree-host-identity-rollout-validation.md b/docs/worktree-host-identity-rollout-validation.md new file mode 100644 index 00000000000..1e62457d543 --- /dev/null +++ b/docs/worktree-host-identity-rollout-validation.md @@ -0,0 +1,54 @@ +# Worktree Host Identity Rollout Validation + +## Goal + +Use host-qualified worktree IDs so the same repo/path on different execution hosts cannot collide: + +```text +orca-worktree://v1?hostId=&repoId=&path= +``` + +Legacy IDs (`repoId::path`) remain accepted at public and persisted boundaries. Runtime and newly written state should resolve to canonical host-qualified IDs. + +## Compatibility Rules + +- New worktree IDs are canonical and include `hostId`, `repoId`, and `path`. +- Legacy `repoId::path` IDs are parsed and accepted for existing sessions, metadata, cleanup requests, terminal selectors, browser scopes, and runtime CLI/mobile calls. +- When Orca can prove a legacy ID maps to exactly one canonical repo/path/host, it returns and stores the canonical ID. +- Existing legacy metadata and lineage are read through alias lookups before stamping new canonical metadata. +- Old daemon PTY ids may remain legacy as PTY ids, but their owning `worktreeId` is canonicalized when the repo/path match is known. +- Teardown sweeps both canonical and legacy PTY/session prefixes so old sessions are still killed during removal. + +## Risks Addressed + +- Local and SSH worktrees with the same repo/path no longer share one ID. +- Legacy metadata remains visible during SSH reconnect/disconnected fallback. +- Legacy terminal/session ids do not create duplicate runtime buckets after migration. +- Cleanup preflight and skip-git deferrals accept both old and new ids. +- Runtime lineage records seeded under legacy ids still attach to canonical worktree rows. + +## Automated Evidence + +Passing after fixes: + +- `pnpm exec vitest run src/shared/worktree-id.test.ts src/main/daemon/pty-session-id.test.ts src/main/ipc/worktrees.test.ts` +- `pnpm exec vitest run src/main/persistence.test.ts -t "migrateWorktreeIdentity|host-partitioned workspace sessions"` +- `pnpm exec vitest run src/main/ipc/workspace-cleanup.test.ts src/main/ipc/remote-workspace.test.ts src/shared/remote-workspace-session-projection.test.ts src/main/memory/hydrate-local-pty-registry.test.ts` +- `pnpm exec vitest run src/main/runtime/orca-runtime.test.ts -t "worktree|ManagedWorktree|listManagedWorktrees|createManagedWorktree"` +- `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/worktrees.test.ts -t "migrateWorktreeIdentity"` +- `pnpm run typecheck:node` +- `pnpm run typecheck:web` +- targeted `oxlint` +- `git diff --check` + +Node engine warning observed locally: repo wants Node 24; validation environment is Node v26. + +## Electron Evidence + +- Launched Electron with an isolated user data directory. +- Verified the app identity matched this worktree before interacting. +- Added a temp git repo and created an Orca-managed worktree through the app. +- Confirmed the visible sidebar rendered the created workspace. +- Confirmed store state used a canonical `orca-worktree://v1?...hostId=local...` id. +- Removed the created workspace through a legacy `repoId::path` id and verified the canonical row disappeared. +- Shut down only the Electron process launched for validation. diff --git a/src/main/daemon/pty-session-id.test.ts b/src/main/daemon/pty-session-id.test.ts index 6ba42f0876a..ea0ea8c3c1d 100644 --- a/src/main/daemon/pty-session-id.test.ts +++ b/src/main/daemon/pty-session-id.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { makeWorktreeKey } from '../../shared/worktree-id' import { isSafePtySessionId, mintPtySessionId, parsePtySessionId } from './pty-session-id' const USER_DATA = '/tmp/orca-userdata' @@ -40,6 +41,15 @@ describe('isSafePtySessionId', () => { expect(isSafePtySessionId(id, USER_DATA)).toBe(true) }) + it('accepts minted ids with host-qualified worktree keys', () => { + const worktreeId = makeWorktreeKey({ + hostId: 'local', + repoId: 'repo-abc123', + path: '/Users/thebr/work/wt-1' + }) + expect(isSafePtySessionId(mintPtySessionId(worktreeId), USER_DATA)).toBe(true) + }) + it('accepts caller-supplied path-shaped ids that stay inside userData', () => { expect(isSafePtySessionId('some-repo::/Users/me/wt/abc@@deadbeef', USER_DATA)).toBe(true) }) @@ -87,6 +97,15 @@ describe('parsePtySessionId', () => { expect(parsePtySessionId(mintPtySessionId(wt))).toEqual({ worktreeId: wt }) }) + it('round-trips a minted host-qualified worktree key back to its worktreeId', () => { + const wt = makeWorktreeKey({ + hostId: 'runtime:gpu', + repoId: 'repo-abc', + path: '/srv/orca/worktree' + }) + expect(parsePtySessionId(mintPtySessionId(wt))).toEqual({ worktreeId: wt }) + }) + it('rejects bare UUIDs (no @@)', () => { expect(parsePtySessionId(mintPtySessionId())).toEqual({ worktreeId: null }) }) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 5ccc3c12cbe..760e2d3963b 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -18,7 +18,12 @@ import type { } from '../../shared/remote-workspace-types' import type { SshTarget } from '../../shared/ssh-types' import type { WorkspaceSessionState } from '../../shared/types' -import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id' +import { + getRepoIdFromWorktreeId, + makeRepoWorktreeKey, + splitWorktreeId +} from '../../shared/worktree-id' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' @@ -224,6 +229,11 @@ function getExplicitHydratedTargetIds(value: unknown): Set | null { } function targetForWorktree(store: Store, worktreeId: string): string | null { + const parsed = splitWorktreeId(worktreeId) + if (parsed?.hostId) { + const host = parseExecutionHostId(parsed.hostId) + return host?.kind === 'ssh' ? host.targetId : null + } const repoId = getRepoIdFromWorktreeId(worktreeId) return store.getRepo(repoId)?.connectionId ?? null } @@ -248,11 +258,11 @@ function importSessionForTarget( return importRemoteWorkspaceSession(remote, { resolveWorktreeId: (worktreePath) => { for (const repo of repoById.values()) { - const candidate = `${repo.id}::${worktreePath}` + const candidate = makeRepoWorktreeKey(repo, worktreePath) // Main does not own the live worktree list for SSH repos, so resolve // against repo identity only. Renderer hydration later validates IDs // against its fetched worktree list before panes mount. - if (splitWorktreeId(candidate)) { + if (splitWorktreeId(candidate)?.hostId === getRepoExecutionHostId(repo)) { return candidate } } diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index 0694ac5e9a5..a030ec9ffd1 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -10,6 +10,7 @@ import type { Repo, WorktreeMeta } from '../../shared/types' +import { makeRepoWorktreeKey } from '../../shared/worktree-id' const { listRepoWorktreesMock, @@ -453,7 +454,7 @@ describe('workspace cleanup scan', () => { expect(result.errors).toEqual([]) expect(result.candidates).toHaveLength(LARGE_WORKTREE_COUNT) expect(result.candidates[0]).toMatchObject({ - worktreeId: 'repo-1::/repo-feature-0', + worktreeId: makeRepoWorktreeKey(REPO, '/repo-feature-0'), path: '/repo-feature-0', branch: 'feature-0', tier: 'review', @@ -463,7 +464,7 @@ describe('workspace cleanup scan', () => { } }) expect(result.candidates[LARGE_WORKTREE_COUNT - 1]).toMatchObject({ - worktreeId: `repo-1::/repo-feature-${LARGE_WORKTREE_COUNT - 1}`, + worktreeId: makeRepoWorktreeKey(REPO, `/repo-feature-${LARGE_WORKTREE_COUNT - 1}`), path: `/repo-feature-${LARGE_WORKTREE_COUNT - 1}`, branch: `feature-${LARGE_WORKTREE_COUNT - 1}` }) diff --git a/src/main/ipc/workspace-cleanup.ts b/src/main/ipc/workspace-cleanup.ts index 7a6bdbf2b66..86afaa16e77 100644 --- a/src/main/ipc/workspace-cleanup.ts +++ b/src/main/ipc/workspace-cleanup.ts @@ -19,7 +19,14 @@ import type { WorktreeMeta } from '../../shared/types' import { mergeWorktree } from './worktree-logic' -import { splitWorktreeId } from '../../shared/worktree-id' +import { + getRepoWorktreeIdAliases, + isRepoWorktreeIdAlias, + makeRepoWorktreeKey, + parseWorktreeKey, + splitWorktreeId +} from '../../shared/worktree-id' +import { getRepoExecutionHostId } from '../../shared/execution-host' import { WORKSPACE_CLEANUP_CLASSIFIER_VERSION, applyWorkspaceCleanupPolicy, @@ -266,13 +273,17 @@ async function scanRepoWorkspaces(args: { const worktrees = gitWorktrees .map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` - const meta = store.getWorktreeMeta(worktreeId) - return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + const worktreeId = makeRepoWorktreeKey(repo, gitWorktree.path) + const meta = + store.getWorktreeMeta(worktreeId) ?? + store.getWorktreeMeta(`${repo.id}::${gitWorktree.path}`) + return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName, { + hostId: getRepoExecutionHostId(repo) + }) }) .filter((worktree) => { if (targetWorktreeId) { - return worktree.id === targetWorktreeId + return isRepoWorktreeIdAlias(repo, worktree.path, targetWorktreeId) } return ( !repoIsFolder && @@ -287,7 +298,9 @@ async function scanRepoWorkspaces(args: { worktree, scannedAt, provider, - skipGit: skipGitWorktreeIds.has(worktree.id), + skipGit: [...getRepoWorktreeIdAliases(repo, worktree.path)].some((id) => + skipGitWorktreeIds.has(id) + ), forceGitCheck: Boolean(targetWorktreeId) }).catch((error) => { console.error('Workspace cleanup candidate scan failed', error) @@ -530,20 +543,38 @@ function synthesizeDisconnectedSshCandidates( targetWorktreeId?: string ): WorkspaceCleanupCandidate[] { const repoWorktreePrefix = `${repo.id}::` + const repoHostId = getRepoExecutionHostId(repo) + const isRepoWorktreeId = (worktreeId: string): boolean => { + const parsed = parseWorktreeKey(worktreeId) + if (parsed) { + return parsed.repoId === repo.id && parsed.hostId === repoHostId + } + return worktreeId.startsWith(repoWorktreePrefix) + } if (targetWorktreeId) { - if (!targetWorktreeId.startsWith(repoWorktreePrefix)) { + if (!isRepoWorktreeId(targetWorktreeId)) { return [] } // Why: focused delete preflight names one workspace already; walking all // persisted metadata is unnecessary for disconnected SSH repos. - const meta = store.getWorktreeMeta(targetWorktreeId) + const parsed = splitWorktreeId(targetWorktreeId) + const canonicalTargetId = + parsed && parsed.repoId === repo.id + ? makeRepoWorktreeKey(repo, parsed.worktreePath) + : targetWorktreeId + const legacyTargetId = + parsed && parsed.repoId === repo.id ? `${repo.id}::${parsed.worktreePath}` : targetWorktreeId + const meta = + store.getWorktreeMeta(targetWorktreeId) ?? + store.getWorktreeMeta(canonicalTargetId) ?? + store.getWorktreeMeta(legacyTargetId) return meta ? [createDisconnectedSshCandidate(repo, scannedAt, targetWorktreeId, meta)] : [] } const candidates: WorkspaceCleanupCandidate[] = [] const allMeta = store.getAllWorktreeMeta() for (const worktreeId in allMeta) { - if (!Object.hasOwn(allMeta, worktreeId) || !worktreeId.startsWith(repoWorktreePrefix)) { + if (!Object.hasOwn(allMeta, worktreeId) || !isRepoWorktreeId(worktreeId)) { continue } const meta = allMeta[worktreeId] diff --git a/src/main/ipc/worktree-metadata-merge.ts b/src/main/ipc/worktree-metadata-merge.ts index d8382110588..0099dc53118 100644 --- a/src/main/ipc/worktree-metadata-merge.ts +++ b/src/main/ipc/worktree-metadata-merge.ts @@ -1,5 +1,10 @@ import { basename } from 'path' import type { GitWorktreeInfo, Worktree, WorktreeMeta } from '../../shared/types' +import { + makeLegacyWorktreeId, + makeWorktreeKey, + type ParsedCanonicalWorktreeKey +} from '../../shared/worktree-id' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { getLinkedWorkItemMetadata } from './worktree-linked-work-item-metadata' @@ -10,15 +15,19 @@ export function mergeWorktree( repoId: string, git: GitWorktreeInfo, meta: WorktreeMeta | undefined, - defaultDisplayName?: string + defaultDisplayName?: string, + canonical?: Pick ): Worktree { const branchShort = git.branch.replace(/^refs\/heads\//, '') + const hostId = meta?.hostId ?? canonical?.hostId return { - id: `${repoId}::${git.path}`, + id: hostId + ? makeWorktreeKey({ hostId, repoId, path: git.path }) + : makeLegacyWorktreeId(repoId, git.path), ...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), repoId, ...(meta?.projectId !== undefined ? { projectId: meta.projectId } : {}), - ...(meta?.hostId !== undefined ? { hostId: meta.hostId } : {}), + ...(hostId !== undefined ? { hostId } : {}), ...(meta?.projectHostSetupId !== undefined ? { projectHostSetupId: meta.projectHostSetupId } : {}), diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 74dfb7db9f8..fdd2e7067c8 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -77,7 +77,8 @@ import { mergeWorktree, areWorktreePathsEqual } from './worktree-logic' -import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' +import { getRepoIdFromWorktreeId, makeRepoWorktreeKey } from '../../shared/worktree-id' +import { getRepoExecutionHostId } from '../../shared/execution-host' import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' import { cleanupUnusedWorktreePushTargetRemoteWithExec, @@ -208,6 +209,13 @@ function recordWorkspaceLineageForCreatedWorktree( }) } +function getRepoWorktreeId( + repo: Pick, + path: string +): string { + return makeRepoWorktreeKey(repo, path) +} + function countNonEmptyGitOutputLines(output: string): number { return output.split(/\r?\n/).filter((line) => line.trim().length > 0).length } @@ -1451,7 +1459,7 @@ export async function createRemoteWorktree( validateWorkspaceLineageParentBeforeCreate( store, args.parentWorkspace, - worktreeWorkspaceKey(`${repo.id}::${remotePath}`) + worktreeWorkspaceKey(getRepoWorktreeId(repo, remotePath)) ) const sparseDirectories = args.sparseCheckout @@ -1620,7 +1628,7 @@ export async function createRemoteWorktree( throw new Error('Worktree created but not found in listing') } - const worktreeId = `${repo.id}::${created.path}` + const worktreeId = getRepoWorktreeId(repo, created.path) const now = Date.now() // Why: PR/MR-created worktrees can start from a head ref/SHA while Source // Control must compare against the review target branch. @@ -1694,7 +1702,11 @@ export async function createRemoteWorktree( } const { worktree } = timing.timeSync('persist_metadata', () => { const meta = store.setWorktreeMeta(worktreeId, metaUpdates) - return { worktree: mergeWorktree(repo.id, created, meta) } + return { + worktree: mergeWorktree(repo.id, created, meta, undefined, { + hostId: getRepoExecutionHostId(repo) + }) + } }) const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now) @@ -2059,7 +2071,7 @@ export async function createLocalWorktree( validateWorkspaceLineageParentBeforeCreate( store, args.parentWorkspace, - worktreeWorkspaceKey(`${repo.id}::${worktreePath}`) + worktreeWorkspaceKey(getRepoWorktreeId(repo, worktreePath)) ) if (remoteTrackingRefresh) { @@ -2227,7 +2239,7 @@ export async function createLocalWorktree( throw new Error('Worktree created but not found in listing') } - const worktreeId = `${repo.id}::${created.path}` + const worktreeId = getRepoWorktreeId(repo, created.path) const now = Date.now() // Why: PR/MR-created worktrees can start from a head ref/SHA while Source // Control must compare against the review target branch. @@ -2291,7 +2303,11 @@ export async function createLocalWorktree( } const { worktree } = timing.timeSync('persist_metadata', () => { const meta = store.setWorktreeMeta(worktreeId, metaUpdates) - return { worktree: mergeWorktree(repo.id, created, meta) } + return { + worktree: mergeWorktree(repo.id, created, meta, undefined, { + hostId: getRepoExecutionHostId(repo) + }) + } }) const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now) // Why: creation already paid for `git worktree list`; seed the exact roots diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 0f043c7c6fe..c931190293f 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -4,9 +4,18 @@ import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join, resolve } from 'path' import type { CreateWorktreeResult, GitWorktreeInfo, Worktree } from '../../shared/types' +import { makeRepoWorktreeKey } from '../../shared/worktree-id' const ORIGINAL_PLATFORM = process.platform +function localWorktreeKey(path: string, repoId = 'repo-1'): string { + return makeRepoWorktreeKey({ id: repoId, connectionId: null, executionHostId: null }, path) +} + +function sshWorktreeKey(path: string, repoId = 'repo-ssh', connectionId = 'conn-1'): string { + return makeRepoWorktreeKey({ id: repoId, connectionId, executionHostId: null }, path) +} + function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { configurable: true, @@ -252,6 +261,7 @@ describe('registerWorktreeHandlers', () => { getWorktreeMeta: vi.fn(), getAllWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), + migrateWorktreeIdentity: vi.fn(), getProjectHostSetups: vi.fn(), removeWorktreeMeta: vi.fn(), getAllWorktreeLineage: vi.fn(), @@ -323,6 +333,7 @@ describe('registerWorktreeHandlers', () => { store.getWorktreeMeta, store.getAllWorktreeMeta, store.setWorktreeMeta, + store.migrateWorktreeIdentity, store.getProjectHostSetups, store.removeWorktreeMeta, store.getAllWorktreeLineage, @@ -703,7 +714,7 @@ describe('registerWorktreeHandlers', () => { }) const persistedMeta = store.setWorktreeMeta.mock.calls.find( - ([worktreeId]) => worktreeId === 'repo-1::/workspace/improve-dashboard' + ([worktreeId]) => worktreeId === localWorktreeKey('/workspace/improve-dashboard') )?.[1] expect(persistedMeta).toBeDefined() expect(persistedMeta).not.toHaveProperty('automationProvenance') @@ -782,7 +793,7 @@ describe('registerWorktreeHandlers', () => { false ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::../worktrees/feature', + localWorktreeKey('../worktrees/feature'), expect.objectContaining({ orcaCreationWorkspaceLayout: { path: '../worktrees', nestWorkspaces: false } }) @@ -888,7 +899,9 @@ describe('registerWorktreeHandlers', () => { expect(addWorktreeMock).not.toHaveBeenCalled() expect(result.worktree).toEqual( expect.objectContaining({ - id: expect.stringMatching(/^repo-folder::\/workspace\/folder::workspace:[0-9a-f-]{36}$/), + id: expect.stringMatching( + /^orca-worktree:\/\/v1\?hostId=local&repoId=repo-folder&path=%2Fworkspace%2Ffolder::workspace:[0-9a-f-]{36}$/ + ), repoId: 'repo-folder', path: '/workspace/folder', displayName: 'folder-session', @@ -938,7 +951,7 @@ describe('registerWorktreeHandlers', () => { expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith( 1, - 'id:repo-1::/workspace/improve-dashboard', + `id:${localWorktreeKey('/workspace/improve-dashboard')}`, { command: 'claude --prefill test', env: { ORCA_AGENT_MODE: 'direct' }, @@ -954,7 +967,7 @@ describe('registerWorktreeHandlers', () => { ) expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith( 2, - 'id:repo-1::/workspace/improve-dashboard', + `id:${localWorktreeKey('/workspace/improve-dashboard')}`, { title: 'Setup', command: 'bash /workspace/repo/.git/orca/setup-runner.sh', @@ -1023,7 +1036,7 @@ describe('registerWorktreeHandlers', () => { { checkoutExistingBranch: true } ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/fix-bug-0', + localWorktreeKey('/workspace/fix-bug-0'), expect.objectContaining({ preserveBranchOnDelete: true }) ) expect(result).toMatchObject({ @@ -1083,7 +1096,7 @@ describe('registerWorktreeHandlers', () => { { checkoutExistingBranch: true } ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/my-folder', + localWorktreeKey('/workspace/my-folder'), expect.objectContaining({ preserveBranchOnDelete: true }) ) expect(result).toMatchObject({ @@ -1213,7 +1226,7 @@ describe('registerWorktreeHandlers', () => { { cwd: '/workspace/fix-title' } ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/fix-title', + localWorktreeKey('/workspace/fix-title'), expect.objectContaining({ baseRef: 'refs/remotes/origin/main', linkedPR: 42 @@ -1252,7 +1265,7 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/fix-title', + localWorktreeKey('/workspace/fix-title'), expect.objectContaining({ baseRef: 'refs/remotes/origin/main', linkedGitLabMR: 7 @@ -1302,7 +1315,7 @@ describe('registerWorktreeHandlers', () => { false ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/bitbucket-title', + localWorktreeKey('/workspace/bitbucket-title'), expect.objectContaining({ linkedBitbucketPR: 11 }) ) expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( @@ -1540,7 +1553,7 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + localWorktreeKey('/workspace/improve-dashboard'), expect.objectContaining({ displayName: 'Fix: dashboards for PRs' }) @@ -1574,7 +1587,7 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + localWorktreeKey('/workspace/improve-dashboard'), expect.objectContaining({ linkedIssue: 123, linkedPR: 456, @@ -1611,7 +1624,7 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + localWorktreeKey('/workspace/improve-dashboard'), expect.objectContaining({ createdWithAgent: 'codex' }) @@ -1667,7 +1680,7 @@ describe('registerWorktreeHandlers', () => { { cwd: '/workspace/improve-dashboard' } ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + localWorktreeKey('/workspace/improve-dashboard'), expect.objectContaining({ pushTarget: expect.objectContaining({ remoteName: 'pr-prateek-orca', @@ -1724,7 +1737,7 @@ describe('registerWorktreeHandlers', () => { expect.any(Object) ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + localWorktreeKey('/workspace/improve-dashboard'), expect.objectContaining({ pushTarget: expect.objectContaining({ remoteName: 'pr-contributor-orca', @@ -2558,7 +2571,7 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-ssh::/remote/improve-dashboard', + sshWorktreeKey('/remote/improve-dashboard'), expect.objectContaining({ linkedIssue: 123, linkedPR: 456, @@ -3134,7 +3147,7 @@ describe('registerWorktreeHandlers', () => { '/remote/sparse-dashboard' ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-ssh::/remote/sparse-dashboard', + sshWorktreeKey('/remote/sparse-dashboard'), expect.objectContaining({ sparseDirectories: ['apps/mobile', 'packages/shared'], baseRef: 'refs/remotes/origin/main', @@ -3931,9 +3944,9 @@ describe('registerWorktreeHandlers', () => { resolveFetch() const result = (await createPromise) as CreateWorktreeResult expect(addWorktreeMock).toHaveBeenCalled() - expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard') + expect(result.worktree.id).toBe(localWorktreeKey('/workspace/improve-dashboard')) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + localWorktreeKey('/workspace/improve-dashboard'), expect.objectContaining({ baseRef: 'refs/remotes/origin/main' }) ) }) @@ -3995,7 +4008,7 @@ describe('registerWorktreeHandlers', () => { ) expect(result).toEqual( expect.objectContaining({ - worktree: expect.objectContaining({ id: 'repo-1::/workspace/improve-dashboard' }) + worktree: expect.objectContaining({ id: localWorktreeKey('/workspace/improve-dashboard') }) }) ) }) @@ -4050,7 +4063,7 @@ describe('registerWorktreeHandlers', () => { } ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + localWorktreeKey('/workspace/improve-dashboard'), expect.objectContaining({ baseRef: 'refs/remotes/origin/main' }) ) expect(result.localBaseRefUpdateSuggestion).toEqual({ @@ -4108,7 +4121,7 @@ describe('registerWorktreeHandlers', () => { }) it('lists a synthetic worktree for folder-mode repos', async () => { - const rootWorktreeId = 'repo-1::/workspace/folder' + const rootWorktreeId = localWorktreeKey('/workspace/folder') const priorWorktreeIds = ['repo-1::/workspace/old-folder'] const rootMeta = makeWorktreeMeta({ instanceId: 'folder-instance', @@ -4203,7 +4216,7 @@ describe('registerWorktreeHandlers', () => { expect(listed).toEqual([ expect.objectContaining({ - id: 'repo-ssh::/remote/feature-wt', + id: sshWorktreeKey('/remote/feature-wt'), repoId: 'repo-ssh', path: '/remote/feature-wt', head: '', @@ -4233,8 +4246,7 @@ describe('registerWorktreeHandlers', () => { ] }) ]) - expect(store.getWorktreeMeta).not.toHaveBeenCalled() - expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-ssh::/remote/feature-wt', { + expect(store.setWorktreeMeta).toHaveBeenCalledWith(sshWorktreeKey('/remote/feature-wt'), { projectId: 'repo:repo-ssh', hostId: 'ssh:conn-1', projectHostSetupId: 'repo-ssh' @@ -4267,7 +4279,7 @@ describe('registerWorktreeHandlers', () => { expect(provider.listWorktrees).toHaveBeenCalledWith('/remote/repo') expect(listed).toEqual([ expect.objectContaining({ - id: 'repo-ssh::/remote/feature-wt', + id: sshWorktreeKey('/remote/feature-wt'), displayName: 'Feature workspace', lastActivityAt: 42 }) @@ -4307,7 +4319,7 @@ describe('registerWorktreeHandlers', () => { expect(listed).toEqual([ expect.objectContaining({ - id: 'repo-ssh::/remote/feature-wt', + id: sshWorktreeKey('/remote/feature-wt'), displayName: 'Good row' }) ]) @@ -4399,11 +4411,11 @@ describe('registerWorktreeHandlers', () => { expect(listed).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: 'repo-ssh::/remote/feature-wt', + id: sshWorktreeKey('/remote/feature-wt'), displayName: 'Remote cached' }), expect.objectContaining({ - id: 'repo-local::/workspace/local', + id: localWorktreeKey('/workspace/local', 'repo-local'), branch: 'refs/heads/main' }) ]) @@ -4437,8 +4449,8 @@ describe('registerWorktreeHandlers', () => { expect(store.getAllWorktreeMeta).toHaveBeenCalledTimes(1) expect(listed).toEqual([ - expect.objectContaining({ id: 'repo-ssh-a::/remote/a/one' }), - expect.objectContaining({ id: 'repo-ssh-b::/remote/b/two' }) + expect.objectContaining({ id: sshWorktreeKey('/remote/a/one', 'repo-ssh-a', 'conn-1') }), + expect.objectContaining({ id: sshWorktreeKey('/remote/b/two', 'repo-ssh-b', 'conn-2') }) ]) }) @@ -4464,6 +4476,10 @@ describe('registerWorktreeHandlers', () => { lastActivityAt: 1_700_000_000_000 } store.setWorktreeMeta.mockReturnValue(stampedMeta) + const expectedWorktreeId = makeRepoWorktreeKey( + { id: 'repo-1', connectionId: null, executionHostId: null }, + '/workspace/discovered-wt' + ) const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { id: string @@ -4471,7 +4487,7 @@ describe('registerWorktreeHandlers', () => { }[] expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/discovered-wt', + expectedWorktreeId, expect.objectContaining({ lastActivityAt: expect.any(Number), projectId: 'repo:repo-1', @@ -4480,11 +4496,63 @@ describe('registerWorktreeHandlers', () => { }) ) expect(listed[0]).toMatchObject({ - id: 'repo-1::/workspace/discovered-wt', + id: expectedWorktreeId, lastActivityAt: 1_700_000_000_000 }) }) + it('migrates matching legacy worktree metadata to the host-qualified key on discovery', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/legacy-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + const legacyWorktreeId = 'repo-1::/workspace/legacy-wt' + const canonicalWorktreeId = makeRepoWorktreeKey( + { id: 'repo-1', connectionId: null, executionHostId: null }, + '/workspace/legacy-wt' + ) + const legacyMeta = makeWorktreeMeta({ + instanceId: 'legacy-instance', + hostId: 'local', + lastActivityAt: 42 + }) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => { + if (worktreeId === legacyWorktreeId) { + return legacyMeta + } + return undefined + }) + store.setWorktreeMeta.mockReturnValue({ + ...legacyMeta, + projectId: 'repo:repo-1', + projectHostSetupId: 'repo-1' + }) + + const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { + id: string + lastActivityAt: number + }[] + + expect(store.migrateWorktreeIdentity).toHaveBeenCalledWith( + legacyWorktreeId, + canonicalWorktreeId + ) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + canonicalWorktreeId, + expect.objectContaining({ + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) + ) + expect(listed[0]).toMatchObject({ id: canonicalWorktreeId, lastActivityAt: 42 }) + }) + 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. Host @@ -4525,8 +4593,12 @@ describe('registerWorktreeHandlers', () => { hostId?: string projectHostSetupId?: string }[] + const expectedWorktreeId = makeRepoWorktreeKey( + { id: 'repo-1', connectionId: null, executionHostId: null }, + '/workspace/existing-wt' + ) - expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/existing-wt', { + expect(store.setWorktreeMeta).toHaveBeenCalledWith(expectedWorktreeId, { projectId: 'repo:repo-1', hostId: 'local', projectHostSetupId: 'repo-1' @@ -4596,12 +4668,16 @@ describe('registerWorktreeHandlers', () => { hostId?: string projectHostSetupId?: string }[] + const expectedWorktreeId = makeRepoWorktreeKey( + { id: 'repo-1', connectionId: null, executionHostId: null }, + '/workspace/existing-wt' + ) - expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/existing-wt', { + expect(store.setWorktreeMeta).toHaveBeenCalledWith(expectedWorktreeId, { projectId: 'github:stablyai/orca' }) expect(listed[0]).toMatchObject({ - id: 'repo-1::/workspace/existing-wt', + id: expectedWorktreeId, projectId: 'github:stablyai/orca', hostId: 'local', projectHostSetupId: 'repo-1', @@ -4704,14 +4780,18 @@ describe('registerWorktreeHandlers', () => { projectHostSetupId?: string lastActivityAt: number }[] + const expectedWorktreeId = makeRepoWorktreeKey( + { id: 'repo-ssh', connectionId: 'ssh-target-1', executionHostId: null }, + '/remote/orca' + ) expect(getSshGitProviderMock).toHaveBeenCalledWith('ssh-target-1') - expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-ssh::/remote/orca', { + expect(store.setWorktreeMeta).toHaveBeenCalledWith(expectedWorktreeId, { projectId: 'github:stablyai/orca' }) expect(listed).toEqual([ expect.objectContaining({ - id: 'repo-ssh::/remote/orca', + id: expectedWorktreeId, projectId: 'github:stablyai/orca', hostId: 'ssh:ssh-target-1', projectHostSetupId: 'repo-ssh', @@ -4784,9 +4864,13 @@ describe('registerWorktreeHandlers', () => { id: string instanceId?: string }[] + const expectedWorktreeId = makeRepoWorktreeKey( + { id: 'repo-1', connectionId: null, executionHostId: null }, + '/workspace/existing-wt' + ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/existing-wt', + expectedWorktreeId, expect.objectContaining({ instanceId: expect.any(String), projectId: 'repo:repo-1', @@ -4831,11 +4915,15 @@ describe('registerWorktreeHandlers', () => { projectHostSetupId: 'repo-1', lastActivityAt: 1_700_000_000_000 }) + const expectedWorktreeId = makeRepoWorktreeKey( + { id: 'repo-1', connectionId: null, executionHostId: null }, + '/workspace/folder' + ) await handlers['worktrees:list'](null, { repoId: 'repo-1' }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/folder', + expectedWorktreeId, expect.objectContaining({ lastActivityAt: expect.any(Number), projectId: 'repo:repo-1', @@ -4860,6 +4948,10 @@ describe('registerWorktreeHandlers', () => { ]) store.getWorktreeMeta.mockReturnValue(undefined) store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 }) + const expectedWorktreeId = makeRepoWorktreeKey( + { id: 'repo-1', connectionId: null, executionHostId: null }, + '/workspace/discovered-wt' + ) const listed = (await handlers['worktrees:listAll'](null, undefined)) as { id: string @@ -4867,11 +4959,11 @@ describe('registerWorktreeHandlers', () => { }[] expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/discovered-wt', + expectedWorktreeId, expect.objectContaining({ lastActivityAt: expect.any(Number) }) ) expect(listed[0]).toMatchObject({ - id: 'repo-1::/workspace/discovered-wt', + id: expectedWorktreeId, lastActivityAt: 1_700_000_000_000 }) }) @@ -5212,7 +5304,7 @@ describe('registerWorktreeHandlers', () => { false ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + localWorktreeKey('/workspace/improve-dashboard'), expect.objectContaining({ sparseDirectories: ['packages/web', 'apps/api'], sparseBaseRef: 'origin/main', @@ -5258,7 +5350,7 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + localWorktreeKey('/workspace/improve-dashboard'), expect.objectContaining({ sparseDirectories: ['packages/web'], sparseBaseRef: 'origin/main', @@ -5295,7 +5387,7 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + localWorktreeKey('/workspace/improve-dashboard'), expect.objectContaining({ sparseDirectories: ['packages/web'], sparseBaseRef: 'origin/main', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index f550d11079a..92a2f084df0 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -114,7 +114,13 @@ import { } from '../worktree-removal-safety' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' -import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' +import { + FOLDER_WORKSPACE_INSTANCE_SEPARATOR, + makeLegacyWorktreeId, + makeRepoWorktreeKey, + parseWorktreeKey +} from '../../shared/worktree-id' +import { getRepoExecutionHostId } from '../../shared/execution-host' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { getLocalProjectGitExecOptions, @@ -176,6 +182,39 @@ function dedupeGitWorktreesByPath(gitWorktrees: GitWorktreeInfo[]): GitWorktreeI return uniqueGitWorktrees } +function getRepoWorktreeId( + repo: Pick, + path: string +): string { + return makeRepoWorktreeKey(repo, path) +} + +function getRepoLegacyWorktreeId(repo: Pick, path: string): string { + return makeLegacyWorktreeId(repo.id, path) +} + +function migrateLegacyWorktreeMetaIfSafe(store: Store, repo: Repo, path: string): void { + const canonicalId = getRepoWorktreeId(repo, path) + if (store.getWorktreeMeta(canonicalId)) { + return + } + const legacyId = getRepoLegacyWorktreeId(repo, path) + const legacyMeta = store.getWorktreeMeta(legacyId) + if (!legacyMeta) { + return + } + const repoHostId = getRepoExecutionHostId(repo) + if (legacyMeta.hostId !== undefined && legacyMeta.hostId !== repoHostId) { + return + } + store.migrateWorktreeIdentity(legacyId, canonicalId) +} + +function isCanonicalWorktreeIdForRepoHost(repo: Repo, worktreeId: string): boolean { + const parsed = parseWorktreeKey(worktreeId) + return parsed?.repoId === repo.id && parsed.hostId === getRepoExecutionHostId(repo) +} + function getProjectHostSetupMetaUpdates( store: Store, repo: Repo, @@ -506,14 +545,17 @@ function pruneLineageForMissingRepoWorktrees( ) { return } - const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`)) + const liveIds = new Set(gitWorktrees.map((worktree) => getRepoWorktreeId(repo, worktree.path))) + const legacyLiveIds = new Set( + gitWorktrees.map((worktree) => getRepoLegacyWorktreeId(repo, worktree.path)) + ) const repoPrefix = `${repo.id}::` for (const childWorkspaceKey of Object.keys(store.getAllWorkspaceLineage?.() ?? {})) { const childScope = parseWorkspaceKey(childWorkspaceKey) if ( childScope?.type === 'worktree' && childScope.worktreeId.startsWith(repoPrefix) && - !liveIds.has(childScope.worktreeId) + !legacyLiveIds.has(childScope.worktreeId) ) { if (isWorkspaceKey(childWorkspaceKey)) { store.removeWorkspaceLineage?.(childWorkspaceKey) @@ -521,7 +563,10 @@ function pruneLineageForMissingRepoWorktrees( } } for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) { - if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) { + if ( + (childId.startsWith(repoPrefix) && !legacyLiveIds.has(childId)) || + (isCanonicalWorktreeIdForRepoHost(repo, childId) && !liveIds.has(childId)) + ) { // Why: path-derived IDs can disappear and later be reused by a different // checkout. Once a successful scan proves the child is gone, drop its // lineage so a future same-path worktree cannot inherit it. Missing @@ -530,7 +575,10 @@ function pruneLineageForMissingRepoWorktrees( store.removeWorktreeLineage(childId) store.removeWorkspaceLineage?.(worktreeWorkspaceKey(childId)) } - if (lineage.parentWorktreeId.startsWith(repoPrefix) && !liveIds.has(lineage.parentWorktreeId)) { + if ( + lineage.parentWorktreeId.startsWith(repoPrefix) && + !legacyLiveIds.has(lineage.parentWorktreeId) + ) { const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { // Why: keep the child lineage so the UI can show "Missing parent", but @@ -595,13 +643,16 @@ function listDisconnectedSshWorktrees( ): ReturnType[] { const byWorktreeId = new Map>() for (const candidate of metaIndex.get(repo.id) ?? []) { - const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, candidate.meta) + migrateLegacyWorktreeMetaIfSafe(store, repo, candidate.path) + const canonicalId = getRepoWorktreeId(repo, candidate.path) + const candidateMeta = store.getWorktreeMeta(canonicalId) ?? candidate.meta + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, candidateMeta) const meta = Object.keys(ownershipUpdates).length > 0 - ? { ...candidate.meta, ...ownershipUpdates } - : candidate.meta + ? { ...candidateMeta, ...ownershipUpdates } + : candidateMeta if (Object.keys(ownershipUpdates).length > 0) { - store.setWorktreeMeta(candidate.id, ownershipUpdates) + store.setWorktreeMeta(canonicalId, ownershipUpdates) } const worktree = mergeWorktree( repo.id, @@ -623,9 +674,12 @@ function buildDetectedGitWorktrees( const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo) const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) return dedupeGitWorktreesByPath(gitWorktrees).map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` + migrateLegacyWorktreeMetaIfSafe(store, repo, gitWorktree.path) + const worktreeId = getRepoWorktreeId(repo, gitWorktree.path) let meta = store.getWorktreeMeta(worktreeId) - const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName, { + hostId: getRepoExecutionHostId(repo) + }) const detected = toDetectedWorktree({ repo, worktree, @@ -641,7 +695,9 @@ function buildDetectedGitWorktrees( meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, worktreeId) return toDetectedWorktree({ repo, - worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), + worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName, { + hostId: getRepoExecutionHostId(repo) + }), meta, settings, knownOrcaLayouts, @@ -655,21 +711,32 @@ function stampAndMergeVisibleDetectedWorktree( repo: Repo, detected: DetectedWorktree ) { + migrateLegacyWorktreeMetaIfSafe(store, repo, detected.path) const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id) - return mergeWorktree(repo.id, detected, meta, repo.displayName) + return mergeWorktree(repo.id, detected, meta, repo.displayName, { + hostId: getRepoExecutionHostId(repo) + }) } function getFolderWorkspaceRootId(repo: Repo): string { - return `${repo.id}::${repo.path}` + return getRepoWorktreeId(repo, repo.path) } function getFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { return `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` } +function getLegacyFolderWorkspaceRootId(repo: Repo): string { + return getRepoLegacyWorktreeId(repo, repo.path) +} + function getFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string { const prefix = `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` - return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() + const legacyPrefix = `${getLegacyFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` + if (worktreeId.startsWith(prefix)) { + return worktreeId.slice(prefix.length) + } + return worktreeId.startsWith(legacyPrefix) ? worktreeId.slice(legacyPrefix.length) : randomUUID() } function isFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { @@ -680,6 +747,44 @@ function isFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { ) } +function isLegacyFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { + const rootId = getLegacyFolderWorkspaceRootId(repo) + return ( + worktreeId === rootId || + worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`) + ) +} + +function isFolderWorkspaceRootIdForRepo(repo: Repo, worktreeId: string): boolean { + return ( + worktreeId === getFolderWorkspaceRootId(repo) || + worktreeId === getLegacyFolderWorkspaceRootId(repo) + ) +} + +function getCanonicalFolderWorkspaceId(repo: Repo, worktreeId: string): string { + if (!isLegacyFolderWorkspaceIdForRepo(repo, worktreeId)) { + return worktreeId + } + const instanceId = getFolderWorkspaceInstanceIdentity(repo, worktreeId) + return worktreeId === getLegacyFolderWorkspaceRootId(repo) + ? getFolderWorkspaceRootId(repo) + : getFolderWorkspaceInstanceId(repo, instanceId) +} + +function migrateLegacyFolderWorkspaceMetaIfSafe(store: Store, repo: Repo): void { + for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) { + if (!isLegacyFolderWorkspaceIdForRepo(repo, worktreeId)) { + continue + } + const repoHostId = getRepoExecutionHostId(repo) + if (meta.hostId !== undefined && meta.hostId !== repoHostId) { + continue + } + store.migrateWorktreeIdentity(worktreeId, getCanonicalFolderWorkspaceId(repo, worktreeId)) + } +} + function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree { return { id: worktreeId, @@ -726,6 +831,7 @@ function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta } function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] { + migrateLegacyFolderWorkspaceMetaIfSafe(store, repo) const rootId = getFolderWorkspaceRootId(repo) const allMeta = store.getAllWorktreeMeta() const ids = Object.keys(allMeta).filter((worktreeId) => @@ -1296,7 +1402,7 @@ export function registerWorktreeHandlers( throw new Error(`Repo not found: ${repoId}`) } if (isFolderRepo(repo)) { - if (args.worktreeId === getFolderWorkspaceRootId(repo)) { + if (isFolderWorkspaceRootIdForRepo(repo, args.worktreeId)) { throw new Error( 'Cannot delete the project root workspace. Remove the folder project instead.' ) diff --git a/src/main/memory/hydrate-local-pty-registry.test.ts b/src/main/memory/hydrate-local-pty-registry.test.ts index 0854fa2493b..c751bb68efe 100644 --- a/src/main/memory/hydrate-local-pty-registry.test.ts +++ b/src/main/memory/hydrate-local-pty-registry.test.ts @@ -12,6 +12,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/types' +import { makeRepoWorktreeKey } from '../../shared/worktree-id' import type { SessionInfo } from '../daemon/types' import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' import type { Store } from '../persistence' @@ -220,7 +221,16 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { const entry = listRegisteredPtys().find((p) => p.ptyId === ptyId) expect(entry).toBeDefined() expect(entry!.pid).toBe(4242) - expect(entry!.worktreeId).toBe('repo-a::/local/Triton') + expect(entry!.worktreeId).toBe( + makeRepoWorktreeKey( + { + id: 'repo-a', + connectionId: null, + executionHostId: null + }, + '/local/Triton' + ) + ) }) it('hydrates large daemon session lists', async () => { diff --git a/src/main/memory/hydrate-local-pty-registry.ts b/src/main/memory/hydrate-local-pty-registry.ts index d416c51392b..d8a51a2689d 100644 --- a/src/main/memory/hydrate-local-pty-registry.ts +++ b/src/main/memory/hydrate-local-pty-registry.ts @@ -24,6 +24,7 @@ import type { SessionInfo } from '../daemon/types' import { listRegisteredPtys, registerPty } from './pty-registry' import { listRepoWorktrees } from '../repo-worktrees' import { parsePtySessionId } from '../../shared/pty-session-id-format' +import { getRepoWorktreeIdAliases, makeRepoWorktreeKey } from '../../shared/worktree-id' import type { Store } from '../persistence' // Why: `attachMainWindowServices` runs on every macOS dock re-activation @@ -69,11 +70,10 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick() + const canonicalWorktreeIdByAlias = new Map() for (const repo of repos) { const connectionId = repo.connectionId ?? null @@ -84,8 +84,10 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick { expect(terminals.terminals).toHaveLength(1) expect(terminals.terminals[0]).toMatchObject({ - worktreeId: TEST_WORKTREE_ID, + worktreeId: TEST_CANONICAL_WORKTREE_ID, worktreePath: TEST_WORKTREE_PATH }) const internals = runtime as unknown as { ptysById: Map } @@ -1450,8 +1455,8 @@ describe('OrcaRuntimeService', () => { expect(listWorktrees).not.toHaveBeenCalled() expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([ - TEST_WORKTREE_ID, - TEST_WORKTREE_ID + TEST_CANONICAL_WORKTREE_ID, + TEST_CANONICAL_WORKTREE_ID ]) const internals = runtime as unknown as { ptysById: Map } expect(internals.ptysById.has(ptyId)).toBe(true) @@ -1487,7 +1492,9 @@ describe('OrcaRuntimeService', () => { const terminals = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) expect(listWorktrees).not.toHaveBeenCalled() - expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([TEST_WORKTREE_ID]) + expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([ + TEST_CANONICAL_WORKTREE_ID + ]) const internals = runtime as unknown as { ptysById: Map } expect(internals.ptysById.has('cwd-only-pty')).toBe(true) expect(internals.ptysById.has('nested-controller-pty')).toBe(false) @@ -1524,7 +1531,9 @@ describe('OrcaRuntimeService', () => { const terminals = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) expect(listWorktrees).not.toHaveBeenCalled() - expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([TEST_WORKTREE_ID]) + expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([ + TEST_CANONICAL_WORKTREE_ID + ]) const internals = runtime as unknown as { ptysById: Map } expect(internals.ptysById.has('cwd-only-pty')).toBe(true) expect(internals.ptysById.has('nested-controller-pty')).toBe(false) @@ -1562,7 +1571,9 @@ describe('OrcaRuntimeService', () => { const terminals = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) expect(listWorktrees).not.toHaveBeenCalled() - expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([TEST_WORKTREE_ID]) + expect(terminals.terminals.map((terminal) => terminal.worktreeId)).toEqual([ + TEST_CANONICAL_WORKTREE_ID + ]) const internals = runtime as unknown as { ptysById: Map } expect(internals.ptysById.has('target-cwd-pty')).toBe(true) expect(internals.ptysById.has('sibling-cwd-pty')).toBe(false) @@ -1784,6 +1795,16 @@ describe('OrcaRuntimeService', () => { } const mainId = `${remoteRepo.id}::/home/user/repo` const childId = `${remoteRepo.id}::/home/user/repo-child` + const canonicalMainId = makeWorktreeKey({ + hostId: 'ssh:ssh-missing', + repoId: remoteRepo.id, + path: '/home/user/repo' + }) + const canonicalChildId = makeWorktreeKey({ + hostId: 'ssh:ssh-missing', + repoId: remoteRepo.id, + path: '/home/user/repo-child' + }) const metaById: Record = { [mainId]: makeWorktreeMeta({ displayName: 'Remote main' }), [childId]: makeWorktreeMeta({ displayName: 'Remote child', linkedPR: 42 }) @@ -1810,14 +1831,14 @@ describe('OrcaRuntimeService', () => { truncated: false, worktrees: [ { - id: mainId, + id: canonicalMainId, path: '/home/user/repo', branch: '', isMainWorktree: true, displayName: 'Remote main' }, { - id: childId, + id: canonicalChildId, path: '/home/user/repo-child', branch: '', isMainWorktree: false, @@ -1879,7 +1900,7 @@ describe('OrcaRuntimeService', () => { staleScan.resolve(MOCK_GIT_WORKTREES) - await expect(staleLookup).resolves.toMatchObject({ id: TEST_WORKTREE_ID }) + await expect(staleLookup).resolves.toMatchObject({ id: TEST_CANONICAL_WORKTREE_ID }) await expect(freshLookup).resolves.toMatchObject({ id: result.worktree.id, path: createdWorktree.path @@ -2790,8 +2811,13 @@ describe('OrcaRuntimeService', () => { '/remote/mobile-feature', { base: 'origin/main' } ) + const canonicalCreatedId = makeWorktreeKey({ + hostId: 'ssh:ssh-1', + repoId: TEST_REPO_ID, + path: created.path + }) expect(result.worktree).toMatchObject({ - id: `${TEST_REPO_ID}::${created.path}`, + id: canonicalCreatedId, path: created.path, linkedGitLabIssue: 321, linkedGitLabMR: 654 @@ -2830,7 +2856,16 @@ describe('OrcaRuntimeService', () => { isMainWorktree: false } const parentId = `${TEST_REPO_ID}::${parent.path}` - const childId = `${TEST_REPO_ID}::${created.path}` + const canonicalParentId = makeWorktreeKey({ + hostId: 'ssh:ssh-1', + repoId: TEST_REPO_ID, + path: parent.path + }) + const canonicalChildId = makeWorktreeKey({ + hostId: 'ssh:ssh-1', + repoId: TEST_REPO_ID, + path: created.path + }) const metaById: Record = { [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) } @@ -2882,19 +2917,22 @@ describe('OrcaRuntimeService', () => { }) expect(result.worktree).toMatchObject({ - id: childId, - parentWorktreeId: parentId, + id: canonicalChildId, + parentWorktreeId: canonicalParentId, lineage: expect.objectContaining({ - worktreeId: childId, - parentWorktreeId: parentId, - worktreeInstanceId: metaById[childId].instanceId, + worktreeId: canonicalChildId, + parentWorktreeId: canonicalParentId, + worktreeInstanceId: metaById[canonicalChildId].instanceId, parentWorktreeInstanceId: 'parent-instance', origin: 'cli' }) }) expect(result.lineage).toBe(result.worktree.lineage) expect(result.warnings).toEqual([]) - expect(remoteStore.setWorktreeLineage).toHaveBeenCalledWith(childId, expect.any(Object)) + expect(remoteStore.setWorktreeLineage).toHaveBeenCalledWith( + canonicalChildId, + expect.any(Object) + ) expect(addWorktree).not.toHaveBeenCalled() expect(listWorktrees).not.toHaveBeenCalled() } finally { @@ -3491,7 +3529,13 @@ describe('OrcaRuntimeService', () => { expect(gitProvider.removeWorktree).toHaveBeenCalledWith('/remote/feature', true) expect(removeWorktree).not.toHaveBeenCalled() expect(listWorktrees).not.toHaveBeenCalled() - expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(`${TEST_REPO_ID}::/remote/feature`) + expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith( + makeWorktreeKey({ + hostId: 'ssh:ssh-1', + repoId: TEST_REPO_ID, + path: '/remote/feature' + }) + ) }) it('rejects SSH-backed runtime removal of the main worktree before provider deletion', async () => { @@ -14705,7 +14749,7 @@ describe('OrcaRuntimeService', () => { worktrees: [ { workspaceKind: 'git', - worktreeId: 'repo-1::/tmp/worktree-a', + worktreeId: TEST_CANONICAL_WORKTREE_ID, repoId: 'repo-1', repo: 'repo', path: '/tmp/worktree-a', @@ -14758,7 +14802,7 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(runtimeStore as never) const { worktrees } = await runtime.getWorktreePs() - const summary = worktrees.find((w) => w.worktreeId === TEST_WORKTREE_ID) + const summary = worktrees.find((w) => w.worktreeId === TEST_CANONICAL_WORKTREE_ID) expect(summary?.linkedPR).toEqual({ number: 42, state: 'merged' }) }) @@ -14993,7 +15037,7 @@ describe('OrcaRuntimeService', () => { const { worktrees } = await runtime.getWorktreePs() const active = worktrees.filter((w) => w.isActive) expect(active).toHaveLength(1) - expect(active[0]?.worktreeId).toBe(TEST_WORKTREE_ID) + expect(active[0]?.worktreeId).toBe(TEST_CANONICAL_WORKTREE_ID) }) it('includes SSH-backed worktrees in the mobile worktree summary', async () => { @@ -15012,6 +15056,11 @@ describe('OrcaRuntimeService', () => { isBare: false, isMainWorktree: false } + const canonicalRemoteWorktreeId = makeWorktreeKey({ + hostId: 'ssh:ssh-1', + repoId: remoteRepo.id, + path: remoteWorktree.path + }) const metaById: Record = { [`${remoteRepo.id}::${remoteWorktree.path}`]: makeWorktreeMeta({ displayName: 'Remote mobile' @@ -15037,7 +15086,7 @@ describe('OrcaRuntimeService', () => { expect(summaries.worktrees).toEqual([ expect.objectContaining({ - worktreeId: `${remoteRepo.id}::${remoteWorktree.path}`, + worktreeId: canonicalRemoteWorktreeId, repoId: remoteRepo.id, repo: 'remote-vm', path: remoteWorktree.path, @@ -15824,6 +15873,16 @@ describe('OrcaRuntimeService', () => { } const childId = `${remoteRepo.id}::/home/user/repo-child` const parentId = `${remoteRepo.id}::/home/user/repo-parent` + const canonicalChildId = makeWorktreeKey({ + hostId: 'ssh:ssh-1', + repoId: remoteRepo.id, + path: '/home/user/repo-child' + }) + const canonicalParentId = makeWorktreeKey({ + hostId: 'ssh:ssh-1', + repoId: remoteRepo.id, + path: '/home/user/repo-parent' + }) const metaById: Record = { [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }), [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) @@ -15867,11 +15926,11 @@ describe('OrcaRuntimeService', () => { expect(listSshWorktrees).toHaveBeenCalledWith(remoteRepo.path) expect(setWorktreeLineage).toHaveBeenCalledWith( - childId, + canonicalChildId, expect.objectContaining({ - worktreeId: childId, + worktreeId: canonicalChildId, worktreeInstanceId: 'child-instance', - parentWorktreeId: parentId, + parentWorktreeId: canonicalParentId, parentWorktreeInstanceId: 'parent-instance', origin: 'manual' }) @@ -16128,6 +16187,16 @@ describe('OrcaRuntimeService', () => { const childPath = '/tmp/worktree-child' const parentId = `${TEST_REPO_ID}::${parentPath}` const childId = `${TEST_REPO_ID}::${childPath}` + const canonicalParentId = makeWorktreeKey({ + hostId: 'local', + repoId: TEST_REPO_ID, + path: parentPath + }) + const canonicalChildId = makeWorktreeKey({ + hostId: 'local', + repoId: TEST_REPO_ID, + path: childPath + }) const metaById: Record = { [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) @@ -16169,18 +16238,18 @@ describe('OrcaRuntimeService', () => { }) expect(setWorktreeLineage).toHaveBeenCalledWith( - childId, + canonicalChildId, expect.objectContaining({ - parentWorktreeId: parentId, + parentWorktreeId: canonicalParentId, parentWorktreeInstanceId: 'parent-instance', capture: { source: 'manual-action', confidence: 'explicit' } }) ) expect(setWorkspaceLineage).toHaveBeenCalledWith( expect.objectContaining({ - childWorkspaceKey: `worktree:${childId}`, + childWorkspaceKey: `worktree:${canonicalChildId}`, childInstanceId: 'child-instance', - parentWorkspaceKey: `worktree:${parentId}`, + parentWorkspaceKey: `worktree:${canonicalParentId}`, parentInstanceId: 'parent-instance', capture: { source: 'manual-action', confidence: 'explicit' } }) @@ -16574,6 +16643,16 @@ describe('OrcaRuntimeService', () => { const childPath = '/tmp/worktree-child' const parentId = `${TEST_REPO_ID}::${parentPath}` const childId = `${TEST_REPO_ID}::${childPath}` + const canonicalParentId = makeWorktreeKey({ + hostId: 'local', + repoId: TEST_REPO_ID, + path: parentPath + }) + const canonicalChildId = makeWorktreeKey({ + hostId: 'local', + repoId: TEST_REPO_ID, + path: childPath + }) const metaById: Record = { [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance', @@ -16624,24 +16703,32 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(runtimeStore as never) const listed = await runtime.listManagedWorktrees('id:repo-1') - const parent = listed.worktrees.find((worktree) => worktree.id === parentId) - const child = listed.worktrees.find((worktree) => worktree.id === childId) + const parent = listed.worktrees.find((worktree) => worktree.id === canonicalParentId) + const child = listed.worktrees.find((worktree) => worktree.id === canonicalChildId) expect(parent).toMatchObject({ parentWorktreeId: null, - childWorktreeIds: [childId], + childWorktreeIds: [canonicalChildId], lineage: null }) expect(child).toMatchObject({ - parentWorktreeId: parentId, + parentWorktreeId: canonicalParentId, childWorktreeIds: [], - lineage: lineageById[childId] + lineage: { + ...lineageById[childId], + worktreeId: canonicalChildId, + parentWorktreeId: canonicalParentId + } }) await expect(runtime.showManagedWorktree(`id:${childId}`)).resolves.toMatchObject({ - id: childId, - parentWorktreeId: parentId, + id: canonicalChildId, + parentWorktreeId: canonicalParentId, childWorktreeIds: [], - lineage: lineageById[childId] + lineage: { + ...lineageById[childId], + worktreeId: canonicalChildId, + parentWorktreeId: canonicalParentId + } }) }) @@ -21242,7 +21329,7 @@ describe('OrcaRuntimeService', () => { page: 'page-1' }) - expect(snapshotMock).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'page-1') + expect(snapshotMock).toHaveBeenCalledWith(TEST_CANONICAL_WORKTREE_ID, 'page-1') }) it('routes tab switch and capture start by explicit page id', async () => { @@ -21361,8 +21448,13 @@ describe('OrcaRuntimeService', () => { } ]) const runtime = createRuntime() + const worktreeBId = makeWorktreeKey({ + hostId: 'local', + repoId: TEST_REPO_ID, + path: '/tmp/worktree-b' + }) const getRegisteredTabsMock = vi.fn((worktreeId?: string) => - worktreeId === `${TEST_REPO_ID}::/tmp/worktree-b` ? new Map() : new Map([['page-1', 1]]) + worktreeId === worktreeBId ? new Map() : new Map([['page-1', 1]]) ) runtime.setAgentBrowserBridge({ @@ -21375,7 +21467,7 @@ describe('OrcaRuntimeService', () => { worktree: 'path:/tmp/worktree-b' }) ).rejects.toThrow('Browser page page-1 was not found in this worktree') - expect(getRegisteredTabsMock).toHaveBeenCalledWith(`${TEST_REPO_ID}::/tmp/worktree-b`) + expect(getRegisteredTabsMock).toHaveBeenCalledWith(worktreeBId) }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index fef93b96e19..d4fc8fecc12 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -143,9 +143,13 @@ import type { FeatureInteractionId } from '../../shared/feature-interactions' import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, + makeLegacyWorktreeId, + makeRepoWorktreeKey, + parseWorktreeKey, splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { getRepoExecutionHostId } from '../../shared/execution-host' import { getProjectHostSetupForRepo, getProjectHostSetupWorktreeMeta @@ -715,6 +719,7 @@ type RuntimeStore = { getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] removeWorktreeMeta: Store['removeWorktreeMeta'] + migrateWorktreeIdentity?: Store['migrateWorktreeIdentity'] getWorktreeLineage?: Store['getWorktreeLineage'] getAllWorktreeLineage?: Store['getAllWorktreeLineage'] setWorktreeLineage?: Store['setWorktreeLineage'] @@ -1217,16 +1222,24 @@ function getRuntimeWorktreeRemovalOptionsKey(force: boolean, runHooks: boolean): } function getRuntimeFolderWorkspaceRootId(repo: Repo): string { - return `${repo.id}::${repo.path}` + return makeRepoWorktreeKey(repo, repo.path) } function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` } +function getRuntimeLegacyFolderWorkspaceRootId(repo: Repo): string { + return makeLegacyWorktreeId(repo.id, repo.path) +} + function getRuntimeFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string { const prefix = `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` - return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() + const legacyPrefix = `${getRuntimeLegacyFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` + if (worktreeId.startsWith(prefix)) { + return worktreeId.slice(prefix.length) + } + return worktreeId.startsWith(legacyPrefix) ? worktreeId.slice(legacyPrefix.length) : randomUUID() } function isRuntimeFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { @@ -1237,6 +1250,100 @@ function isRuntimeFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): bool ) } +function isRuntimeLegacyFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { + const rootId = getRuntimeLegacyFolderWorkspaceRootId(repo) + return ( + worktreeId === rootId || + worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`) + ) +} + +function getRuntimeCanonicalFolderWorkspaceId(repo: Repo, worktreeId: string): string { + if (!isRuntimeLegacyFolderWorkspaceIdForRepo(repo, worktreeId)) { + return worktreeId + } + const instanceId = getRuntimeFolderWorkspaceInstanceIdentity(repo, worktreeId) + return worktreeId === getRuntimeLegacyFolderWorkspaceRootId(repo) + ? getRuntimeFolderWorkspaceRootId(repo) + : getRuntimeFolderWorkspaceInstanceId(repo, instanceId) +} + +function getRuntimeRepoWorktreeId(repo: Repo, path: string): string { + return makeRepoWorktreeKey(repo, path) +} + +function getRuntimeLegacyRepoWorktreeId(repo: Repo, path: string): string { + return makeLegacyWorktreeId(repo.id, path) +} + +function getRuntimeWorktreeMetaForRepoPath( + store: RuntimeStore, + repo: Repo, + path: string +): WorktreeMeta | undefined { + migrateRuntimeLegacyWorktreeMetaIfSafe(store, repo, path) + return ( + store.getWorktreeMeta(getRuntimeRepoWorktreeId(repo, path)) ?? + store.getWorktreeMeta(getRuntimeLegacyRepoWorktreeId(repo, path)) + ) +} + +function migrateRuntimeLegacyWorktreeMetaIfSafe( + store: RuntimeStore, + repo: Repo, + path: string +): void { + if (!store.migrateWorktreeIdentity) { + return + } + const canonicalId = getRuntimeRepoWorktreeId(repo, path) + if (store.getWorktreeMeta(canonicalId)) { + return + } + const legacyId = getRuntimeLegacyRepoWorktreeId(repo, path) + const legacyMeta = store.getWorktreeMeta(legacyId) + if (!legacyMeta) { + return + } + const repoHostId = getRepoExecutionHostId(repo) + if (legacyMeta.hostId !== undefined && legacyMeta.hostId !== repoHostId) { + return + } + store.migrateWorktreeIdentity(legacyId, canonicalId) +} + +function isRuntimeCanonicalWorktreeIdForRepoHost(repo: Repo, worktreeId: string): boolean { + const parsed = parseWorktreeKey(worktreeId) + return parsed?.repoId === repo.id && parsed.hostId === getRepoExecutionHostId(repo) +} + +function isRuntimeWorktreeIdAliasForResolved( + worktree: Pick, + worktreeId: string +): boolean { + if (worktree.id === worktreeId) { + return true + } + const parsed = splitWorktreeIdForFilesystem(worktreeId) + if (!parsed || parsed.repoId !== worktree.repoId) { + return false + } + if (parsed.hostId !== undefined && worktree.hostId !== parsed.hostId) { + return false + } + return runtimePathsEqual(parsed.worktreePath, worktree.path) +} + +function findResolvedWorktreeForIdAlias( + worktrees: readonly ResolvedWorktree[], + worktreeId: string +): ResolvedWorktree | null { + const matches = worktrees.filter((worktree) => + isRuntimeWorktreeIdAliasForResolved(worktree, worktreeId) + ) + return matches.length === 1 ? matches[0] : null +} + function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree { return { id: worktreeId, @@ -1283,9 +1390,25 @@ function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: Workt } function listRuntimeFolderWorkspaces( - store: Pick, + store: Pick< + RuntimeStore, + 'getAllWorktreeMeta' | 'getWorktreeMeta' | 'setWorktreeMeta' | 'migrateWorktreeIdentity' + >, repo: Repo ): Worktree[] { + for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) { + if (!isRuntimeLegacyFolderWorkspaceIdForRepo(repo, worktreeId)) { + continue + } + const repoHostId = getRepoExecutionHostId(repo) + if (meta.hostId !== undefined && meta.hostId !== repoHostId) { + continue + } + store.migrateWorktreeIdentity?.( + worktreeId, + getRuntimeCanonicalFolderWorkspaceId(repo, worktreeId) + ) + } const rootId = getRuntimeFolderWorkspaceRootId(repo) const allMeta = store.getAllWorktreeMeta() const ids = Object.keys(allMeta).filter((worktreeId) => @@ -7555,8 +7678,7 @@ export class OrcaRuntimeService { : null const cachedExplicitTargetWorktree = explicitTargetWorktreeId && cachedResolvedWorktrees - ? (cachedResolvedWorktrees.find((worktree) => worktree.id === explicitTargetWorktreeId) ?? - null) + ? findResolvedWorktreeForIdAlias(cachedResolvedWorktrees, explicitTargetWorktreeId) : null const parsedExplicitTargetWorktree = explicitTargetWorktreeId && !cachedExplicitTargetWorktree @@ -7566,7 +7688,7 @@ export class OrcaRuntimeService { worktreeSelector && !explicitTargetWorktreeId ? await this.resolveWorktreeSelector(worktreeSelector) : (cachedExplicitTargetWorktree ?? parsedExplicitTargetWorktree) - const targetWorktreeId = explicitTargetWorktreeId ?? targetWorktree?.id ?? null + const targetWorktreeId = targetWorktree?.id ?? explicitTargetWorktreeId ?? null const classificationResolvedWorktreeCache = this.resolvedWorktreeCache const classificationResolvedWorktrees = targetWorktreeId && @@ -7614,9 +7736,13 @@ export class OrcaRuntimeService { const terminals: RuntimeTerminalSummary[] = [] const ptyIdsFromLeaves = new Set() + const matchesTargetWorktree = (worktreeId: string): boolean => + !targetWorktreeId || + worktreeId === targetWorktreeId || + (targetWorktree ? isRuntimeWorktreeIdAliasForResolved(targetWorktree, worktreeId) : false) if (graphEpoch !== null) { for (const leaf of this.leaves.values()) { - if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) { + if (!matchesTargetWorktree(leaf.worktreeId)) { continue } if (opts.requireFreshPtyLiveness && leaf.ptyId && !refreshedPtyLiveness?.has(leaf.ptyId)) { @@ -7642,7 +7768,7 @@ export class OrcaRuntimeService { if (opts.requireFreshPtyLiveness && !refreshedPtyLiveness?.has(pty.ptyId)) { continue } - if (targetWorktreeId && pty.worktreeId !== targetWorktreeId) { + if (!matchesTargetWorktree(pty.worktreeId)) { continue } terminals.push(this.buildPtyTerminalSummary(pty, worktreesById)) @@ -11308,9 +11434,12 @@ export class OrcaRuntimeService { this.pruneLineageForMissingRepoWorktrees(repo, scan.worktrees) } const detected = scan.worktrees.map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` - const meta = this.store?.getWorktreeMeta(worktreeId) - const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + const meta = this.store + ? getRuntimeWorktreeMetaForRepoPath(this.store, repo, gitWorktree.path) + : undefined + const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName, { + hostId: getRepoExecutionHostId(repo) + }) const detectedWorktree = this.toRuntimeDetectedWorktree(repo, worktree) if (scan.ok) { return detectedWorktree @@ -12386,7 +12515,7 @@ export class OrcaRuntimeService { throw new Error('Worktree created but not found in listing') } - const worktreeId = `${repo.id}::${created.path}` + const worktreeId = getRuntimeRepoWorktreeId(repo, created.path) const now = Date.now() // Why: PR/MR-created worktrees can start from a head ref/SHA while Source // Control must compare against the review target branch. @@ -12453,7 +12582,9 @@ export class OrcaRuntimeService { ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) }) - const worktree = mergeWorktree(repo.id, created, meta) + const worktree = mergeWorktree(repo.id, created, meta, undefined, { + hostId: getRepoExecutionHostId(repo) + }) const { lineage, workspaceLineage, @@ -13190,7 +13321,21 @@ export class OrcaRuntimeService { } clearOptimisticReconcileToken(worktreeId: string): void { - this.optimisticReconcileTokens.delete(worktreeId) + for (const id of this.getKnownWorktreeIdAliases(worktreeId)) { + this.optimisticReconcileTokens.delete(id) + } + } + + private getKnownWorktreeIdAliases(worktreeId: string): Set { + const ids = new Set([worktreeId]) + const parsed = splitWorktreeIdForFilesystem(worktreeId) + const repo = parsed ? this.store?.getRepo(parsed.repoId) : null + if (!parsed || !repo) { + return ids + } + ids.add(getRuntimeRepoWorktreeId(repo, parsed.worktreePath)) + ids.add(getRuntimeLegacyRepoWorktreeId(repo, parsed.worktreePath)) + return ids } emitWorktreeBaseStatus(event: WorktreeBaseStatusEvent): void { @@ -13930,8 +14075,14 @@ export class OrcaRuntimeService { throw new Error('runtime_unavailable') } const removalTarget = parseExactWorktreeIdSelector(worktreeSelector) + const repo = removalTarget ? this.store.getRepo(removalTarget.repoId) : undefined + const canonicalRemovalTargetId = + removalTarget && repo ? getRuntimeRepoWorktreeId(repo, removalTarget.path) : undefined const cleanupTarget = removalTarget - ? this.preservedBranchCleanupByWorktreeId.get(removalTarget.id) + ? (this.preservedBranchCleanupByWorktreeId.get(removalTarget.id) ?? + (canonicalRemovalTargetId + ? this.preservedBranchCleanupByWorktreeId.get(canonicalRemovalTargetId) + : undefined)) : undefined if ( !removalTarget || @@ -13942,7 +14093,6 @@ export class OrcaRuntimeService { throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) } - const repo = this.store.getRepo(removalTarget.repoId) if (!repo) { throw new Error('repo_not_found') } @@ -15433,12 +15583,12 @@ export class OrcaRuntimeService { this.assertStableReadyGraph(graphEpoch) const ptyIds = new Set() for (const leaf of this.leaves.values()) { - if (leaf.worktreeId === worktree.id && leaf.ptyId) { + if (isRuntimeWorktreeIdAliasForResolved(worktree, leaf.worktreeId) && leaf.ptyId) { ptyIds.add(leaf.ptyId) } } for (const pty of this.ptysById.values()) { - if (pty.worktreeId === worktree.id && pty.connected) { + if (isRuntimeWorktreeIdAliasForResolved(worktree, pty.worktreeId) && pty.connected) { ptyIds.add(pty.ptyId) } } @@ -15479,7 +15629,7 @@ export class OrcaRuntimeService { if (!refreshedPtyLiveness) { throw new Error('terminal_liveness_unavailable') } - const livePtyIds = this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness) + const livePtyIds = this.getLivePtyIdsForWorktree(worktree, refreshedPtyLiveness) const targetOnly = opts.targetOnly === true const expectedIsLive = [...expected].every((ptyId) => livePtyIds.has(ptyId)) if (targetOnly ? !expectedIsLive : !setsEqual(livePtyIds, expected)) { @@ -15511,7 +15661,7 @@ export class OrcaRuntimeService { postStopFailure: 'terminal_liveness_unavailable' } } - const remainingLivePtyIds = this.getLivePtyIdsForWorktree(worktree.id, postStopLiveness) + const remainingLivePtyIds = this.getLivePtyIdsForWorktree(worktree, postStopLiveness) const stoppedTargetsStillLive = [...expected].filter((ptyId) => remainingLivePtyIds.has(ptyId)) if (targetOnly ? stoppedTargetsStillLive.length > 0 : remainingLivePtyIds.size > 0) { return { @@ -15535,13 +15685,13 @@ export class OrcaRuntimeService { } private getLivePtyIdsForWorktree( - worktreeId: string, + worktree: ResolvedWorktree, freshPtyIds?: ReadonlySet ): Set { const ptyIds = new Set() for (const leaf of this.leaves.values()) { if ( - leaf.worktreeId === worktreeId && + isRuntimeWorktreeIdAliasForResolved(worktree, leaf.worktreeId) && leaf.connected && leaf.ptyId && (!freshPtyIds || freshPtyIds.has(leaf.ptyId)) @@ -15551,7 +15701,7 @@ export class OrcaRuntimeService { } for (const pty of this.ptysById.values()) { if ( - pty.worktreeId === worktreeId && + isRuntimeWorktreeIdAliasForResolved(worktree, pty.worktreeId) && pty.connected && (!freshPtyIds || freshPtyIds.has(pty.ptyId)) ) { @@ -15566,12 +15716,12 @@ export class OrcaRuntimeService { const worktree = await this.resolveWorktreeSelector(worktreeSelector) this.assertStableReadyGraph(graphEpoch) for (const leaf of this.leaves.values()) { - if (leaf.worktreeId === worktree.id && leaf.ptyId) { + if (isRuntimeWorktreeIdAliasForResolved(worktree, leaf.worktreeId) && leaf.ptyId) { return true } } for (const pty of this.ptysById.values()) { - if (pty.worktreeId === worktree.id && pty.connected) { + if (isRuntimeWorktreeIdAliasForResolved(worktree, pty.worktreeId) && pty.connected) { return true } } @@ -15771,12 +15921,19 @@ export class OrcaRuntimeService { if (selector.startsWith('id:')) { const worktreeId = selector.slice(3) candidates = worktrees.filter((worktree) => worktree.id === worktreeId) + if (candidates.length === 0) { + candidates = worktrees.filter((worktree) => + isRuntimeWorktreeIdAliasForResolved(worktree, worktreeId) + ) + } if (candidates.length === 0) { const parsed = splitWorktreeIdForFilesystem(worktreeId) const repo = parsed ? this.store?.getRepo(parsed.repoId) : null const fallback = - repo?.connectionId && this.store?.getWorktreeMeta(worktreeId) - ? this.buildResolvedWorktreeFromId(worktreeId) + repo?.connectionId && parsed + ? getRuntimeWorktreeMetaForRepoPath(this.requireStore(), repo, parsed.worktreePath) + ? this.buildResolvedWorktreeFromId(worktreeId) + : null : null if (fallback !== null) { candidates = [fallback] @@ -15811,6 +15968,7 @@ export class OrcaRuntimeService { candidates = worktrees.filter( (worktree) => worktree.id === selector || + isRuntimeWorktreeIdAliasForResolved(worktree, selector) || runtimePathsEqual(worktree.path, selector) || branchSelectorMatches(worktree.branch, selector) ) @@ -16272,6 +16430,10 @@ export class OrcaRuntimeService { return null } const repo = this.store?.getRepos().find((entry) => entry.id === parsed.repoId) + const canonicalWorktreeId = + repo && (parsed.hostId === undefined || parsed.hostId === getRepoExecutionHostId(repo)) + ? getRuntimeRepoWorktreeId(repo, parsed.worktreePath) + : worktreeId const git = { path: parsed.worktreePath, head: '', @@ -16279,11 +16441,20 @@ export class OrcaRuntimeService { isBare: false, isMainWorktree: repo ? areWorktreePathsEqual(parsed.worktreePath, repo.path) : false } - const meta = this.store?.getWorktreeMeta(worktreeId) - const merged = mergeWorktree(parsed.repoId, git, meta, repo?.displayName) + const meta = + repo && this.store + ? getRuntimeWorktreeMetaForRepoPath(this.store, repo, parsed.worktreePath) + : this.store?.getWorktreeMeta(worktreeId) + const merged = mergeWorktree( + parsed.repoId, + git, + meta, + repo?.displayName, + repo ? { hostId: getRepoExecutionHostId(repo) } : undefined + ) return { ...merged, - id: worktreeId, + id: canonicalWorktreeId, parentWorktreeId: null, childWorktreeIds: [], lineage: null, @@ -16315,15 +16486,14 @@ export class OrcaRuntimeService { ) }) ) - worktreeIds.add(targetWorktreeId) + worktreeIds.add(targetWorktree.id) const resolved: ResolvedWorktree[] = [] for (const worktreeId of worktreeIds) { - const worktree = - worktreeId === targetWorktreeId - ? targetWorktree - : this.buildResolvedWorktreeFromId(worktreeId) - if (worktree) { + const worktree = isRuntimeWorktreeIdAliasForResolved(targetWorktree, worktreeId) + ? targetWorktree + : this.buildResolvedWorktreeFromId(worktreeId) + if (worktree && !resolved.some((entry) => entry.id === worktree.id)) { resolved.push(worktree) } } @@ -16358,10 +16528,10 @@ export class OrcaRuntimeService { if (!this.store) { return [] } + const store = this.store const now = Date.now() - const metaById = this.store.getAllWorktreeMeta() ?? {} const perRepoWorktrees = await Promise.all( - this.store.getRepos().map(async (repo) => { + store.getRepos().map(async (repo) => { if (isFolderRepo(repo)) { return listRuntimeFolderWorkspaces(this.requireStore(), repo).map((worktree) => ({ ...worktree, @@ -16391,15 +16561,17 @@ export class OrcaRuntimeService { this.pruneLineageForMissingRepoWorktrees(repo, gitWorktrees) } return gitWorktrees.map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` + const worktreeId = getRuntimeRepoWorktreeId(repo, gitWorktree.path) // Why: lineage validation needs a durable instance ID even when the // runtime sees a workspace before the renderer's discovery-stamp path. - const existingMeta = metaById[worktreeId] + const existingMeta = getRuntimeWorktreeMetaForRepoPath(store, repo, gitWorktree.path) const meta = existingMeta && existingMeta.instanceId ? existingMeta - : this.store?.setWorktreeMeta(worktreeId, {}) - const merged = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + : store.setWorktreeMeta(worktreeId, existingMeta ?? {}) + const merged = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName, { + hostId: getRepoExecutionHostId(repo) + }) return { ...merged, parentWorktreeId: null, @@ -16438,8 +16610,10 @@ export class OrcaRuntimeService { const childIdsByParentId = new Map() for (const [childId, lineage] of Object.entries(lineageById)) { - const child = worktreeById.get(childId) - const parent = worktreeById.get(lineage.parentWorktreeId) + const child = worktreeById.get(childId) ?? findResolvedWorktreeForIdAlias(worktrees, childId) + const parent = + worktreeById.get(lineage.parentWorktreeId) ?? + findResolvedWorktreeForIdAlias(worktrees, lineage.parentWorktreeId) if ( !child || !parent || @@ -16450,10 +16624,15 @@ export class OrcaRuntimeService { // checkouts from appearing as children of stale same-path lineage. continue } - validLineageByChildId.set(childId, lineage) - const children = childIdsByParentId.get(lineage.parentWorktreeId) ?? [] - children.push(childId) - childIdsByParentId.set(lineage.parentWorktreeId, children) + const normalizedLineage = { + ...lineage, + worktreeId: child.id, + parentWorktreeId: parent.id + } + validLineageByChildId.set(child.id, normalizedLineage) + const children = childIdsByParentId.get(parent.id) ?? [] + children.push(child.id) + childIdsByParentId.set(parent.id, children) } return worktrees.map((worktree) => { @@ -16476,14 +16655,19 @@ export class OrcaRuntimeService { ) { return } - const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`)) + const liveIds = new Set( + gitWorktrees.map((worktree) => getRuntimeRepoWorktreeId(repo, worktree.path)) + ) + const legacyLiveIds = new Set( + gitWorktrees.map((worktree) => getRuntimeLegacyRepoWorktreeId(repo, worktree.path)) + ) const repoPrefix = `${repo.id}::` for (const childWorkspaceKey of Object.keys(store.getAllWorkspaceLineage?.() ?? {})) { const childScope = parseWorkspaceKey(childWorkspaceKey) if ( childScope?.type === 'worktree' && childScope.worktreeId.startsWith(repoPrefix) && - !liveIds.has(childScope.worktreeId) + !legacyLiveIds.has(childScope.worktreeId) ) { if (isWorkspaceKey(childWorkspaceKey)) { store.removeWorkspaceLineage?.(childWorkspaceKey) @@ -16491,7 +16675,10 @@ export class OrcaRuntimeService { } } for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) { - if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) { + if ( + (childId.startsWith(repoPrefix) && !legacyLiveIds.has(childId)) || + (isRuntimeCanonicalWorktreeIdForRepoHost(repo, childId) && !liveIds.has(childId)) + ) { // Why: runtime selector scans can be the only scan before a path is // reused. Once a successful scan proves the child is gone, stale // lineage must not survive into the replacement checkout. @@ -16500,7 +16687,7 @@ export class OrcaRuntimeService { } if ( lineage.parentWorktreeId.startsWith(repoPrefix) && - !liveIds.has(lineage.parentWorktreeId) + !legacyLiveIds.has(lineage.parentWorktreeId) ) { const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { @@ -16722,9 +16909,13 @@ export class OrcaRuntimeService { const sessions = sessionsResult.value const livePtyIds = new Set(sessions.map((session) => session.id)) for (const session of sessions) { - const worktreeId = + const inferredWorktreeId = inferWorktreeIdFromPtyId(session.id) ?? findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd) + const worktreeId = inferredWorktreeId + ? (findResolvedWorktreeForIdAlias(resolvedWorktrees, inferredWorktreeId)?.id ?? + inferredWorktreeId) + : null if (targetWorktreeId && worktreeId !== targetWorktreeId) { continue } diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 7572b9bbd77..56c7d9971e5 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -1,6 +1,7 @@ import type { IPtyProvider } from '../providers/types' import type { OrcaRuntimeService } from './orca-runtime' import { listRegisteredPtys } from '../memory/pty-registry' +import { makeLegacyWorktreeId, splitWorktreeId } from '../../shared/worktree-id' export type WorktreeTeardownDeps = { runtime?: OrcaRuntimeService @@ -51,13 +52,15 @@ export async function killAllProcessesForWorktree( result.runtimeStopped = r.stopped } + const worktreeIds = getTeardownWorktreeIds(worktreeId) + result.providerStopped = await sweepProviderByPrefix( - worktreeId, + worktreeIds, deps.localProvider, deps.onPtyStopped ) result.registryStopped = await sweepRegistryForWorktree( - worktreeId, + worktreeIds, deps.localProvider, deps.onPtyStopped ) @@ -66,15 +69,15 @@ export async function killAllProcessesForWorktree( } async function sweepProviderByPrefix( - worktreeId: string, + worktreeIds: ReadonlySet, provider: IPtyProvider, onPtyStopped?: (ptyId: string) => void ): Promise { - const prefix = `${worktreeId}@@` + const prefixes = [...worktreeIds].map((worktreeId) => `${worktreeId}@@`) const sessions = await provider.listProcesses().catch(() => []) let killed = 0 for (const s of sessions) { - if (!s.id.startsWith(prefix)) { + if (!prefixes.some((prefix) => s.id.startsWith(prefix))) { continue } try { @@ -90,11 +93,13 @@ async function sweepProviderByPrefix( } async function sweepRegistryForWorktree( - worktreeId: string, + worktreeIds: ReadonlySet, localProvider: IPtyProvider, onPtyStopped?: (ptyId: string) => void ): Promise { - const entries = listRegisteredPtys().filter((r) => r.worktreeId === worktreeId) + const entries = listRegisteredPtys().filter( + (r) => r.worktreeId !== null && worktreeIds.has(r.worktreeId) + ) let killed = 0 for (const entry of entries) { try { @@ -108,6 +113,15 @@ async function sweepRegistryForWorktree( return killed } +function getTeardownWorktreeIds(worktreeId: string): Set { + const ids = new Set([worktreeId]) + const parsed = splitWorktreeId(worktreeId) + if (parsed) { + ids.add(makeLegacyWorktreeId(parsed.repoId, parsed.worktreePath)) + } + return ids +} + function clearStoppedPtyState(ptyId: string, onPtyStopped?: (ptyId: string) => void): void { if (!onPtyStopped) { return diff --git a/src/main/usage-worktree-metadata.ts b/src/main/usage-worktree-metadata.ts index 1047f54f37e..9ad763725cb 100644 --- a/src/main/usage-worktree-metadata.ts +++ b/src/main/usage-worktree-metadata.ts @@ -1,6 +1,10 @@ import { basename } from 'path' import type { Repo } from '../shared/types' -import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../shared/worktree-id' +import { + makeRepoWorktreeKey, + splitWorktreeId, + splitWorktreeIdForFilesystem +} from '../shared/worktree-id' import { isFolderRepo } from '../shared/repo-kind' import type { Store } from './persistence' @@ -26,7 +30,7 @@ export function loadKnownUsageWorktreesByRepo( for (const repo of localRepos) { worktreesByRepo.set(repo.id, [ { - worktreeId: `${repo.id}::${repo.path}`, + worktreeId: makeRepoWorktreeKey(repo, repo.path), path: repo.path, displayName: repo.displayName || getDefaultUsageWorktreeLabel(repo.path) } diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index f035a426ae3..f30c725656e 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -24,6 +24,8 @@ import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees' import { mergeWorktree } from './ipc/worktree-logic' +import { getRepoExecutionHostId } from '../shared/execution-host' +import { makeRepoWorktreeKey } from '../shared/worktree-id' const WORKTREE_SCAN_CONCURRENCY = 3 const LOCAL_FS_CONCURRENCY = 48 @@ -797,8 +799,10 @@ async function listWorktreesForSpaceScan( } function mergeForSpaceScan(repo: Repo, gitWorktree: GitWorktreeInfo, store: Store): Worktree { - const worktreeId = `${repo.id}::${gitWorktree.path}` - return mergeWorktree(repo.id, gitWorktree, store.getWorktreeMeta(worktreeId), repo.displayName) + const worktreeId = makeRepoWorktreeKey(repo, gitWorktree.path) + return mergeWorktree(repo.id, gitWorktree, store.getWorktreeMeta(worktreeId), repo.displayName, { + hostId: getRepoExecutionHostId(repo) + }) } function reportProgress( diff --git a/src/renderer/src/components/settings/McpConfigSection.tsx b/src/renderer/src/components/settings/McpConfigSection.tsx index 8e775a5c69b..a42431b7613 100644 --- a/src/renderer/src/components/settings/McpConfigSection.tsx +++ b/src/renderer/src/components/settings/McpConfigSection.tsx @@ -3,7 +3,7 @@ import { AlertCircle, FileCode2, LoaderCircle, Plus, RefreshCw } from 'lucide-re import { toast } from 'sonner' import { useMountedRef } from '@/hooks/useMountedRef' import type { Repo, Worktree } from '../../../../shared/types' -import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { getRepoIdFromWorktreeId, makeRepoWorktreeKey } from '../../../../shared/worktree-id' import { canInspectLocalMcpConfigRoot, inspectMcpConfigContent, @@ -63,9 +63,26 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele return ( worktreesForRepo.find((worktree) => worktree.isMainWorktree) ?? worktreesForRepo.find((worktree) => worktree.path === repo.path) ?? - worktreesForRepo[0] ?? { id: `${repo.id}::${repo.path}`, path: repo.path } + worktreesForRepo[0] ?? { + id: makeRepoWorktreeKey( + { + id: repo.id, + connectionId: repo.connectionId, + executionHostId: repo.executionHostId + }, + repo.path + ), + path: repo.path + } ) - }, [activeWorktreeId, repo.id, repo.path, worktreesForRepo]) + }, [ + activeWorktreeId, + repo.connectionId, + repo.executionHostId, + repo.id, + repo.path, + worktreesForRepo + ]) const targetWorktreeId = targetWorktree.id const targetRootPath = targetWorktree.path const detectedCount = useMemo(() => configs.filter((config) => config.exists).length, [configs]) diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 00e8c7903b2..13c545bf028 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -83,7 +83,7 @@ const mockApi = { // @ts-expect-error -- test shim globalThis.window = { api: mockApi } -import { createWorktreeSlice } from './worktrees' +import { createWorktreeSlice, WORKTREE_ID_KEYED_MAP_KEYS } from './worktrees' import type { PendingWorktreeCreation } from '@/lib/pending-worktree-creation' import { getHostedReviewCacheKey } from './hosted-review' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key' @@ -93,6 +93,7 @@ import { } from '../../components/browser-pane/webview-registry' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' +import { makeWorktreeKey } from '../../../../shared/worktree-id' function resetRemoteRuntimeMocks() { clearRuntimeCompatibilityCacheForTests() @@ -5001,6 +5002,34 @@ describe('setWorktreesPinnedAndReveal', () => { describe('migrateWorktreeIdentity', () => { const OLD = 'repo1::/ws/cunner' const NEW = 'repo1::/ws/worktree-creation-spinner' + const CANONICAL = makeWorktreeKey({ hostId: 'local', repoId: 'repo1', path: '/ws/cunner' }) + + function makeMigrationMapValue( + key: (typeof WORKTREE_ID_KEYED_MAP_KEYS)[number], + worktreeId: string + ): unknown { + switch (key) { + case 'worktreeLineageById': + return makeLineage({ worktreeId, parentWorktreeId: 'repo1::/ws/parent' }) + case 'tabsByWorktree': + return [{ id: 'tab1', worktreeId }] + case 'browserTabsByWorktree': + return [{ id: 'browser1', worktreeId }] + case 'recentlyClosedBrowserTabsByWorktree': + return [ + { + workspace: { id: 'closed-browser', worktreeId }, + pages: [{ id: 'closed-page', worktreeId }] + } + ] + case 'unifiedTabsByWorktree': + return [{ id: 'unified1', worktreeId }] + case 'groupsByWorktree': + return [{ id: 'group1', worktreeId }] + default: + return { marker: key } + } + } it('re-keys worktree-scoped maps, pointers, the Set, and openFiles old->new', () => { const store = createTestStore() @@ -5083,6 +5112,50 @@ describe('migrateWorktreeIdentity', () => { expect(s.terminalLayoutsByTabId.tab1).toBeDefined() }) + it('moves every declared worktree-id keyed map and lineage relationship to the canonical id', () => { + const store = createTestStore() + const childId = 'repo1::/ws/child' + const mapState = Object.fromEntries( + WORKTREE_ID_KEYED_MAP_KEYS.map((key) => [key, { [OLD]: makeMigrationMapValue(key, OLD) }]) + ) as Partial + + store.setState({ + ...mapState, + worktreeLineageById: { + [OLD]: makeLineage({ worktreeId: OLD, parentWorktreeId: 'repo1::/ws/parent' }), + [childId]: makeLineage({ worktreeId: childId, parentWorktreeId: OLD }) + }, + workspaceLineageByChildKey: { + [worktreeWorkspaceKey(OLD)]: makeWorkspaceLineage({ + childWorkspaceKey: worktreeWorkspaceKey(OLD), + parentWorkspaceKey: folderWorkspaceKey('folder-parent') + }), + [worktreeWorkspaceKey(childId)]: makeWorkspaceLineage({ + childWorkspaceKey: worktreeWorkspaceKey(childId), + parentWorkspaceKey: worktreeWorkspaceKey(OLD) + }) + } + } as unknown as Partial) + + store.getState().migrateWorktreeIdentity(OLD, CANONICAL) + const s = store.getState() + + for (const key of WORKTREE_ID_KEYED_MAP_KEYS) { + const map = s[key] as Record + expect(map[OLD], `${key} retained the legacy key`).toBeUndefined() + expect(map[CANONICAL], `${key} did not receive the canonical key`).toBeDefined() + } + expect(s.worktreeLineageById[CANONICAL]).toMatchObject({ worktreeId: CANONICAL }) + expect(s.worktreeLineageById[childId]).toMatchObject({ parentWorktreeId: CANONICAL }) + expect(s.workspaceLineageByChildKey[worktreeWorkspaceKey(OLD)]).toBeUndefined() + expect(s.workspaceLineageByChildKey[worktreeWorkspaceKey(CANONICAL)]).toMatchObject({ + childWorkspaceKey: worktreeWorkspaceKey(CANONICAL) + }) + expect(s.workspaceLineageByChildKey[worktreeWorkspaceKey(childId)]).toMatchObject({ + parentWorkspaceKey: worktreeWorkspaceKey(CANONICAL) + }) + }) + it('is a no-op when the ids match', () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 2c9b3ef5e67..5ac00f564a2 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -15,7 +15,9 @@ import type { RemoveWorktreeResult, WorktreeLineage, WorkspaceLineage, - WorktreeMeta + WorkspaceKey, + WorktreeMeta, + Repo } from '../../../../shared/types' import type { RuntimeWorktreeListResult } from '../../../../shared/runtime-types' import { @@ -1247,7 +1249,7 @@ function encodePushTargetClearForRuntimeRpc( // new `*ByWorktree` map is not silently missed when a worktree id changes. Maps keyed // by tab id or file id are deliberately NOT here — tabs and files keep their ids across // a worktree rename. -const WORKTREE_ID_KEYED_MAP_KEYS = [ +export const WORKTREE_ID_KEYED_MAP_KEYS = [ 'worktreeLineageById', 'tabsByWorktree', 'deleteStateByWorktreeId', @@ -1283,6 +1285,36 @@ const WORKTREE_ID_KEYED_MAP_KEYS = [ 'defaultTerminalTabsAppliedByWorktreeId' ] as const satisfies readonly (keyof AppState)[] +function migrateWorkspaceLineageForWorktreeIdentity( + lineageByChildKey: Record, + oldWorkspaceKey: WorkspaceKey, + newWorkspaceKey: WorkspaceKey +): Record { + let changed = false + const next: Record = {} + for (const [childKey, lineage] of Object.entries(lineageByChildKey)) { + const nextChildKey = childKey === oldWorkspaceKey ? newWorkspaceKey : childKey + const nextLineage = + lineage.childWorkspaceKey === oldWorkspaceKey || + lineage.parentWorkspaceKey === oldWorkspaceKey + ? { + ...lineage, + childWorkspaceKey: + lineage.childWorkspaceKey === oldWorkspaceKey + ? newWorkspaceKey + : lineage.childWorkspaceKey, + parentWorkspaceKey: + lineage.parentWorkspaceKey === oldWorkspaceKey + ? newWorkspaceKey + : lineage.parentWorkspaceKey + } + : lineage + changed = changed || nextChildKey !== childKey || nextLineage !== lineage + next[nextChildKey] = nextLineage + } + return changed ? next : lineageByChildKey +} + /** * Re-key every worktree-id-keyed map (plus the Set, openFiles[].worktreeId, and * the active/renaming pointers) from `oldWorktreeId` to `newWorktreeId` after a @@ -1301,6 +1333,8 @@ function buildWorktreeRenameState( if (oldWorktreeId === newWorktreeId) { return {} } + const oldWorkspaceKey = worktreeWorkspaceKey(oldWorktreeId) + const newWorkspaceKey = worktreeWorkspaceKey(newWorktreeId) const renamed: Record = {} const renameKey = ( key: keyof AppState, @@ -1317,7 +1351,14 @@ function buildWorktreeRenameState( } const withNewWorktreeId = (value: T): T => value.worktreeId === oldWorktreeId ? { ...value, worktreeId: newWorktreeId } : value + const withNewLineageWorktreeIds = (lineage: WorktreeLineage): WorktreeLineage => ({ + ...lineage, + worktreeId: lineage.worktreeId === oldWorktreeId ? newWorktreeId : lineage.worktreeId, + parentWorktreeId: + lineage.parentWorktreeId === oldWorktreeId ? newWorktreeId : lineage.parentWorktreeId + }) const renameValueByKey: Partial> = { + worktreeLineageById: withNewLineageWorktreeIds, tabsByWorktree: (tabs: { worktreeId: string }[]) => tabs.map(withNewWorktreeId), browserTabsByWorktree: (workspaces: { worktreeId: string }[]) => workspaces.map(withNewWorktreeId), @@ -1335,6 +1376,28 @@ function buildWorktreeRenameState( for (const key of WORKTREE_ID_KEYED_MAP_KEYS) { renameKey(key, renameValueByKey[key] as ((value: unknown) => unknown) | undefined) } + const currentLineage = + (renamed.worktreeLineageById as Record | undefined) ?? + s.worktreeLineageById + const lineageWithUpdatedParents = Object.values(currentLineage).some( + (lineage) => lineage.worktreeId === oldWorktreeId || lineage.parentWorktreeId === oldWorktreeId + ) + ? Object.fromEntries( + Object.entries(currentLineage).map(([worktreeId, lineage]) => [ + worktreeId, + withNewLineageWorktreeIds(lineage) + ]) + ) + : currentLineage + if (lineageWithUpdatedParents !== s.worktreeLineageById) { + renamed.worktreeLineageById = lineageWithUpdatedParents + } + const currentWorkspaceLineage = s.workspaceLineageByChildKey ?? {} + const workspaceLineageByChildKey = migrateWorkspaceLineageForWorktreeIdentity( + currentWorkspaceLineage, + oldWorkspaceKey, + newWorkspaceKey + ) // Re-key these on rename so a renamed worktree keeps its editor-undo + push/pull // state. (Both removal paths — buildWorktreePurgeState and the single // removeWorktree reducer — now also purge them on removal.) @@ -1407,6 +1470,9 @@ function buildWorktreeRenameState( ...(sleepingAgentSessionsByPaneKey !== s.sleepingAgentSessionsByPaneKey ? { sleepingAgentSessionsByPaneKey } : {}), + ...(workspaceLineageByChildKey !== currentWorkspaceLineage + ? { workspaceLineageByChildKey } + : {}), ...(s.activeWorktreeId === oldWorktreeId ? { activeWorktreeId: newWorktreeId } : {}), // The active workspace key derives from the worktree id, so keep it in sync when the active worktree is renamed. ...(s.activeWorkspaceKey === worktreeWorkspaceKey(oldWorktreeId) diff --git a/src/shared/pty-session-id-format.ts b/src/shared/pty-session-id-format.ts index b414e15e2fc..65c55c0ac33 100644 --- a/src/shared/pty-session-id-format.ts +++ b/src/shared/pty-session-id-format.ts @@ -11,8 +11,31 @@ * can import. */ +import { normalizeExecutionHostId } from './execution-host' + export const PTY_SESSION_ID_SEPARATOR = '@@' export const WORKTREE_ID_SEPARATOR = '::' +const WORKTREE_KEY_PREFIX = 'orca-worktree://v1?' + +function isCanonicalWorktreeKey(candidate: string): boolean { + if (!candidate.startsWith(WORKTREE_KEY_PREFIX)) { + return false + } + const params = new URLSearchParams(candidate.slice(WORKTREE_KEY_PREFIX.length)) + const keys = [...params.keys()] + return ( + keys.length === 3 && + keys[0] === 'hostId' && + keys[1] === 'repoId' && + keys[2] === 'path' && + params.getAll('hostId').length === 1 && + params.getAll('repoId').length === 1 && + params.getAll('path').length === 1 && + normalizeExecutionHostId(params.get('hostId')) !== null && + (params.get('repoId')?.length ?? 0) > 0 && + (params.get('path')?.length ?? 0) > 0 + ) +} /** * Recover the owning worktreeId from a minted session id. @@ -30,6 +53,9 @@ export function parsePtySessionId(sessionId: string): { worktreeId: string | nul return { worktreeId: null } } const candidate = sessionId.slice(0, idx) + if (isCanonicalWorktreeKey(candidate)) { + return { worktreeId: candidate } + } // Why: require non-empty halves on both sides of `::` so degenerate // ids like `::@@…`, `repo::@@…`, or `::path@@…` don't synthesize a // phantom worktreeId for memory attribution. diff --git a/src/shared/worktree-id.test.ts b/src/shared/worktree-id.test.ts index 99e6b4e49e7..0f9e06e0fdd 100644 --- a/src/shared/worktree-id.test.ts +++ b/src/shared/worktree-id.test.ts @@ -3,6 +3,11 @@ import { WORKTREE_ID_SEPARATOR, getRepoIdFromWorktreeId, getWorktreePathBasenameFromId, + isLegacyWorktreeId, + makeRepoWorktreeKey, + makeWorktreeKey, + parseAnyWorktreeId, + parseWorktreeKey, splitWorktreeId, splitWorktreeIdForFilesystem } from './worktree-id' @@ -18,6 +23,14 @@ describe('getRepoIdFromWorktreeId', () => { expect(getRepoIdFromWorktreeId('repo-123::/abs/path')).toBe('repo-123') }) + it('returns the repo id for a host-qualified worktree key', () => { + expect( + getRepoIdFromWorktreeId( + makeWorktreeKey({ hostId: 'runtime:gpu', repoId: 'repo-123', path: '/abs/path' }) + ) + ).toBe('repo-123') + }) + it('returns the whole input when there is no separator', () => { expect(getRepoIdFromWorktreeId('just-a-repo-id')).toBe('just-a-repo-id') }) @@ -43,6 +56,96 @@ describe('getRepoIdFromWorktreeId', () => { }) }) +describe('makeWorktreeKey', () => { + it('creates a canonical host-qualified key with stable parameter order', () => { + expect( + makeWorktreeKey({ hostId: 'local', repoId: 'repo-123', path: '/Users/alice/orca' }) + ).toBe('orca-worktree://v1?hostId=local&repoId=repo-123&path=%2FUsers%2Falice%2Forca') + }) + + it('encodes host ids, repo ids, POSIX paths, and Windows paths without delimiter collisions', () => { + const key = makeWorktreeKey({ + hostId: 'ssh:openclaw%202', + repoId: 'repo::with spaces', + path: 'C:\\Users\\Alice\\repo::workspace' + }) + + expect(parseWorktreeKey(key)).toEqual({ + hostId: 'ssh:openclaw%202', + repoId: 'repo::with spaces', + worktreePath: 'C:\\Users\\Alice\\repo::workspace' + }) + }) + + it('builds keys from repo execution ownership', () => { + expect( + makeRepoWorktreeKey( + { id: 'repo-123', connectionId: null, executionHostId: 'runtime:gpu' }, + '/srv/repo' + ) + ).toBe('orca-worktree://v1?hostId=runtime%3Agpu&repoId=repo-123&path=%2Fsrv%2Frepo') + }) +}) + +describe('parseWorktreeKey', () => { + it('parses a valid canonical key', () => { + expect( + parseWorktreeKey('orca-worktree://v1?hostId=runtime%3Agpu&repoId=repo-123&path=%2Fsrv%2Frepo') + ).toEqual({ hostId: 'runtime:gpu', repoId: 'repo-123', worktreePath: '/srv/repo' }) + }) + + it('rejects legacy ids', () => { + expect(parseWorktreeKey('repo-123::/abs/path')).toBeNull() + }) + + it('rejects missing, empty, duplicate, reordered, and unknown fields', () => { + expect(parseWorktreeKey('orca-worktree://v1?hostId=local&repoId=repo')).toBeNull() + expect(parseWorktreeKey('orca-worktree://v1?hostId=local&repoId=repo&path=')).toBeNull() + expect( + parseWorktreeKey('orca-worktree://v1?hostId=local&repoId=repo&path=%2Fx&path=%2Fy') + ).toBeNull() + expect(parseWorktreeKey('orca-worktree://v1?repoId=repo&hostId=local&path=%2Fx')).toBeNull() + expect( + parseWorktreeKey('orca-worktree://v1?hostId=local&repoId=repo&path=%2Fx&extra=1') + ).toBeNull() + }) + + it('rejects unknown versions and invalid hosts', () => { + expect(parseWorktreeKey('orca-worktree://v2?hostId=local&repoId=repo&path=%2Fx')).toBeNull() + expect(parseWorktreeKey('orca-worktree://v1?hostId=bogus&repoId=repo&path=%2Fx')).toBeNull() + }) +}) + +describe('parseAnyWorktreeId', () => { + it('distinguishes canonical and legacy ids', () => { + const canonical = makeWorktreeKey({ hostId: 'local', repoId: 'repo-123', path: '/abs/path' }) + + expect(parseAnyWorktreeId(canonical)).toEqual({ + format: 'canonical', + hostId: 'local', + repoId: 'repo-123', + worktreePath: '/abs/path' + }) + expect(parseAnyWorktreeId('repo-123::/abs/path')).toEqual({ + format: 'legacy', + repoId: 'repo-123', + worktreePath: '/abs/path' + }) + }) +}) + +describe('isLegacyWorktreeId', () => { + it('returns true only for legacy ids', () => { + expect(isLegacyWorktreeId('repo-123::/abs/path')).toBe(true) + expect( + isLegacyWorktreeId( + makeWorktreeKey({ hostId: 'local', repoId: 'repo-123', path: '/abs/path' }) + ) + ).toBe(false) + expect(isLegacyWorktreeId('repo-123')).toBe(false) + }) +}) + describe('splitWorktreeId', () => { it('splits a canonical worktree id into repo id and path', () => { expect(splitWorktreeId('repo-123::/abs/path')).toEqual({ @@ -51,6 +154,12 @@ describe('splitWorktreeId', () => { }) }) + it('splits a host-qualified worktree key into host, repo id, and path', () => { + expect( + splitWorktreeId(makeWorktreeKey({ hostId: 'local', repoId: 'repo-123', path: '/abs/path' })) + ).toEqual({ hostId: 'local', repoId: 'repo-123', worktreePath: '/abs/path' }) + }) + it('returns null when there is no separator', () => { expect(splitWorktreeId('just-a-repo-id')).toBeNull() }) @@ -91,6 +200,18 @@ describe('splitWorktreeIdForFilesystem', () => { splitWorktreeIdForFilesystem('repo::/folder::workspace:123e4567-e89b-12d3-a456-426614174000') ).toEqual({ repoId: 'repo', worktreePath: '/folder' }) }) + + it('strips folder workspace instance suffixes from host-qualified keys', () => { + expect( + splitWorktreeIdForFilesystem( + makeWorktreeKey({ + hostId: 'local', + repoId: 'repo', + path: '/folder::workspace:123e4567-e89b-12d3-a456-426614174000' + }) + ) + ).toEqual({ hostId: 'local', repoId: 'repo', worktreePath: '/folder' }) + }) }) describe('getWorktreePathBasenameFromId', () => { diff --git a/src/shared/worktree-id.ts b/src/shared/worktree-id.ts index d8b6c3cfa6c..ad193b2e528 100644 --- a/src/shared/worktree-id.ts +++ b/src/shared/worktree-id.ts @@ -1,23 +1,136 @@ import { WORKTREE_ID_SEPARATOR } from './pty-session-id-format' +import { + getRepoExecutionHostId, + normalizeExecutionHostId, + type ExecutionHostId +} from './execution-host' +import type { Repo } from './types' export { WORKTREE_ID_SEPARATOR } from './pty-session-id-format' export type ParsedWorktreeId = { repoId: string worktreePath: string + hostId?: ExecutionHostId } +export type ParsedCanonicalWorktreeKey = { + hostId: ExecutionHostId + repoId: string + worktreePath: string +} + +export type ParsedAnyWorktreeId = + | ({ format: 'canonical' } & ParsedCanonicalWorktreeKey) + | ({ format: 'legacy' } & ParsedWorktreeId) + export const FOLDER_WORKSPACE_INSTANCE_SEPARATOR = '::workspace:' +const WORKTREE_KEY_PREFIX = 'orca-worktree://v1?' const FOLDER_WORKSPACE_INSTANCE_SUFFIX = new RegExp( `${FOLDER_WORKSPACE_INSTANCE_SEPARATOR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[0-9a-f-]{36}$` ) +function encodeKeyPart(value: string): string { + return encodeURIComponent(value) +} + +function readSingleParam(params: URLSearchParams, key: string): string | null { + const values = params.getAll(key) + if (values.length !== 1) { + return null + } + const [value] = values + return value.length > 0 ? value : null +} + +export function makeLegacyWorktreeId(repoId: string, path: string): string { + return `${repoId}${WORKTREE_ID_SEPARATOR}${path}` +} + +export function makeWorktreeKey(input: { + hostId: ExecutionHostId + repoId: string + path: string +}): string { + return `${WORKTREE_KEY_PREFIX}${[ + `hostId=${encodeKeyPart(input.hostId)}`, + `repoId=${encodeKeyPart(input.repoId)}`, + `path=${encodeKeyPart(input.path)}` + ].join('&')}` +} + +export function makeRepoWorktreeKey( + repo: Pick, + path: string +): string { + return makeWorktreeKey({ + hostId: getRepoExecutionHostId(repo), + repoId: repo.id, + path + }) +} + +export function getRepoWorktreeIdAliases( + repo: Pick, + path: string +): Set { + return new Set([makeRepoWorktreeKey(repo, path), makeLegacyWorktreeId(repo.id, path)]) +} + +export function isRepoWorktreeIdAlias( + repo: Pick, + path: string, + worktreeId: string +): boolean { + return getRepoWorktreeIdAliases(repo, path).has(worktreeId) +} + +export function parseWorktreeKey(worktreeId: string): ParsedCanonicalWorktreeKey | null { + if (!worktreeId.startsWith(WORKTREE_KEY_PREFIX)) { + return null + } + const query = worktreeId.slice(WORKTREE_KEY_PREFIX.length) + const params = new URLSearchParams(query) + const keys = [...params.keys()] + if (keys.length !== 3 || keys[0] !== 'hostId' || keys[1] !== 'repoId' || keys[2] !== 'path') { + return null + } + const rawHostId = readSingleParam(params, 'hostId') + const rawRepoId = readSingleParam(params, 'repoId') + const rawPath = readSingleParam(params, 'path') + if (!rawHostId || !rawRepoId || !rawPath) { + return null + } + const hostId = normalizeExecutionHostId(rawHostId) + if (!hostId) { + return null + } + return { hostId, repoId: rawRepoId, worktreePath: rawPath } +} + +export function parseAnyWorktreeId(worktreeId: string): ParsedAnyWorktreeId | null { + const canonical = parseWorktreeKey(worktreeId) + if (canonical) { + return { format: 'canonical', ...canonical } + } + const legacy = splitLegacyWorktreeId(worktreeId) + return legacy ? { format: 'legacy', ...legacy } : null +} + +export function isLegacyWorktreeId(worktreeId: string): boolean { + return !parseWorktreeKey(worktreeId) && splitLegacyWorktreeId(worktreeId) !== null +} + export function getRepoIdFromWorktreeId(worktreeId: string): string { + const canonical = parseWorktreeKey(worktreeId) + if (canonical) { + return canonical.repoId + } const separatorIdx = worktreeId.indexOf(WORKTREE_ID_SEPARATOR) return separatorIdx === -1 ? worktreeId : worktreeId.slice(0, separatorIdx) } -export function splitWorktreeId(worktreeId: string): ParsedWorktreeId | null { +function splitLegacyWorktreeId(worktreeId: string): ParsedWorktreeId | null { const separatorIdx = worktreeId.indexOf(WORKTREE_ID_SEPARATOR) if (separatorIdx === -1) { return null @@ -28,12 +141,21 @@ export function splitWorktreeId(worktreeId: string): ParsedWorktreeId | null { } } +export function splitWorktreeId(worktreeId: string): ParsedWorktreeId | null { + const canonical = parseWorktreeKey(worktreeId) + if (canonical) { + return canonical + } + return splitLegacyWorktreeId(worktreeId) +} + export function splitWorktreeIdForFilesystem(worktreeId: string): ParsedWorktreeId | null { const parsed = splitWorktreeId(worktreeId) if (!parsed) { return null } return { + ...(parsed.hostId !== undefined ? { hostId: parsed.hostId } : {}), repoId: parsed.repoId, // Why: folder projects can have multiple workspace sessions backed by the // same directory. Their IDs carry a UUID suffix, but filesystem callers