From eedca5ee1c00820a8bc4e735ea0e0807ff634904 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:39:54 -0700 Subject: [PATCH] fix(persistence): worktree meta and lineage writes take the row's host, not the id (R4-2b) (#30811) * fix(persistence): worktree meta and lineage writes take the row's host, not the id (R4-2b) * fix(persistence): review fixes for host-targeted worktree writes * test: SAFETY notes on partial runtime store mocks * test: drop imports duplicated by the rebase onto #30797 --- config/id-only-store-write-baseline.txt | 7 - .../check-id-only-store-write-ratchet.mjs | 4 + .../first-work-folder-rename.test.ts | 5 +- .../agent-hooks/first-work-folder-rename.ts | 6 +- .../agent-hooks/first-work-rename-runtime.ts | 17 +- src/main/ipc/worktree-create-lineage.test.ts | 29 +-- src/main/ipc/worktree-push-target-cleanup.ts | 2 +- ...remote-push-target-materialization.test.ts | 16 +- src/main/ipc/worktree-remote.ts | 47 +++-- ...thoritative-local-metadata-pruning.test.ts | 12 +- .../ipc/worktrees-background-removal.test.ts | 5 +- .../worktrees-canonical-ssh-listing.test.ts | 21 +- .../ipc/worktrees-create-event-timing.test.ts | 2 +- ...ktrees-create-metadata-persistence.test.ts | 71 +++---- .../ipc/worktrees-delete-pty-teardown.test.ts | 5 +- ...ktrees-discovery-metadata-backfill.test.ts | 40 ++-- ...worktrees-existing-branch-checkout.test.ts | 22 ++- src/main/ipc/worktrees-failed-removal.test.ts | 5 +- src/main/ipc/worktrees-forget-local.test.ts | 33 +++- .../worktrees-issue-command-overrides.test.ts | 2 +- .../ipc/worktrees-lineage-hydration.test.ts | 16 +- .../worktrees-listing-fallback-rows.test.ts | 14 +- ...orktrees-local-base-ref-resolution.test.ts | 5 +- .../ipc/worktrees-local-create-flow.test.ts | 10 +- ...worktrees-orphan-directory-cleanup.test.ts | 19 +- .../ipc/worktrees-removal-recovery.test.ts | 41 +++- ...trees-setup-launch-sparse-checkout.test.ts | 7 +- ...orktrees-ssh-repo-owner-resolution.test.ts | 15 +- src/main/ipc/worktrees-test-harness.ts | 4 - src/main/ipc/worktrees-test-ipc-surface.ts | 13 +- src/main/ipc/worktrees-windows.test.ts | 6 +- src/main/ipc/worktrees-wsl-move-fence.test.ts | 19 +- .../ipc/worktrees-wsl-runtime-routing.test.ts | 5 +- .../create/folder-workspace-creation.ts | 3 +- .../detected-worktree-classification.test.ts | 8 +- .../listing/folder-workspace-catalog.ts | 4 +- .../listing/register-host-catalog-handlers.ts | 5 +- .../listing/ssh-worktree-fallback.ts | 11 +- .../listing/worktree-discovery-metadata.ts | 11 +- .../register-worktree-metadata-handlers.ts | 17 +- .../removal/worktree-removal-ownership.ts | 7 +- src/main/persistence-automations.test.ts | 7 +- ...ence-cohort-and-identity-migration.test.ts | 39 ++-- ...sistence-deregistered-repo-residue.test.ts | 17 +- ...tence-duplicate-repo-id-host-scope.test.ts | 5 +- ...sistence-host-partitioned-sessions.test.ts | 38 ++-- ...ersistence-orcad-migration-catalog.test.ts | 3 +- ...persistence-remote-session-startup.test.ts | 9 +- src/main/persistence-repo-lifecycle.test.ts | 47 ++--- src/main/persistence-update-repo.test.ts | 12 +- .../persistence-workspace-attachments.test.ts | 33 +++- ...sistence-worktree-deletion-fencing.test.ts | 32 ++- ...tence-worktree-lineage-and-backups.test.ts | 40 ++-- ...orktree-meta-and-folder-workspaces.test.ts | 23 ++- ...ce-worktree-meta-host-target-twins.test.ts | 185 ++++++++++++++++++ .../host-qualified-worktree-meta.ts | 74 +++++-- .../metadata-lineage-operations.ts | 115 ++++++----- .../profile-state-direct-flush.test.ts | 3 +- .../profile-state-sqlite-authority.test.ts | 10 +- .../state-write-listeners.spec.ts | 18 +- .../store-prune-gate-signals.test.ts | 18 +- .../profile-state-cutover-soak.test.ts | 12 +- ...knowledged-terminal-tab-retirement.test.ts | 5 +- .../runtime-file-command-state.ts | 11 +- .../folder-upgrade-runtime-listing.test.ts | 5 +- .../folder-workspace-pty-identity.test.ts | 2 +- .../integrations/linear/linear-commands.ts | 8 +- ...linked-issue-live-meta.integration.test.ts | 3 +- ...untime-startup-draft-launch-parity.test.ts | 7 +- .../orca-runtime-test-fixtures.spec.ts | 7 +- .../browser-capabilities.spec.ts | 9 +- .../lineage-and-scan-cache-part-02.spec.ts | 52 +++-- .../lineage-and-scan-cache-part-03.spec.ts | 17 +- .../lineage-and-scan-cache-part-04.spec.ts | 21 +- .../lineage-and-scan-cache-part-06.spec.ts | 25 ++- .../lineage-and-scan-cache.spec.ts | 49 +++-- .../local-worktree-creation.spec.ts | 5 +- ...creation-and-orchestration-part-04.spec.ts | 3 +- .../mobile-session-tabs-part-02.spec.ts | 33 +++- .../mobile-session-tabs.spec.ts | 23 ++- .../mobile-summaries-part-03.spec.ts | 3 +- .../ssh-worktree-lifecycle.spec.ts | 5 +- ...removal-and-reconciliation-part-02.spec.ts | 5 +- ...removal-and-reconciliation-part-03.spec.ts | 17 +- ...removal-and-reconciliation-part-04.spec.ts | 14 +- ...orktree-removal-and-reconciliation.spec.ts | 5 +- .../worktree-removal-execution-host.spec.ts | 7 +- .../worktree-selector-resolution.spec.ts | 3 +- ...worktree-setup-and-startup-part-03.spec.ts | 13 +- ...worktree-setup-and-startup-part-04.spec.ts | 13 +- ...ration-worker-workspace-resolution.test.ts | 3 +- ...y-inventory-partial-relay-liveness.spec.ts | 3 +- .../repo-worktree-row-resolution.test.ts | 4 +- .../runtime/repo-worktree-row-resolution.ts | 8 +- .../agent-status-producer-census.test.ts | 3 +- ...e-created-worktree-terminal-target.test.ts | 15 +- .../runtime-folder-worktree-create.test.ts | 3 +- .../runtime/runtime-folder-worktree-create.ts | 3 +- .../runtime/runtime-git-command-target.ts | 5 +- src/main/runtime/runtime-git-sync-commands.ts | 2 +- .../runtime/runtime-linear-command-base.ts | 6 +- .../runtime-linear-team-write-commands.ts | 2 +- .../runtime-local-worktree-create.test.ts | 3 +- ...ime-local-worktree-materialization.test.ts | 6 +- .../runtime-local-worktree-materialization.ts | 3 +- ...me-managed-worktree-metadata-sweep.test.ts | 17 +- ...managed-worktree-metadata-wsl-move.test.ts | 5 +- .../runtime-managed-worktree-metadata.test.ts | 31 ++- .../runtime-managed-worktree-metadata.ts | 39 ++-- .../runtime-managed-worktree-queries.test.ts | 4 +- .../runtime-provisioned-root-adoption.test.ts | 3 +- .../runtime-provisioned-root-adoption.ts | 3 +- .../runtime-rpc-worktree-store-fixtures.ts | 3 +- .../runtime-sort-order-scan-cache.test.ts | 3 +- src/main/runtime/runtime-store-contract.ts | 3 +- .../runtime/runtime-worktree-filesystem.ts | 3 +- .../runtime-worktree-lineage-controller.ts | 7 +- .../runtime-worktree-lineage-recording.ts | 51 +++-- .../orca-runtime-sort-stamps.test.ts | 6 +- .../orca-runtime-terminal-use.test.ts | 6 +- .../orca-runtime-workspace-unread.test.ts | 6 +- .../sidebar-inputs/sort-stamp-records.ts | 34 ++-- .../sort-stamps-producer.test.ts | 23 ++- .../terminal-use-producer.test.ts | 6 +- .../workspaces/workspace-activation.ts | 10 +- .../workspace-query-prototype.test.ts | 1 + .../workspaces/workspace-removal-target.ts | 7 +- .../worktree-ps-degraded-repo-scan.test.ts | 26 ++- .../runtime/worktree-ps-host-scope.test.ts | 3 +- ...ktree-rm-id-selector-path-spelling.test.ts | 3 +- ...rktree-scan-admin-fingerprint-gate.test.ts | 3 +- ...rktree-scan-execution-host-routing.test.ts | 3 +- .../worktree-scan-repeated-path.test.ts | 3 +- .../orcad-migration-repository-link.test.ts | 29 ++- .../worktree-identity-persistence.test.ts | 136 +++++++++---- src/main/worktree-lineage-pruning.test.ts | 41 ++-- src/main/worktree-lineage-pruning.ts | 25 ++- .../worktree-removal-close-records.test.ts | 22 ++- ...-removal-session-partition-fencing.test.ts | 29 +-- src/main/wsl-move-incidental-writes.test.ts | 16 +- src/main/wsl-move-scan-pruning.test.ts | 8 +- ...-upgrade-identity-persistence.unit.test.ts | 9 +- 142 files changed, 1625 insertions(+), 772 deletions(-) create mode 100644 src/main/persistence-worktree-meta-host-target-twins.test.ts diff --git a/config/id-only-store-write-baseline.txt b/config/id-only-store-write-baseline.txt index 672cc7325ba..750ec367d4a 100644 --- a/config/id-only-store-write-baseline.txt +++ b/config/id-only-store-write-baseline.txt @@ -1,10 +1,3 @@ # Store writes whose first parameter is a bare repo or worktree id (R4-2), as Class.method. # This is a RATCHET: entries may only be REMOVED. Take RepoHostTarget or HostWorktreeTarget. # Prune after a fix: pnpm check:id-only-store-write-ratchet --prune -MetadataLineageOperations.migrateWorktreeIdentity -MetadataLineageOperations.removeWorkspaceLineage -MetadataLineageOperations.removeWorktreeLineage -MetadataLineageOperations.removeWorktreeMeta -MetadataLineageOperations.setWorktreeLineage -MetadataLineageOperations.setWorktreeMeta -MetadataLineageOperations.setWorktreeMetaForHost diff --git a/config/scripts/check-id-only-store-write-ratchet.mjs b/config/scripts/check-id-only-store-write-ratchet.mjs index f2802a1947b..a9e519f44d5 100644 --- a/config/scripts/check-id-only-store-write-ratchet.mjs +++ b/config/scripts/check-id-only-store-write-ratchet.mjs @@ -42,6 +42,10 @@ export const NOT_HOST_TWINNED = new Map([ ['AutomationPersistence.advanceAutomationNextRun', 'automation ids are minted UUIDs'], ['AutomationPersistence.deleteAutomation', 'automation ids are minted UUIDs'], ['AutomationPersistence.updateAutomation', 'automation ids are minted UUIDs'], + [ + 'MetadataLineageOperations.removeUnhostedWorktreeMeta', + 'acts only on a row no host stamped; it refuses a stamped one' + ], [ 'OrcadMigrationBlobInstallPersistence.installDeferredOrcadMigrationShellHistory', 'a retry trigger: the id only gates a store-wide reinstall; nothing is written under it' diff --git a/src/main/agent-hooks/first-work-folder-rename.test.ts b/src/main/agent-hooks/first-work-folder-rename.test.ts index 07865c8fdf4..6a3572aa6c6 100644 --- a/src/main/agent-hooks/first-work-folder-rename.test.ts +++ b/src/main/agent-hooks/first-work-folder-rename.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings } from '../../shared/global-settings-types' import type { Repo } from '../../shared/repo-types' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' import { renameWorktreeFolderOnFirstWork, type FirstWorkFolderRenameDeps @@ -42,7 +43,7 @@ describe('renameWorktreeFolderOnFirstWork', () => { '/ws/worktree-creation-spinner' ) expect(deps.migrateWorktreeIdentity).toHaveBeenCalledWith( - OLD_ID, + hostWorktreeTarget({ id: OLD_ID, hostId: 'local' }), 'repo1::/ws/worktree-creation-spinner' ) expect(deps.notifyWorktreeRenamed).toHaveBeenCalledWith( @@ -66,7 +67,7 @@ describe('renameWorktreeFolderOnFirstWork', () => { ) expect(result).toBe(true) expect(deps.migrateWorktreeIdentity).toHaveBeenCalledWith( - FOLDER_WORKSPACE_ID, + hostWorktreeTarget({ id: FOLDER_WORKSPACE_ID, hostId: 'local' }), 'repo1::/ws/worktree-creation-spinner::workspace:12345678-1234-1234-1234-123456789abc' ) expect(deps.notifyWorktreeRenamed).toHaveBeenCalledWith( diff --git a/src/main/agent-hooks/first-work-folder-rename.ts b/src/main/agent-hooks/first-work-folder-rename.ts index ae445723d73..01e03755ec7 100644 --- a/src/main/agent-hooks/first-work-folder-rename.ts +++ b/src/main/agent-hooks/first-work-folder-rename.ts @@ -13,12 +13,14 @@ import { } from '../../shared/worktree/id' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { planWorktreeFolderRename } from '../ipc/worktree-folder-rename-target' +import { hostWorktreeTargetForRepo } from '../persistence/host-qualified-worktree-meta' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' export type FirstWorkFolderRenameDeps = { getRepo: (repoId: string) => Repo | undefined getSettings: () => Parameters[0]['settings'] /** Re-key all worktreeId-keyed state from the old (path-derived) id to the new. */ - migrateWorktreeIdentity: (oldWorktreeId: string, newWorktreeId: string) => void + migrateWorktreeIdentity: (target: HostWorktreeTarget, newWorktreeId: string) => void /** Invalidate caches + tell the renderer the worktree's id changed (old->new) so * it re-keys its state instead of treating the rename as a deletion. */ notifyWorktreeRenamed: (repoId: string, oldWorktreeId: string, newWorktreeId: string) => void @@ -67,7 +69,7 @@ export async function renameWorktreeFolderOnFirstWork( await deps.moveWorktree(repo.path, plan.oldPath, plan.newPath) // Order: move first (point of no return), then re-key identity synchronously so // nothing interleaves before the worktree's state is re-bound to the new id. - deps.migrateWorktreeIdentity(worktreeId, plan.newWorktreeId) + deps.migrateWorktreeIdentity(hostWorktreeTargetForRepo(worktreeId, repo), plan.newWorktreeId) deps.notifyWorktreeRenamed(repo.id, worktreeId, plan.newWorktreeId) return true } diff --git a/src/main/agent-hooks/first-work-rename-runtime.ts b/src/main/agent-hooks/first-work-rename-runtime.ts index 4a73ac54e45..5e3ebbe1d16 100644 --- a/src/main/agent-hooks/first-work-rename-runtime.ts +++ b/src/main/agent-hooks/first-work-rename-runtime.ts @@ -9,6 +9,7 @@ import { isWslMoveFolderWorkspaceBlocked } from '../persistence/moving-wsl-proje import type { Store } from '../persistence' import type { RuntimeStore } from '../runtime/runtime-store-contract' import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { resolveWorktreeMetaOwner } from '../persistence/host-qualified-worktree-meta' const ENABLE_FIRST_WORK_FOLDER_RENAME = false @@ -17,6 +18,7 @@ export function firstWorkRenameDeps( RuntimeStore, | 'getSettings' | 'getRepo' + | 'getRepos' | 'getWorktreeMeta' | 'setWorktreeMeta' | 'getFolderWorkspace' @@ -77,7 +79,11 @@ export function firstWorkRenameDeps( runtime.notifyFolderWorkspaceChanged() return } - store.setWorktreeMeta(worktreeId, { + const target = resolveWorktreeMetaOwner(store, worktreeId) + if (!target) { + return + } + store.setWorktreeMeta(target, { displayName, // The first-agent title is an intentional user-facing label; keep it stable after the // generated branch is renamed and across subsequent catalog refreshes. @@ -92,7 +98,8 @@ export function firstWorkRenameDeps( renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, { getRepo: (repoId) => store.getRepo(repoId), getSettings: () => store.getSettings(), - migrateWorktreeIdentity: (oldId, newId) => store.migrateWorktreeIdentity!(oldId, newId), + migrateWorktreeIdentity: (target, newId) => + store.migrateWorktreeIdentity!(target, newId), notifyWorktreeRenamed: (repoId, oldId, newId) => runtime.notifyWorktreeFolderRenamed(repoId, oldId, newId), pathExists: async (candidate) => existsSync(candidate), @@ -121,7 +128,11 @@ export function firstWorkRenameDeps( if ((current ?? null) === (error ?? null)) { return } - store.setWorktreeMeta(worktreeId, { firstAgentMessageRenameError: error }) + const target = resolveWorktreeMetaOwner(store, worktreeId) + if (!target) { + return + } + store.setWorktreeMeta(target, { firstAgentMessageRenameError: error }) // Why: the hook only knows the worktreeId, so derive the repoId notifyBranchRenamed expects. runtime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId)) }, diff --git a/src/main/ipc/worktree-create-lineage.test.ts b/src/main/ipc/worktree-create-lineage.test.ts index 9c101f939f4..450663a80f7 100644 --- a/src/main/ipc/worktree-create-lineage.test.ts +++ b/src/main/ipc/worktree-create-lineage.test.ts @@ -8,6 +8,10 @@ import { assertAttachableParentWorkspace, recordWorkspaceLineageForCreatedWorktree } from './worktree-remote' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../shared/worktree/host-qualified-identity' const CHILD_ID = 'repo-1::/repos/child' const PARENT_ID = 'repo-1::/repos/parent' @@ -72,7 +76,7 @@ function createStore(options: { getFolderWorkspace: vi.fn((id: string) => folderWorkspaceIds.has(id) ? { id, path: `/folders/${id}` } : undefined ), - setWorktreeLineage: vi.fn((_id: string, lineage: unknown) => lineage), + setWorktreeLineage: vi.fn((_target: HostWorktreeTarget, lineage: unknown) => lineage), setWorkspaceLineage: vi.fn((lineage: unknown) => lineage) } } @@ -104,15 +108,18 @@ describe('recordWorkspaceLineageForCreatedWorktree', () => { const result = record(store, { parentWorkspace: worktreeWorkspaceKey(PARENT_ID) }) - expect(store.setWorktreeLineage).toHaveBeenCalledWith(CHILD_ID, { - worktreeId: CHILD_ID, - worktreeInstanceId: 'child-instance', - parentWorktreeId: PARENT_ID, - parentWorktreeInstanceId: 'parent-instance', - origin: 'manual', - capture: { source: 'manual-action', confidence: 'explicit' }, - createdAt: CREATED_AT - }) + expect(store.setWorktreeLineage).toHaveBeenCalledWith( + hostWorktreeTarget({ id: CHILD_ID, hostId: 'local' }), + { + worktreeId: CHILD_ID, + worktreeInstanceId: 'child-instance', + parentWorktreeId: PARENT_ID, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: CREATED_AT + } + ) expect(result.lineage).toMatchObject({ parentWorktreeId: PARENT_ID, parentWorktreeInstanceId: 'parent-instance', @@ -140,7 +147,7 @@ describe('recordWorkspaceLineageForCreatedWorktree', () => { const result = record(store, { parentWorkspace: worktreeWorkspaceKey(foreignParentId) }) expect(store.setWorktreeLineage).toHaveBeenCalledWith( - CHILD_ID, + hostWorktreeTarget({ id: CHILD_ID, hostId: 'local' }), expect.objectContaining({ parentWorktreeId: foreignParentId }) ) expect(result.lineage).toMatchObject({ parentWorktreeId: foreignParentId }) diff --git a/src/main/ipc/worktree-push-target-cleanup.ts b/src/main/ipc/worktree-push-target-cleanup.ts index 09918ffe8f7..a0c9fdd28e6 100644 --- a/src/main/ipc/worktree-push-target-cleanup.ts +++ b/src/main/ipc/worktree-push-target-cleanup.ts @@ -20,7 +20,7 @@ export type GitRemoteExec = ( // `getAllWorktreeMeta`) keep compiling; callers that want materialize-time // provenance persistence (worktree-remote.ts) pass a store that has it. export type WorktreePushTargetStore = Pick & - Partial> + Partial> export function sameGitHubRemoteUrl(left: string, right: string): boolean { if (left === right) { diff --git a/src/main/ipc/worktree-remote-push-target-materialization.test.ts b/src/main/ipc/worktree-remote-push-target-materialization.test.ts index c91beaaa7a5..92958456c0b 100644 --- a/src/main/ipc/worktree-remote-push-target-materialization.test.ts +++ b/src/main/ipc/worktree-remote-push-target-materialization.test.ts @@ -6,6 +6,7 @@ const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) import { materializeWorktreePushTargetRemote } from './worktree-remote' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' const REPO_PATH = '/repo-root' const FORK_URL = 'git@github.com:contributor/orca.git' @@ -342,10 +343,14 @@ describe('materializeWorktreePushTargetRemote', () => { }) const target = forkTarget() const setWorktreeMeta = vi.fn() + // The write lands on the id's only repo row. const store: WorktreePushTargetStore = { getAllWorktreeMeta: () => ({}), + getRepos: () => [ + { id: 'worktree-1', path: REPO_PATH, displayName: 'r', badgeColor: '#000', addedAt: 1 } + ], setWorktreeMeta - } as unknown as WorktreePushTargetStore + } const result = await materializeWorktreePushTargetRemote( REPO_PATH, @@ -357,9 +362,12 @@ describe('materializeWorktreePushTargetRemote', () => { ) expect(result).toEqual({ ...target, remoteCreated: true }) - expect(setWorktreeMeta).toHaveBeenCalledWith('worktree-1', { - pushTarget: { ...target, remoteCreated: true } - }) + expect(setWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: 'worktree-1', hostId: 'local' }), + { + pushTarget: { ...target, remoteCreated: true } + } + ) }) it('does not touch the store when no worktreeId is provided', async () => { diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 371a2f2e525..fb5148352b5 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -81,7 +81,6 @@ import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import type { BranchPrefixSettings } from '../../shared/branch-prefix' import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' import { sharesWorktreeLineageBoundary } from '../../shared/resolved-worktree-lineage' -import { readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta' import { cleanupUnusedWorktreePushTargetRemoteWithExec, sameGitHubRemoteUrl, @@ -145,6 +144,12 @@ import { import { createRetiredNameLookup } from '../../shared/worktree/retired-name-registry' import { findPendingWorktreeRemovalConflict } from '../worktree-removal-table' import { isWslMoveWorktreeBlocked } from '../persistence/moving-wsl-projects/wsl-move-background-backoff' +import { + hostWorktreeTargetForRepo, + readWorktreeMetaForHost, + resolveWorktreeMetaOwner, + worktreeRowTarget +} from '../persistence/host-qualified-worktree-meta' // Why: bound the fallback `git fetch origin` so a Windows credential-manager GUI hang (STA-1292) can't wedge worktree creation forever. const CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS = 60_000 // Why (#17828 CodeRabbit follow-up): the deferred materialize fetch runs off the main @@ -253,6 +258,7 @@ export function recordWorkspaceLineageForCreatedWorktree( // Why: only a worktree parent produces sidebar nesting; a folder parent has no WorktreeLineage row. let lineage: CreateWorktreeResult['lineage'] = null let parentOutsideLineageBoundary = false + let lineageOwnedByTwin = false if (parentScope.type === 'worktree' && parentWorktreeMeta) { if (!parentWorktreeMeta.instanceId) { console.warn( @@ -264,20 +270,26 @@ export function recordWorkspaceLineageForCreatedWorktree( `[worktree-create] parent ${parentScope.worktreeId} is outside ${worktree.id}'s execution host; skipping lineage` ) } else { - lineage = store.setWorktreeLineage(worktree.id, { - worktreeId: worktree.id, - worktreeInstanceId: worktree.instanceId, - parentWorktreeId: parentScope.worktreeId, - parentWorktreeInstanceId: parentWorktreeMeta.instanceId, - origin: 'manual', - capture: { source: 'manual-action', confidence: 'explicit' }, - createdAt - }) + const target = worktreeRowTarget(store, worktree) + lineage = + (target && + store.setWorktreeLineage(target, { + worktreeId: worktree.id, + worktreeInstanceId: worktree.instanceId, + parentWorktreeId: parentScope.worktreeId, + parentWorktreeInstanceId: parentWorktreeMeta.instanceId, + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt + })) ?? + null + lineageOwnedByTwin = lineage === null } } // Why: a workspace row for an out-of-boundary worktree parent is a cross-host link that survives restarts. - if (parentOutsideLineageBoundary) { + // A twin-owned bucket keeps the twin's workspace row too. + if (parentOutsideLineageBoundary || lineageOwnedByTwin) { return NO_CREATED_WORKTREE_LINEAGE } @@ -807,11 +819,18 @@ function persistMaterializedPushTargetIfCreated( if (!target.remoteCreated || !worktreeId || !store?.setWorktreeMeta) { return } + const owner = resolveWorktreeMetaOwner( + { + getWorktreeMeta: (id) => store.getAllWorktreeMeta()[id], + getRepos: () => store.getRepos?.() ?? [] + }, + worktreeId + ) // Bookkeeping incidental to the push: a project a WSL move is taking skips it, not the push. - if (isWslMoveWorktreeBlocked(worktreeId, store.getAllWorktreeMeta()[worktreeId]?.hostId)) { + if (!owner || isWslMoveWorktreeBlocked(worktreeId, owner.hostId)) { return } - store.setWorktreeMeta(worktreeId, { pushTarget: target }) + store.setWorktreeMeta(owner, { pushTarget: target }) } function isPushTargetRemoteCreatedByKnownWorktree( @@ -1575,7 +1594,7 @@ async function performLocalWorktreeCreate( ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) } const { worktree } = timing.timeSync('persist_metadata', () => { - const meta = store.setWorktreeMeta(worktreeId, metaUpdates) + const meta = store.setWorktreeMeta(hostWorktreeTargetForRepo(worktreeId, repo), metaUpdates) return { worktree: mergeWorktree(repo.id, created, meta) } }) onWorkspaceCreated?.(worktreeId) diff --git a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts index 958a6fae2bc..40f69c3074c 100644 --- a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts +++ b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GitWorktreeInfo } from '../../shared/worktree/types' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' import type { NativeLocalWorktreeMetadataScanExpectation } from '../persistence/tracking-repos/missing-local-worktree-metadata-pruning' import { notifyWorktreesChanged } from './worktree-remote' import { @@ -367,7 +368,10 @@ describe('authoritative local worktree metadata pruning integration', () => { resolveScan([worktree(REPO_PATH)]) queueMicrotask(() => { registerWorktreeRootsForRepo(store as never, REPO_ID, [REPO_PATH, newPath]) - store.setWorktreeMeta(newId, { hostId: LOCAL_HOST_ID, instanceId: 'new-instance' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: newId, hostId: LOCAL_HOST_ID }), { + hostId: LOCAL_HOST_ID, + instanceId: 'new-instance' + }) store.getAllWorktreeLineage.mockReturnValue({ [newId]: { worktreeId: newId, @@ -465,7 +469,7 @@ describe('authoritative local worktree metadata pruning integration', () => { registerWorktreeRootsForRepo(store as never, REPO_ID, [REPO_PATH, newPath]) const newMetadata = { hostId: LOCAL_HOST_ID, instanceId: 'new-instance' } metadata.set(newId, newMetadata) - store.setWorktreeMeta(newId, newMetadata) + store.setWorktreeMeta(hostWorktreeTarget({ id: newId, hostId: LOCAL_HOST_ID }), newMetadata) store.getAllWorktreeLineage.mockReturnValue({ [newId]: { worktreeId: newId, @@ -646,8 +650,6 @@ describe('authoritative local worktree metadata pruning integration', () => { await listDetected() expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).toHaveBeenCalledTimes(1) - expect(store.setWorktreeMeta.mock.calls.some(([worktreeId]) => worktreeId === staleId)).toBe( - false - ) + expect(store.setWorktreeMeta.mock.calls.some(([target]) => target.id === staleId)).toBe(false) }) }) diff --git a/src/main/ipc/worktrees-background-removal.test.ts b/src/main/ipc/worktrees-background-removal.test.ts index b272b4e0a22..0297b6225d7 100644 --- a/src/main/ipc/worktrees-background-removal.test.ts +++ b/src/main/ipc/worktrees-background-removal.test.ts @@ -13,6 +13,7 @@ import { _resetPendingWorktreeRemovalsForTests, startBackgroundWorktreeRemoval } from '../worktree-background-removal' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -154,7 +155,9 @@ describe('worktrees:remove in the background', () => { const result = await reply expect(result).toMatchObject({ preservedBranch: { branchName: 'feature', head: 'feature' } }) expect(result.removing).toBeUndefined() - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(featureId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: featureId, hostId: 'local' }) + ) const after = await listRepoRows() expect(after.map((row) => row.id)).not.toContain(featureId) }) diff --git a/src/main/ipc/worktrees-canonical-ssh-listing.test.ts b/src/main/ipc/worktrees-canonical-ssh-listing.test.ts index 7f174639a1a..0eccdba499b 100644 --- a/src/main/ipc/worktrees-canonical-ssh-listing.test.ts +++ b/src/main/ipc/worktrees-canonical-ssh-listing.test.ts @@ -43,6 +43,10 @@ import { listDetectedGitWorktrees, rememberLocalWorktreeRoots } from './worktrees/listing/detected-worktree-scan-cache' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../shared/worktree/host-qualified-identity' const path = '/remote/repository' const rows = [{ path, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }] @@ -180,11 +184,13 @@ it.each(['git', 'folder'] as const)( if (kind === 'folder') { metaById[parentId].instanceId = undefined } - store.setWorktreeMeta.mockImplementation((id: string, updates: Partial) => { - const meta = mergeWorktreeMetaForWrite(metaById[id], updates) - metaById[id] = meta - return meta - }) + store.setWorktreeMeta.mockImplementation( + ({ id }: HostWorktreeTarget, updates: Partial) => { + const meta = mergeWorktreeMetaForWrite(metaById[id], updates) + metaById[id] = meta + return meta + } + ) Object.assign(store, { getRepos: () => [repo, parentRepo], getAllWorktreeMeta: () => metaById, @@ -207,7 +213,10 @@ it.each(['git', 'folder'] as const)( status: 'complete', result: { worktrees: [expect.objectContaining({ id: childId, parentWorktreeId: parentId })] } }) - expect(store.setWorktreeMeta).not.toHaveBeenCalledWith(parentId, expect.anything()) + expect(store.setWorktreeMeta).not.toHaveBeenCalledWith( + hostWorktreeTarget({ id: parentId, hostId: 'local' }), + expect.anything() + ) const parent = mocks.handlers.get('worktrees:listKnownForExecutionHost')!(null, { repoId: parentRepo.id, executionHostId: 'ssh:host-a' diff --git a/src/main/ipc/worktrees-create-event-timing.test.ts b/src/main/ipc/worktrees-create-event-timing.test.ts index a1147c3a3b3..acb642e9da5 100644 --- a/src/main/ipc/worktrees-create-event-timing.test.ts +++ b/src/main/ipc/worktrees-create-event-timing.test.ts @@ -118,7 +118,7 @@ function makeRepo(fields: Record) { function useRepo(repo: ReturnType): void { store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue(repo) - store.setWorktreeMeta.mockImplementation((_worktreeId: string, meta: unknown) => meta) + store.setWorktreeMeta.mockImplementation((_target: unknown, meta: unknown) => meta) } function useLocalListing(): void { diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 4d57901e899..167d297f094 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -16,6 +16,10 @@ import { makeWorktreeMeta } from './worktrees-test-fixtures' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../shared/worktree/host-qualified-identity' const WORKTREE_HANDLER_CHANNELS = [ 'worktrees:listAll', @@ -168,7 +172,7 @@ describe('registerWorktreeHandlers', () => { handlers['worktrees:persistSortOrder'](null, { orderedIds: [liveId, staleId] }) - const orderedTargets = store.setWorktreeMeta.mock.calls.map((call) => call[0]) + const orderedTargets = store.setWorktreeMeta.mock.calls.map((call) => call[0].id) expect(orderedTargets).toContain(liveId) expect(orderedTargets).not.toContain(staleId) }) @@ -186,7 +190,7 @@ describe('registerWorktreeHandlers', () => { }) it('strips Orca provenance fields from renderer metadata updates', () => { - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta) const result = handlers['worktrees:updateMeta'](null, { worktreeId: 'repo-1::/workspace/feature-wt', @@ -199,14 +203,14 @@ describe('registerWorktreeHandlers', () => { } }) - expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/feature-wt', { - comment: 'keep me', - isPinned: true - }) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: 'repo-1::/workspace/feature-wt', hostId: 'local' }), + { comment: 'keep me', isPinned: true } + ) expect(result).toMatchObject({ comment: 'keep me', isPinned: true }) }) it('routes metadata updates through the explicitly selected host', () => { - store.setWorktreeMetaForHost.mockImplementation((_worktreeId, _executionHostId, meta) => meta) + store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta) const result = handlers['worktrees:updateMeta'](null, { worktreeId: 'repo-1::/workspace/feature-wt', @@ -214,12 +218,10 @@ describe('registerWorktreeHandlers', () => { updates: { comment: 'remote note' } }) - expect(store.setWorktreeMetaForHost).toHaveBeenCalledWith( - 'repo-1::/workspace/feature-wt', - 'ssh:build-box', + expect(store.setWorktreeMeta).toHaveBeenCalledExactlyOnceWith( + hostWorktreeTarget({ id: 'repo-1::/workspace/feature-wt', hostId: 'ssh:build-box' }), { comment: 'remote note' } ) - expect(store.setWorktreeMeta).not.toHaveBeenCalled() expect(result).toMatchObject({ comment: 'remote note' }) }) @@ -232,12 +234,11 @@ describe('registerWorktreeHandlers', () => { }) ).toThrow('Invalid execution host identity.') - expect(store.setWorktreeMetaForHost).not.toHaveBeenCalled() expect(store.setWorktreeMeta).not.toHaveBeenCalled() }) it('pushes a remote-client invalidation for renames but not read-state updates', () => { - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta) handlers['worktrees:updateMeta'](null, { worktreeId: 'repo-1::/workspace/feature-wt', @@ -264,11 +265,13 @@ describe('registerWorktreeHandlers', () => { store.getRepos.mockReturnValue([folderRepo]) store.getWorktreeMeta.mockImplementation((id: string) => table[id]) store.getAllWorktreeMeta.mockImplementation(() => table) - store.setWorktreeMeta.mockImplementation((id: string, meta: Partial) => { - table[id] = mergeWorktreeMetaForWrite(table[id], meta) - return table[id] - }) - store.removeWorktreeMeta.mockImplementation((id: string) => { + store.setWorktreeMeta.mockImplementation( + ({ id }: HostWorktreeTarget, meta: Partial) => { + table[id] = mergeWorktreeMetaForWrite(table[id], meta) + return table[id] + } + ) + store.removeWorktreeMeta.mockImplementation(({ id }: HostWorktreeTarget) => { delete table[id] }) table[workspaceId] = mergeWorktreeMetaForWrite(undefined, { @@ -329,7 +332,7 @@ describe('registerWorktreeHandlers', () => { }) it('persists display-name provenance at the host boundary', () => { - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta) handlers['worktrees:updateMeta'](null, { worktreeId: 'repo-1::/workspace/feature-wt', @@ -337,13 +340,13 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/feature-wt', + hostWorktreeTarget({ id: 'repo-1::/workspace/feature-wt', hostId: 'local' }), expect.objectContaining({ displayName: 'Agent label', displayNameIsPinned: true }) ) }) it('does not trust renderer-authored automation provenance during local create', async () => { - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta) listWorktreesMock.mockResolvedValue([ { path: '/workspace/improve-dashboard', @@ -371,7 +374,7 @@ describe('registerWorktreeHandlers', () => { }) const persistedMeta = store.setWorktreeMeta.mock.calls.find( - ([worktreeId]) => worktreeId === 'repo-1::/workspace/improve-dashboard' + ([target]) => target.id === 'repo-1::/workspace/improve-dashboard' )?.[1] expect(persistedMeta).toBeDefined() expect(persistedMeta).not.toHaveProperty('automationProvenance') @@ -387,7 +390,7 @@ describe('registerWorktreeHandlers', () => { isMainWorktree: false } ]) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta) const result = await handlers['worktrees:create'](null, { repoId: 'repo-1', @@ -396,7 +399,7 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + hostWorktreeTarget({ id: 'repo-1::/workspace/improve-dashboard', hostId: 'local' }), expect.objectContaining({ displayName: 'Fix: dashboards for PRs' }) @@ -418,7 +421,7 @@ describe('registerWorktreeHandlers', () => { isMainWorktree: false } ]) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta) await handlers['worktrees:create'](null, { repoId: 'repo-1', @@ -426,7 +429,7 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/feature', + hostWorktreeTarget({ id: 'repo-1::/workspace/feature', hostId: 'local' }), expect.objectContaining({ displayName: 'feature', displayNameIsPinned: true }) ) }) @@ -441,7 +444,7 @@ describe('registerWorktreeHandlers', () => { isMainWorktree: false } ]) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta) const result = await handlers['worktrees:create'](null, { repoId: 'repo-1', @@ -453,7 +456,7 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + hostWorktreeTarget({ id: 'repo-1::/workspace/improve-dashboard', hostId: 'local' }), expect.objectContaining({ linkedIssue: 123, linkedPR: 456, @@ -481,7 +484,7 @@ describe('registerWorktreeHandlers', () => { isMainWorktree: false } ]) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta) const result = await handlers['worktrees:create'](null, { repoId: 'repo-1', @@ -490,7 +493,7 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + hostWorktreeTarget({ id: 'repo-1::/workspace/improve-dashboard', hostId: 'local' }), expect.objectContaining({ createdWithAgent: 'codex' }) @@ -515,7 +518,7 @@ describe('registerWorktreeHandlers', () => { isMainWorktree: false } ]) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta) await handlers['worktrees:create'](null, { repoId: 'repo-1', @@ -560,7 +563,7 @@ describe('registerWorktreeHandlers', () => { // Exact object, not objectContaining: `remoteCreated` must stay absent until // something actually mints the remote. expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + hostWorktreeTarget({ id: 'repo-1::/workspace/improve-dashboard', hostId: 'local' }), expect.objectContaining({ pushTarget: { remoteName: 'pr-prateek-orca', @@ -594,7 +597,7 @@ describe('registerWorktreeHandlers', () => { store.getAllWorktreeMeta.mockReturnValue({ 'repo-1::/workspace/previous-fix': makeWorktreeMeta({ pushTarget: existingPushTarget }) }) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta) gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args[0] === 'remote' && args.length === 1) { return { stdout: 'pr-contributor-orca\n', stderr: '' } @@ -620,7 +623,7 @@ describe('registerWorktreeHandlers', () => { expect.any(Object) ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + hostWorktreeTarget({ id: 'repo-1::/workspace/improve-dashboard', hostId: 'local' }), expect.objectContaining({ pushTarget: { remoteName: 'pr-contributor-orca', diff --git a/src/main/ipc/worktrees-delete-pty-teardown.test.ts b/src/main/ipc/worktrees-delete-pty-teardown.test.ts index 1c904c9d7c0..adec8942450 100644 --- a/src/main/ipc/worktrees-delete-pty-teardown.test.ts +++ b/src/main/ipc/worktrees-delete-pty-teardown.test.ts @@ -12,6 +12,7 @@ import { import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' import { mockKnownFeatureWorktree } from './worktrees-test-fixtures' import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -446,7 +447,9 @@ describe('registerWorktreeHandlers', () => { ) expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(3) for (const item of approved) { - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(`repo-1::${item.path}`, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: `repo-1::${item.path}`, hostId: 'local' }) + ) } }) diff --git a/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts b/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts index 58eb84888e1..0c7a56d54df 100644 --- a/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts +++ b/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { listWorktreesMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { makeWorktreeMeta } from './worktrees-test-fixtures' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -114,7 +115,7 @@ describe('registerWorktreeHandlers', () => { }[] expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/discovered-wt', + hostWorktreeTarget({ id: 'repo-1::/workspace/discovered-wt', hostId: 'local' }), expect.objectContaining({ lastActivityAt: expect.any(Number), projectId: 'repo:repo-1', @@ -167,11 +168,14 @@ describe('registerWorktreeHandlers', () => { projectHostSetupId?: string }[] - expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/existing-wt', { - projectId: 'repo:repo-1', - hostId: 'local', - projectHostSetupId: 'repo-1' - }) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: 'repo-1::/workspace/existing-wt', hostId: 'local' }), + { + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + } + ) expect(listed[0].lastActivityAt).toBe(42) expect(listed[0]).toMatchObject({ projectId: 'repo:repo-1', @@ -236,9 +240,12 @@ describe('registerWorktreeHandlers', () => { projectHostSetupId?: string }[] - expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/existing-wt', { - projectId: 'github:stablyai/orca' - }) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: 'repo-1::/workspace/existing-wt', hostId: 'local' }), + { + projectId: 'github:stablyai/orca' + } + ) expect(listed[0]).toMatchObject({ id: 'repo-1::/workspace/existing-wt', projectId: 'github:stablyai/orca', @@ -345,9 +352,12 @@ describe('registerWorktreeHandlers', () => { }[] expect(listWorktreesMock).not.toHaveBeenCalled() - expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-ssh::/remote/orca', { - projectId: 'github:stablyai/orca' - }) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: 'repo-ssh::/remote/orca', hostId: 'ssh:ssh-target-1' }), + { + projectId: 'github:stablyai/orca' + } + ) expect(listed).toEqual([ expect.objectContaining({ id: 'repo-ssh::/remote/orca', @@ -425,7 +435,7 @@ describe('registerWorktreeHandlers', () => { }[] expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/existing-wt', + hostWorktreeTarget({ id: 'repo-1::/workspace/existing-wt', hostId: 'local' }), expect.objectContaining({ instanceId: expect.any(String), projectId: 'repo:repo-1', @@ -472,7 +482,7 @@ describe('registerWorktreeHandlers', () => { await handlers['worktrees:list'](null, { repoId: 'repo-1' }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/folder', + hostWorktreeTarget({ id: 'repo-1::/workspace/folder', hostId: 'local' }), expect.objectContaining({ lastActivityAt: expect.any(Number), projectId: 'repo:repo-1', @@ -502,7 +512,7 @@ describe('registerWorktreeHandlers', () => { }[] expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/discovered-wt', + hostWorktreeTarget({ id: 'repo-1::/workspace/discovered-wt', hostId: 'local' }), expect.objectContaining({ lastActivityAt: expect.any(Number) }) ) expect(listed[0]).toMatchObject({ diff --git a/src/main/ipc/worktrees-existing-branch-checkout.test.ts b/src/main/ipc/worktrees-existing-branch-checkout.test.ts index 09487998aaa..dd9a040b835 100644 --- a/src/main/ipc/worktrees-existing-branch-checkout.test.ts +++ b/src/main/ipc/worktrees-existing-branch-checkout.test.ts @@ -10,6 +10,10 @@ import { } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -144,7 +148,7 @@ describe('registerWorktreeHandlers', () => { { checkoutExistingBranch: true } ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/fix-bug-0', + hostWorktreeTarget({ id: 'repo-1::/workspace/fix-bug-0', hostId: 'local' }), expect.objectContaining({ preserveBranchOnDelete: true }) ) expect(result).toMatchObject({ @@ -202,7 +206,7 @@ describe('registerWorktreeHandlers', () => { { checkoutExistingBranch: true } ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/my-folder', + hostWorktreeTarget({ id: 'repo-1::/workspace/my-folder', hostId: 'local' }), expect.objectContaining({ preserveBranchOnDelete: true }) ) expect(result).toMatchObject({ @@ -312,7 +316,7 @@ describe('registerWorktreeHandlers', () => { isMainWorktree: false } ]) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta) getPRForBranchMock.mockResolvedValueOnce({ number: 42, title: 'Selected PR', @@ -347,7 +351,7 @@ describe('registerWorktreeHandlers', () => { { cwd: '/workspace/fix-title' } ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/fix-title', + hostWorktreeTarget({ id: 'repo-1::/workspace/fix-title', hostId: 'local' }), expect.objectContaining({ baseRef: 'refs/remotes/origin/main', linkedPR: 42 @@ -373,7 +377,7 @@ describe('registerWorktreeHandlers', () => { isMainWorktree: false } ]) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta) await handlers['worktrees:create'](null, { repoId: 'repo-1', @@ -386,7 +390,7 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/fix-title', + hostWorktreeTarget({ id: 'repo-1::/workspace/fix-title', hostId: 'local' }), expect.objectContaining({ baseRef: 'refs/remotes/origin/main', linkedGitLabMR: 7 @@ -407,7 +411,7 @@ describe('registerWorktreeHandlers', () => { isMainWorktree: false } ]) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta) getHostedReviewForBranchMock.mockResolvedValueOnce({ provider: 'bitbucket', number: 11, @@ -438,7 +442,7 @@ describe('registerWorktreeHandlers', () => { {} ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/bitbucket-title', + hostWorktreeTarget({ id: 'repo-1::/workspace/bitbucket-title', hostId: 'local' }), expect.objectContaining({ linkedBitbucketPR: 11 }) ) expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( @@ -494,7 +498,7 @@ describe('registerWorktreeHandlers', () => { {} ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/bitbucket-title-2', + hostWorktreeTarget({ id: 'repo-1::/workspace/bitbucket-title-2', hostId: 'local' }), expect.objectContaining({ linkedBitbucketPR: 11 }) ) }) diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index 8c02b4cb48d..d9ebb2e1ffa 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -23,6 +23,7 @@ import { retryFailedWorktreeRemoval, startBackgroundWorktreeRemoval } from '../worktree-background-removal' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -242,7 +243,9 @@ describe('a failed delete Git no longer registers, over desktop IPC', () => { featureId, expect.objectContaining({ requirePhysicalStop: true }) ) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(featureId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: featureId, hostId: 'local' }) + ) expect(await listFeature()).toBeUndefined() }) diff --git a/src/main/ipc/worktrees-forget-local.test.ts b/src/main/ipc/worktrees-forget-local.test.ts index 4d382dab8e1..f7bd350dc68 100644 --- a/src/main/ipc/worktrees-forget-local.test.ts +++ b/src/main/ipc/worktrees-forget-local.test.ts @@ -18,6 +18,7 @@ import { import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -139,7 +140,9 @@ describe('registerWorktreeHandlers', () => { }) expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId) // The purge must be scoped to the same owner the sweep used, or the ssh:* partition keeps this worktree's session state. - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:ssh-dead') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:ssh-dead' }) + ) expect(advertisedUrlWatcherForgetWorktreeMock).toHaveBeenCalledWith(worktreeId) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { @@ -157,7 +160,9 @@ describe('registerWorktreeHandlers', () => { await expect(handlers['worktrees:forgetLocal'](null, { worktreeId })).resolves.toEqual({}) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { repoId: 'repo-1' }) @@ -198,7 +203,9 @@ describe('registerWorktreeHandlers', () => { await expect(resolveRegisteredWorktreePath(worktreePath, store as never)).rejects.toThrow( 'Access denied: unknown repository or worktree path' ) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'runtime:env-1') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'runtime:env-1' }) + ) expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId) expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { repoId: 'repo-gone' @@ -227,7 +234,9 @@ describe('registerWorktreeHandlers', () => { await expect(handlers['worktrees:forgetLocal'](null, { worktreeId })).resolves.toEqual({}) // Without the resolved owner the purge resolves to [local] only and ssh:ssh-live keeps tabsByWorktree et al. forever. - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:ssh-live') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:ssh-live' }) + ) }) it('rejects an unqualified forget when the repo id has owners on two hosts', async () => { @@ -273,7 +282,9 @@ describe('registerWorktreeHandlers', () => { await expect(handlers['worktrees:forgetLocal'](null, { worktreeId })).resolves.toEqual({}) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) }) it('rejects forgetting a folder project root', async () => { @@ -316,7 +327,9 @@ describe('registerWorktreeHandlers', () => { handlers['worktrees:forgetLocal'](null, { worktreeId, hostId: 'ssh:removed' }) ).resolves.toEqual({}) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:removed') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:removed' }) + ) }) }) }) @@ -629,7 +642,11 @@ describe('worktree execution-host disambiguation', () => { finishFirst() await expect(first).resolves.toEqual({}) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:conn-1') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:conn-1' }) + ) }) }) diff --git a/src/main/ipc/worktrees-issue-command-overrides.test.ts b/src/main/ipc/worktrees-issue-command-overrides.test.ts index 1c8814c7dcd..144db6cc943 100644 --- a/src/main/ipc/worktrees-issue-command-overrides.test.ts +++ b/src/main/ipc/worktrees-issue-command-overrides.test.ts @@ -109,7 +109,7 @@ function makeRepo(fields: Record) { function useRepo(repo: ReturnType): void { store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue(repo) - store.setWorktreeMeta.mockImplementation((_worktreeId: string, meta: unknown) => meta) + store.setWorktreeMeta.mockImplementation((_target: unknown, meta: unknown) => meta) } describe('registerWorktreeHandlers', () => { diff --git a/src/main/ipc/worktrees-lineage-hydration.test.ts b/src/main/ipc/worktrees-lineage-hydration.test.ts index bacaff09602..40e49ab8ce2 100644 --- a/src/main/ipc/worktrees-lineage-hydration.test.ts +++ b/src/main/ipc/worktrees-lineage-hydration.test.ts @@ -4,6 +4,10 @@ import type { Worktree } from '../../shared/worktree/types' import { listWorktreesMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { makeWorktreeMeta } from './worktrees-test-fixtures' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -100,10 +104,12 @@ describe('registerWorktreeHandlers', () => { [childId]: { instanceId: 'child-instance' } } store.getWorktreeMeta.mockImplementation((id: string) => metaById[id]) - store.setWorktreeMeta.mockImplementation((id: string, updates: Partial) => ({ - ...metaById[id], - ...updates - })) + store.setWorktreeMeta.mockImplementation( + ({ id }: HostWorktreeTarget, updates: Partial) => ({ + ...metaById[id], + ...updates + }) + ) store.getAllWorktreeLineage.mockReturnValue({ [childId]: { worktreeId: childId, @@ -279,7 +285,7 @@ describe('registerWorktreeHandlers', () => { await handlers['worktrees:list'](null, { repoId: 'repo-ssh' }) expect(store.setWorktreeMeta).not.toHaveBeenCalledWith( - 'repo-ssh::/remote/missing-parent', + hostWorktreeTarget({ id: 'repo-ssh::/remote/missing-parent', hostId: 'local' }), expect.objectContaining({ instanceId: expect.any(String) }) ) }) diff --git a/src/main/ipc/worktrees-listing-fallback-rows.test.ts b/src/main/ipc/worktrees-listing-fallback-rows.test.ts index 79ea6dd4020..60094552de0 100644 --- a/src/main/ipc/worktrees-listing-fallback-rows.test.ts +++ b/src/main/ipc/worktrees-listing-fallback-rows.test.ts @@ -4,6 +4,7 @@ import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness import { makeWorktreeMeta } from './worktrees-test-fixtures' import { buildDetectedGitWorktrees } from './worktrees/listing/ssh-worktree-fallback' import type { Store } from '../persistence/loading-store/store' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -224,11 +225,14 @@ describe('registerWorktreeHandlers', () => { }) ]) expect(store.getWorktreeMeta).not.toHaveBeenCalled() - expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-ssh::/remote/feature-wt', { - projectId: 'repo:repo-ssh', - hostId: 'ssh:conn-1', - projectHostSetupId: 'repo-ssh' - }) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: 'repo-ssh::/remote/feature-wt', hostId: 'ssh:conn-1' }), + { + projectId: 'repo:repo-ssh', + hostId: 'ssh:conn-1', + projectHostSetupId: 'repo-ssh' + } + ) }) it('lists a canonical-only SSH row without leaking the same-id local row', async () => { diff --git a/src/main/ipc/worktrees-local-base-ref-resolution.test.ts b/src/main/ipc/worktrees-local-base-ref-resolution.test.ts index bd79a609ac1..1308a15fe26 100644 --- a/src/main/ipc/worktrees-local-base-ref-resolution.test.ts +++ b/src/main/ipc/worktrees-local-base-ref-resolution.test.ts @@ -10,6 +10,7 @@ import { } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -157,7 +158,7 @@ describe('registerWorktreeHandlers', () => { expect(addWorktreeMock).toHaveBeenCalled() expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard') expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + hostWorktreeTarget({ id: 'repo-1::/workspace/improve-dashboard', hostId: 'local' }), expect.objectContaining({ baseRef: 'refs/remotes/origin/main' }) ) }) @@ -517,7 +518,7 @@ describe('registerWorktreeHandlers', () => { } ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + hostWorktreeTarget({ id: 'repo-1::/workspace/improve-dashboard', hostId: 'local' }), expect.objectContaining({ baseRef: 'refs/remotes/origin/main' }) ) expect(result.localBaseRefUpdateSuggestion).toEqual({ diff --git a/src/main/ipc/worktrees-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index bd8d9f4aef5..40fd6d09a8e 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -22,6 +22,10 @@ import { } from './worktrees-test-module-mocks' import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -394,7 +398,7 @@ describe('registerWorktreeHandlers', () => { {} ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/rocket', + hostWorktreeTarget({ id: 'repo-1::/workspace/rocket', hostId: 'local' }), expect.objectContaining({ displayName: '🚀' }) ) }) @@ -446,7 +450,7 @@ describe('registerWorktreeHandlers', () => { {} ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::../worktrees/feature', + hostWorktreeTarget({ id: 'repo-1::../worktrees/feature', hostId: 'local' }), expect.objectContaining({ orcaCreationWorkspaceLayout: { path: '../worktrees', nestWorkspaces: false } }) @@ -584,7 +588,7 @@ describe('registerWorktreeHandlers', () => { kind: 'folder' as const } store.getRepo.mockReturnValue(repo) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => ({ + store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => ({ displayName: '', comment: '', linkedIssue: null, diff --git a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts index 2fe0982dc85..19b24e1c185 100644 --- a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts +++ b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts @@ -13,6 +13,7 @@ import { import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' import { makeWorktreeMeta, mockKnownFeatureWorktree } from './worktrees-test-fixtures' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -141,8 +142,7 @@ describe('registerWorktreeHandlers', () => { 'repo-1::/workspace/already-deleted-wt' ) expect(store.removeWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/already-deleted-wt', - 'local' + hostWorktreeTarget({ id: 'repo-1::/workspace/already-deleted-wt', hostId: 'local' }) ) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith( 'repo-1::/workspace/already-deleted-wt' @@ -166,7 +166,9 @@ describe('registerWorktreeHandlers', () => { expect(runHookMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { repoId: 'repo-1' @@ -188,8 +190,7 @@ describe('registerWorktreeHandlers', () => { 'repo-1::/workspace/already-deleted-wt' ) expect(store.removeWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/already-deleted-wt', - 'local' + hostWorktreeTarget({ id: 'repo-1::/workspace/already-deleted-wt', hostId: 'local' }) ) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith( 'repo-1::/workspace/already-deleted-wt' @@ -236,7 +237,9 @@ describe('registerWorktreeHandlers', () => { expect(runHookMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { repoId: 'repo-1' @@ -327,7 +330,9 @@ describe('registerWorktreeHandlers', () => { catalogVersion: anyCatalogVersion }) expect(removeWorktreeMock).not.toHaveBeenCalled() - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) } finally { await rm(parentDir, { recursive: true, force: true }) } diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index fd946f807f9..af20198631c 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -37,6 +37,7 @@ import { } from './worktrees-test-harness' import { makeWorktreeMeta, mockKnownFeatureWorktree } from './worktrees-test-fixtures' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -233,7 +234,9 @@ describe('registerWorktreeHandlers', () => { try { await handlers['worktrees:remove'](null, { worktreeId, hostId: 'local' }) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) expect(advertisedUrlWatcherForgetWorktreeMock).not.toHaveBeenCalled() expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled() expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { @@ -355,7 +358,9 @@ describe('registerWorktreeHandlers', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { cwd: '/workspace/repo' }) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/feature-wt', 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: 'repo-1::/workspace/feature-wt', hostId: 'local' }) + ) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith('repo-1::/workspace/feature-wt') expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { repoId: 'repo-1' @@ -407,7 +412,9 @@ describe('registerWorktreeHandlers', () => { await expect(handlers['worktrees:remove'](null, { worktreeId })).resolves.toEqual({ catalogVersion: anyCatalogVersion }) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) expect(finish).toHaveBeenLastCalledWith(true) } finally { removePath.mockRestore() @@ -451,7 +458,9 @@ describe('registerWorktreeHandlers', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { cwd: '/workspace/repo' }) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { repoId: 'repo-1' }) @@ -571,7 +580,9 @@ describe('registerWorktreeHandlers', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { cwd: '/workspace/repo' }) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) } ) @@ -596,7 +607,9 @@ describe('registerWorktreeHandlers', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { cwd: '/workspace/repo' }) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) expect((await lstat(markerPath)).isFile()).toBe(true) } finally { await rm(root, { recursive: true, force: true }) @@ -683,7 +696,9 @@ describe('registerWorktreeHandlers', () => { expect(killAllProcessesForWorktreeMock.mock.invocationCallOrder[0]).toBeLessThan( store.removeWorktreeMeta.mock.invocationCallOrder[0] ) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) expect(advertisedUrlWatcherForgetWorktreeMock).toHaveBeenCalledWith(worktreeId) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { @@ -705,7 +720,9 @@ describe('registerWorktreeHandlers', () => { await handlers['worktrees:remove'](null, { worktreeId }) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { repoId: 'repo-folder' }) @@ -745,7 +762,9 @@ describe('registerWorktreeHandlers', () => { includeLocalRegistry: false, closeStructuredSessions: true }) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:conn-1') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:conn-1' }) + ) expect(advertisedUrlWatcherForgetWorktreeMock).not.toHaveBeenCalled() expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled() }) @@ -817,7 +836,9 @@ describe('registerWorktreeHandlers', () => { worktreeIds: [worktreeId] }) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:target-a') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:target-a' }) + ) expect(agentHookServer.getStatusSnapshot().map((row) => row.paneKey)).toEqual([otherHostPane]) } finally { agentHookServer.dropStatusEntriesByTabPrefix('tab-scan') diff --git a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts index 5785fa825ae..c38d7af497e 100644 --- a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts +++ b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts @@ -10,6 +10,7 @@ import { } from './worktrees-test-module-mocks' import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' import { createdWorktreeList } from './worktrees-test-fixtures' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -225,7 +226,7 @@ describe('registerWorktreeHandlers', () => { {} ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + hostWorktreeTarget({ id: 'repo-1::/workspace/improve-dashboard', hostId: 'local' }), expect.objectContaining({ sparseDirectories: ['packages/web', 'apps/api'], sparseBaseRef: 'origin/main', @@ -293,7 +294,7 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + hostWorktreeTarget({ id: 'repo-1::/workspace/improve-dashboard', hostId: 'local' }), expect.objectContaining({ sparseDirectories: ['packages/web'], sparseBaseRef: 'origin/main', @@ -330,7 +331,7 @@ describe('registerWorktreeHandlers', () => { }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/improve-dashboard', + hostWorktreeTarget({ id: 'repo-1::/workspace/improve-dashboard', hostId: 'local' }), expect.objectContaining({ sparseDirectories: ['packages/web'], sparseBaseRef: 'origin/main', diff --git a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts index 4773de79308..4fb9a14aa95 100644 --- a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts +++ b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts @@ -14,6 +14,10 @@ import { } from './worktrees-test-module-mocks' import { handlers, ipcEvent, setupWorktreeHandlers, store } from './worktrees-test-harness' import { makeWorktreeMeta } from './worktrees-test-fixtures' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -369,7 +373,7 @@ describe('registerWorktreeHandlers', () => { store.getProjectHostSetups.mockReturnValue([]) store.getAllWorktreeMeta.mockReturnValue(metaById) store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId]) - store.removeWorktreeMeta.mockImplementation((worktreeId: string) => { + store.removeWorktreeMeta.mockImplementation(({ id: worktreeId }: HostWorktreeTarget) => { delete metaById[worktreeId] }) @@ -390,7 +394,9 @@ describe('registerWorktreeHandlers', () => { forgottenWorktreeIds: ['repo-1::/remote/deleted', 'repo-1::/remote/deleted-too'] }) expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(2) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/remote/deleted', sshHostId) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: 'repo-1::/remote/deleted', hostId: sshHostId }) + ) const pruneTargets = [ { worktreeId: 'repo-1::/remote/deleted', executionHostId: sshHostId }, { worktreeId: 'repo-1::/remote/deleted-too', executionHostId: sshHostId } @@ -461,7 +467,7 @@ describe('registerWorktreeHandlers', () => { store.getRepos.mockReturnValue([runtimeRepo]) store.getProjectHostSetups.mockReturnValue([]) store.getAllWorktreeMeta.mockReturnValue(metaById) - store.removeWorktreeMeta.mockImplementation((worktreeId: string) => { + store.removeWorktreeMeta.mockImplementation(({ id: worktreeId }: HostWorktreeTarget) => { delete metaById[worktreeId] }) @@ -474,8 +480,7 @@ describe('registerWorktreeHandlers', () => { // The row stamped to another host needs that host's own scan, not this one's. expect(forgotten).toEqual({ forgottenWorktreeIds: ['repo-1::/home/orca/deleted'] }) expect(store.removeWorktreeMeta).toHaveBeenCalledExactlyOnceWith( - 'repo-1::/home/orca/deleted', - runtimeHostId + hostWorktreeTarget({ id: 'repo-1::/home/orca/deleted', hostId: runtimeHostId }) ) }) diff --git a/src/main/ipc/worktrees-test-harness.ts b/src/main/ipc/worktrees-test-harness.ts index 44b8e9d4308..f583c275791 100644 --- a/src/main/ipc/worktrees-test-harness.ts +++ b/src/main/ipc/worktrees-test-harness.ts @@ -139,7 +139,6 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub { store.getAllWorktreeMeta, store.captureNativeLocalWorktreeMetadataScanExpectation, store.setWorktreeMeta, - store.setWorktreeMetaForHost, store.getProjectHostSetups, store.removeWorktreeMeta, store.pruneSessionlessMissingLocalWorktreeMetadataForRepo, @@ -207,9 +206,6 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub { store.getRetiredWorktreeNameRegistry.mockReturnValue({ exhaustedTiers: 0, names: [] }) resetRetirementCollisionKeyCacheForTests() store.setWorktreeMeta.mockReturnValue({}) - store.setWorktreeMetaForHost.mockImplementation((...args: unknown[]) => - store.setWorktreeMeta(args[0] as string, args[2] as object) - ) store.getProjectHostSetups.mockReturnValue([ { id: 'repo-1', diff --git a/src/main/ipc/worktrees-test-ipc-surface.ts b/src/main/ipc/worktrees-test-ipc-surface.ts index 985107b067e..4c23765fe15 100644 --- a/src/main/ipc/worktrees-test-ipc-surface.ts +++ b/src/main/ipc/worktrees-test-ipc-surface.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'node:events' import { type Mock, vi } from 'vitest' import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' export type HandlerMap = Record unknown> @@ -9,7 +10,11 @@ type StoreMock = Mock<(...args: unknown[]) => unknown> /** Store lookups tests re-implement per id, so the first arg stays narrowed. */ type KeyedStoreMock = Mock<(id: string, ...rest: unknown[]) => unknown> /** Store writers tests re-implement by merging the patch they receive. */ -type KeyedStoreWriteMock = Mock<(id: string, patch: Partial) => unknown> +type KeyedStoreWriteMock = Mock< + (target: HostWorktreeTarget, patch: Partial) => unknown +> +/** Host-qualified store removals. */ +type TargetStoreMock = Mock<(target: HostWorktreeTarget) => unknown> export type TestMainWindow = { isDestroyed: () => boolean @@ -28,13 +33,12 @@ export type TestStore = { getAllWorktreeMeta: StoreMock captureNativeLocalWorktreeMetadataScanExpectation: StoreMock setWorktreeMeta: KeyedStoreWriteMock - setWorktreeMetaForHost: StoreMock getProjectHostSetups: StoreMock - removeWorktreeMeta: KeyedStoreMock + removeWorktreeMeta: TargetStoreMock pruneSessionlessMissingLocalWorktreeMetadataForRepo: StoreMock removeWorkspaceSessionStateForWorktree: KeyedStoreMock getAllWorktreeLineage: StoreMock - removeWorktreeLineage: KeyedStoreMock + removeWorktreeLineage: TargetStoreMock getAllWorkspaceLineage: StoreMock getFolderWorkspaces: StoreMock getProjectGroups: StoreMock @@ -64,7 +68,6 @@ export const store: TestStore = { getAllWorktreeMeta: vi.fn(), captureNativeLocalWorktreeMetadataScanExpectation: vi.fn(), setWorktreeMeta: vi.fn(), - setWorktreeMetaForHost: vi.fn(), getProjectHostSetups: vi.fn(), removeWorktreeMeta: vi.fn(), pruneSessionlessMissingLocalWorktreeMetadataForRepo: vi.fn(), diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index d3746a2d4a0..f9bc308bfab 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -149,6 +149,7 @@ vi.mock('./worktree-logic', async (importOriginal) => { import { registerWorktreeHandlers } from './worktrees' import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retirement' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' type HandlerMap = Record unknown> @@ -349,7 +350,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { // A name the user typed is never retired — the pool holds ordinary words people choose. expect(store.addRetiredWorktreeName).not.toHaveBeenCalled() expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::C:/workspaces/improve-dashboard', + hostWorktreeTarget({ id: 'repo-1::C:/workspaces/improve-dashboard', hostId: 'local' }), expect.objectContaining({ lastActivityAt: expect.any(Number) }) @@ -598,8 +599,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { }) ) expect(store.removeWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::C:/workspaces/improve-dashboard', - 'local' + hostWorktreeTarget({ id: 'repo-1::C:/workspaces/improve-dashboard', hostId: 'local' }) ) // Windows history lives under a path-derived hash, so scheduling must not regress on this path only. expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith( diff --git a/src/main/ipc/worktrees-wsl-move-fence.test.ts b/src/main/ipc/worktrees-wsl-move-fence.test.ts index 1c77daca546..8bb79b07b87 100644 --- a/src/main/ipc/worktrees-wsl-move-fence.test.ts +++ b/src/main/ipc/worktrees-wsl-move-fence.test.ts @@ -19,9 +19,10 @@ import { listWorktreesMock, removeWorktreeMock } from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { handlers, harnessRepo, setupWorktreeHandlers, store } from './worktrees-test-harness' import { rememberPreservedBranchCleanupTarget } from './worktrees/removal/preserved-branch-cleanup' import * as issueCommandFile from '../issue-command-file' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -51,6 +52,8 @@ const STAYING = 'repo-2::/workspace/other-wt' let userDataPath: string beforeEach(() => { setupWorktreeHandlers() + // The staying worktree's repo must be registered: an unstamped write resolves its host from it. + store.getRepos.mockReturnValue([harnessRepo, { ...harnessRepo, id: 'repo-2' }]) userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-worktree-ipc-')) loadWslMoveFence(userDataPath) seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) @@ -126,7 +129,10 @@ describe('worktree IPC under a WSL move', () => { expect(() => updateMeta(MOVING, { comment: 'x' })).toThrow(WslMoveProjectFencedError) expect(store.setWorktreeMeta).not.toHaveBeenCalled() updateMeta(STAYING, { comment: 'x' }) - expect(store.setWorktreeMeta).toHaveBeenCalledWith(STAYING, { comment: 'x' }) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: STAYING, hostId: 'local' }), + { comment: 'x' } + ) }) it('drops the moving worktree from a sort-order batch', async () => { @@ -135,8 +141,11 @@ describe('worktree IPC under a WSL move', () => { ) await handlers['worktrees:persistSortOrder'](null, { orderedIds: [MOVING, STAYING] }) expect(store.setWorktreeMeta).toHaveBeenCalledTimes(1) - expect(store.setWorktreeMeta).toHaveBeenCalledWith(STAYING, { - sortOrder: expect.any(Number) - }) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: STAYING, hostId: 'local' }), + { + sortOrder: expect.any(Number) + } + ) }) }) diff --git a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts index eca72e290c3..1dfa7053a8e 100644 --- a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts +++ b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts @@ -24,6 +24,7 @@ import { mockKnownFeatureWorktree, mockSelectedWslProjectRuntime } from './worktrees-test-fixtures' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -260,7 +261,7 @@ describe('registerWorktreeHandlers', () => { isMainWorktree: false } ]) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta) await handlers['worktrees:create'](null, { repoId: 'repo-1', @@ -277,7 +278,7 @@ describe('registerWorktreeHandlers', () => { expect(calls.some((args) => args[0] === 'remote' && args[1] === 'add')).toBe(false) expect(calls.some((args) => args[0] === 'fetch')).toBe(false) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - expect.any(String), + expect.objectContaining({ id: expect.any(String) }), expect.objectContaining({ pushTarget: { remoteName: 'pr-contributor-orca', diff --git a/src/main/ipc/worktrees/create/folder-workspace-creation.ts b/src/main/ipc/worktrees/create/folder-workspace-creation.ts index 4ba0d41db4b..b35dad3b48c 100644 --- a/src/main/ipc/worktrees/create/folder-workspace-creation.ts +++ b/src/main/ipc/worktrees/create/folder-workspace-creation.ts @@ -6,6 +6,7 @@ import type { Store } from '../../../persistence/loading-store/store' import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas' import { getFolderWorkspaceInstanceId, mergeFolderWorkspace } from '../folder-workspace-model' import { resolveWorktreeCreateDisplayNameRequest } from '../../worktree-display-name' +import { hostWorktreeTargetForRepo } from '../../../persistence/host-qualified-worktree-meta' export function createFolderWorkspace( args: CreateWorktreeArgsWithSystemProvenance, @@ -22,7 +23,7 @@ export function createFolderWorkspace( args.cliProvenance?.kind === 'created-by-cli', args.nameWasGenerated === true ) - const meta = store.setWorktreeMeta(worktreeId, { + const meta = store.setWorktreeMeta(hostWorktreeTargetForRepo(worktreeId, repo), { instanceId, ...(store.getProjectHostSetups ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) diff --git a/src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts b/src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts index 5a22eccb303..fa8509c6708 100644 --- a/src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts +++ b/src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts @@ -9,6 +9,7 @@ import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' import type { GitWorktreeInfo } from '../../../../shared/worktree/types' import type * as NodeCryptoModule from 'node:crypto' import type * as OwnershipModule from '../../../../shared/worktree/ownership' +import type { HostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' const { toDetectedWorktreeSpy } = vi.hoisted(() => ({ toDetectedWorktreeSpy: vi.fn() })) @@ -79,6 +80,7 @@ function settledMeta(overrides: Partial = {}): WorktreeMeta { function createStore(meta: Record, repos: Repo[] = [repo]) { const rows = { ...meta } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial store mock; the code under test reads only the members it defines. return { getRepos: () => repos, getSettings: () => ({ workspaceDir: '/workspace', nestWorkspaces: true }), @@ -89,11 +91,7 @@ function createStore(meta: Record, repos: Repo[] = [repo]) getWorktreeMetaForHost: (id: string, hostId: string) => rows[id]?.hostId === hostId ? rows[id] : undefined, getAllWorktreeMetaForHost: () => rows, - setWorktreeMeta: (id: string, patch: Partial) => { - rows[id] = { ...rows[id], ...patch } as WorktreeMeta - return rows[id] - }, - setWorktreeMetaForHost: (id: string, hostId: string, patch: Partial) => { + setWorktreeMeta: ({ id, hostId }: HostWorktreeTarget, patch: Partial) => { rows[id] = { ...rows[id], ...patch, hostId } as WorktreeMeta return rows[id] } diff --git a/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts b/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts index 847632e1ddf..c3c51ac35c1 100644 --- a/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts +++ b/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts @@ -7,6 +7,7 @@ import { mergeWorktreeMetaForWrite } from '../../../persistence/loading-store/wo import { isWslMoveRepoBlocked } from '../../../persistence/moving-wsl-projects/wsl-move-background-backoff' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { + hostWorktreeTargetForRepo, readAllWorktreeMetaForHost, readWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta' @@ -34,7 +35,8 @@ function folderMetaWriter( repo: Repo ): (worktreeId: string, updates: Partial) => WorktreeMeta { if (!isWslMoveRepoBlocked(repo)) { - return (worktreeId, updates) => store.setWorktreeMeta(worktreeId, updates) + return (worktreeId, updates) => + store.setWorktreeMeta(hostWorktreeTargetForRepo(worktreeId, repo), updates) } return (worktreeId, updates) => { const existing = store.getWorktreeMeta(worktreeId) diff --git a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts index d943e9a2b57..57f0031ebdc 100644 --- a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts @@ -29,6 +29,7 @@ import { } from './ssh-worktree-fallback' import type { WorktreeIpcContext } from '../worktree-ipc-context' import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta' +import { hostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' export function registerHostCatalogHandlers(context: WorktreeIpcContext): void { const { store } = context @@ -142,7 +143,9 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void { if (meta.hostId && meta.hostId !== requestedExecutionHostId) { continue } - store.removeWorktreeMeta(worktreeId, requestedExecutionHostId) + store.removeWorktreeMeta( + hostWorktreeTarget({ id: worktreeId, hostId: requestedExecutionHostId }) + ) // Why here too: a scan the host answered is positive evidence of removal, and this is the only // path that ever retires an off-host row — so it owes the status store the same drop the // in-Orca delete does, or the SSH rows stay stranded in `last-status.json`. diff --git a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts index 8f8fe61ab6e..93ef3281b2c 100644 --- a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts +++ b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts @@ -10,10 +10,8 @@ import type { Repo } from '../../../../shared/repo-types' import type { GitWorktreeInfo, DetectedWorktree, Worktree } from '../../../../shared/worktree/types' import type { Store } from '../../../persistence/loading-store/store' import { getRepoExecutionHostId } from '../../../../shared/execution-host' -import { - readWorktreeMetaForRepo, - writeWorktreeMetaForHost -} from '../../../persistence/host-qualified-worktree-meta' +import { readWorktreeMetaForRepo } from '../../../persistence/host-qualified-worktree-meta' +import { hostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' import { getRepoOwnedWorktreeMeta } from '../../../worktree-metadata-ownership' import { buildKnownOrcaWorkspaceLayouts, @@ -117,7 +115,10 @@ export function listDisconnectedSshWorktrees( ? { ...candidate.meta, ...ownershipUpdates } : candidate.meta if (Object.keys(ownershipUpdates).length > 0) { - writeWorktreeMetaForHost(store, candidate.id, expectedHostId, ownershipUpdates) + store.setWorktreeMeta( + hostWorktreeTarget({ id: candidate.id, hostId: expectedHostId }), + ownershipUpdates + ) } // Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename() // applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must diff --git a/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts b/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts index 3edb8efc1d7..e75c1161b67 100644 --- a/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts +++ b/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts @@ -2,10 +2,9 @@ import type { Store } from '../../../persistence/loading-store/store' import type { Repo } from '../../../../shared/repo-types' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-lookup' -import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { - readWorktreeMetaForRepo, - writeWorktreeMetaForHost + hostWorktreeTargetForRepo, + readWorktreeMetaForRepo } from '../../../persistence/host-qualified-worktree-meta' import { getRepoOwnedWorktreeMeta } from '../../../worktree-metadata-ownership' import { randomUUID } from 'node:crypto' @@ -39,7 +38,7 @@ export function resolveWorktreeMetaWithDiscoveryBackfill( allMetaOverride?: Record, repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length ): WorktreeMeta { - const executionHostId = getRepoExecutionHostId(repo) + const target = hostWorktreeTargetForRepo(worktreeId, repo) const allMeta = allMetaOverride ?? store.getAllWorktreeMeta?.() // Why: the locator-keyed row is only a stand-in for a missing snapshot, so don't read it when we have one. const legacyMeta = allMeta === undefined ? store.getWorktreeMeta?.(worktreeId) : undefined @@ -59,11 +58,11 @@ export function resolveWorktreeMetaWithDiscoveryBackfill( } if (Object.keys(updates).length > 0) { // Why: pre-lineage profiles already have WorktreeMeta rows; backfill on discovery so upgraded workspaces get lineage and host routing. - return writeWorktreeMetaForHost(store, worktreeId, executionHostId, updates) + return store.setWorktreeMeta(target, updates) } return existing } - return writeWorktreeMetaForHost(store, worktreeId, executionHostId, { + return store.setWorktreeMeta(target, { lastActivityAt: Date.now(), ...ownershipUpdates }) diff --git a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts index 61f56990bb2..112ac8607b5 100644 --- a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts +++ b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts @@ -10,6 +10,8 @@ import { planWorktreeSortOrderUpdates } from '../../../../shared/worktree/sort-o import { readBranchRenameFailureOutputForDisplay } from '../../../agent-hooks/branch-rename-failure-output' import { normalizeLinkedWorkItemFields } from '../ipc-context-schemas' import { isRemovedFolderWorkspaceInstance } from '../../../folder-workspace-meta-presence' +import { resolveWorktreeMetaOwner } from '../../../persistence/host-qualified-worktree-meta' +import { hostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' import type { WorktreeIpcContext } from '../worktree-ipc-context' import { isWslMoveSourceHost, @@ -63,9 +65,13 @@ export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): v if (isRemovedFolderWorkspaceInstance(store, args.worktreeId, executionHostId)) { return null } - const meta = executionHostId - ? store.setWorktreeMetaForHost(args.worktreeId, executionHostId, sanitizedUpdates) - : store.setWorktreeMeta(args.worktreeId, sanitizedUpdates) + const target = executionHostId + ? hostWorktreeTarget({ id: args.worktreeId, hostId: executionHostId }) + : resolveWorktreeMetaOwner(store, args.worktreeId) + if (!target) { + return null + } + const meta = store.setWorktreeMeta(target, sanitizedUpdates) // Do NOT notify here: renderer already applied this optimistically; a notification would re-sort the sidebar (bug PR #209). if (args.updates.displayName !== undefined) { // Why: remote clients have no optimistic rename and stopped polling titles, so push a remote-only invalidation; gate on displayName so per-click isUnread updates stay event-free. @@ -109,7 +115,10 @@ export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): v Date.now() ).filter((update) => !isWslMoveWorkspaceBlocked(store, update.worktreeId)) for (const update of updates) { - store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder }) + const target = resolveWorktreeMetaOwner(store, update.worktreeId) + if (target) { + store.setWorktreeMeta(target, { sortOrder: update.sortOrder }) + } } }) diff --git a/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts index b5678cd538d..14205da289f 100644 --- a/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts +++ b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts @@ -16,6 +16,7 @@ import { pruneWorktreePRRefreshAliases } from '../../../github/pr-refresh-coordi import { recordWorkspaceCleanupRemovalSnapshotPrune } from '../../../workspace-cleanup-removal-snapshot-prune' import { pruneWorkspaceCleanupScanSnapshot } from '../../../workspace-cleanup-scan-snapshot' import { pruneWorkspaceSpaceAnalysisSnapshot } from '../../../workspace-space-analysis-snapshot' +import { removeWorktreeMetaRow } from '../../../persistence/host-qualified-worktree-meta' export async function stopPtysForDestructiveWorktreeRemoval( runtime: OrcaRuntimeService, @@ -91,11 +92,7 @@ export function removeWorktreeMetadataAndTransientState( hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId)) ) // Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace. - if (hostId) { - store.removeWorktreeMeta(worktreeId, hostId) - } else { - store.removeWorktreeMeta(worktreeId) - } + removeWorktreeMetaRow(store, worktreeId, hostId) // Why outside the same-id gate: retirement is per host and per pane, so a surviving owner keeps its own. agentHookServer.dropStatusEntriesForRemovedWorktree(worktreeId, hostId ?? persistedHostId) if (!preservesSameIdOwner) { diff --git a/src/main/persistence-automations.test.ts b/src/main/persistence-automations.test.ts index 3365e2751fd..c2b21547fca 100644 --- a/src/main/persistence-automations.test.ts +++ b/src/main/persistence-automations.test.ts @@ -13,6 +13,7 @@ import { tmpdir } from 'node:os' import type { PersistedState } from '../shared/persisted-state-types' import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../shared/execution-host' import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity' +import { removeWorktreeMetaRow } from './persistence/host-qualified-worktree-meta' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -707,7 +708,9 @@ describe('Store', () => { it('snapshots automation run workspace names for deleted-workspace history', async () => { const store = await createStore() store.addRepo(makeRepo()) - store.setWorktreeMeta('wt1', { displayName: 'Nightly workspace' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'wt1', hostId: 'local' }), { + displayName: 'Nightly workspace' + }) const automation = store.createAutomation({ name: 'Nightly', prompt: 'Run checks', @@ -721,7 +724,7 @@ describe('Store', () => { }) const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime()) - store.removeWorktreeMeta('wt1') + removeWorktreeMetaRow(store, 'wt1', undefined) expect(run.workspaceDisplayName).toBe('Nightly workspace') expect(store.listAutomationRuns(automation.id)[0].workspaceDisplayName).toBe( diff --git a/src/main/persistence-cohort-and-identity-migration.test.ts b/src/main/persistence-cohort-and-identity-migration.test.ts index ca40d13e34f..d3df878f0ec 100644 --- a/src/main/persistence-cohort-and-identity-migration.test.ts +++ b/src/main/persistence-cohort-and-identity-migration.test.ts @@ -17,6 +17,7 @@ import type { WorkspaceSessionState } from '../shared/workspace-session-state-ty import { getDefaultWorkspaceSession } from '../shared/constants' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' import { createProfileStateStore } from './persistence/profile-state/profile-state-store-factory' +import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -216,8 +217,14 @@ describe('Store.migrateWorktreeIdentity', () => { it('moves meta, lineage, tabs, active pointers, and records the prior id', async () => { const store = await createStore() - store.setWorktreeMeta(OLD, { displayName: 'Cunner', linkedIssue: 42 }) - store.setWorktreeLineage(OLD, makeWorktreeLineage({ worktreeId: OLD })) + store.setWorktreeMeta(hostWorktreeTarget({ id: OLD, hostId: 'local' }), { + displayName: 'Cunner', + linkedIssue: 42 + }) + store.setWorktreeLineage( + hostWorktreeTarget({ id: OLD, hostId: 'local' }), + makeWorktreeLineage({ worktreeId: OLD }) + ) store.setWorkspaceLineage( makeWorkspaceLineage({ childWorkspaceKey: OLD_WORKSPACE_KEY, @@ -322,7 +329,7 @@ describe('Store.migrateWorktreeIdentity', () => { 'runtime:env-a' ) - store.migrateWorktreeIdentity(OLD, NEW) + store.migrateWorktreeIdentity(hostWorktreeTarget({ id: OLD, hostId: 'local' }), NEW) expect(store.getWorktreeMeta(OLD)).toBeUndefined() const meta = store.getWorktreeMeta(NEW) @@ -384,14 +391,16 @@ describe('Store.migrateWorktreeIdentity', () => { it('rewrites parentWorktreeId back-references in other lineage entries', async () => { const store = await createStore() - store.setWorktreeMeta(OLD, { displayName: 'Cunner' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: OLD, hostId: 'local' }), { + displayName: 'Cunner' + }) const CHILD = 'repo1::/ws/child' store.setWorktreeLineage( - CHILD, + hostWorktreeTarget({ id: CHILD, hostId: 'local' }), makeWorktreeLineage({ worktreeId: CHILD, parentWorktreeId: OLD }) ) - store.migrateWorktreeIdentity(OLD, NEW) + store.migrateWorktreeIdentity(hostWorktreeTarget({ id: OLD, hostId: 'local' }), NEW) expect(store.getWorktreeLineage(CHILD)?.parentWorktreeId).toBe(NEW) }) @@ -406,7 +415,7 @@ describe('Store.migrateWorktreeIdentity', () => { } }) - store.migrateWorktreeIdentity(OLD, NEW) + store.migrateWorktreeIdentity(hostWorktreeTarget({ id: OLD, hostId: 'local' }), NEW) store.flush() expect(store.getMobileClientTabSelections()['device-a']?.[OLD]).toBeUndefined() @@ -416,10 +425,12 @@ describe('Store.migrateWorktreeIdentity', () => { it('accumulates prior ids across chained renames', async () => { const store = await createStore() - store.setWorktreeMeta(OLD, { displayName: 'Cunner' }) - store.migrateWorktreeIdentity(OLD, NEW) + store.setWorktreeMeta(hostWorktreeTarget({ id: OLD, hostId: 'local' }), { + displayName: 'Cunner' + }) + store.migrateWorktreeIdentity(hostWorktreeTarget({ id: OLD, hostId: 'local' }), NEW) const NEWER = 'repo1::/ws/final-name' - store.migrateWorktreeIdentity(NEW, NEWER) + store.migrateWorktreeIdentity(hostWorktreeTarget({ id: NEW, hostId: 'local' }), NEWER) expect(store.getWorktreeMeta(NEWER)?.priorWorktreeIds).toEqual([OLD, NEW]) }) @@ -430,7 +441,7 @@ describe('Store.migrateWorktreeIdentity', () => { lastVisitedAtByWorktreeId: { [OLD]: 100, [NEW]: 900 } }) - store.migrateWorktreeIdentity(OLD, NEW) + store.migrateWorktreeIdentity(hostWorktreeTarget({ id: OLD, hostId: 'local' }), NEW) expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.[OLD]).toBeUndefined() expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.[NEW]).toBe(900) @@ -438,8 +449,10 @@ describe('Store.migrateWorktreeIdentity', () => { it('is a no-op when the ids match', async () => { const store = await createStore() - store.setWorktreeMeta(OLD, { displayName: 'Cunner' }) - store.migrateWorktreeIdentity(OLD, OLD) + store.setWorktreeMeta(hostWorktreeTarget({ id: OLD, hostId: 'local' }), { + displayName: 'Cunner' + }) + store.migrateWorktreeIdentity(hostWorktreeTarget({ id: OLD, hostId: 'local' }), OLD) expect(store.getWorktreeMeta(OLD)?.priorWorktreeIds).toBeUndefined() }) }) diff --git a/src/main/persistence-deregistered-repo-residue.test.ts b/src/main/persistence-deregistered-repo-residue.test.ts index e2d74ffa42d..abaeaa4da13 100644 --- a/src/main/persistence-deregistered-repo-residue.test.ts +++ b/src/main/persistence-deregistered-repo-residue.test.ts @@ -14,7 +14,10 @@ import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { getDefaultWorkspaceSession } from '../shared/constants' -import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity' +import { + composeWorktreeHostIdentity, + hostWorktreeTarget +} from '../shared/worktree/host-qualified-identity' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' import type { PersistedState } from '../shared/persisted-state-types' @@ -77,8 +80,12 @@ describe('deregistered repo residue', () => { const seed = await createStore() seed.addRepo(makeRepo({ id: LIVE_REPO, path: '/workspace/live' })) seed.addRepo(makeRepo({ id: GONE_REPO, path: '/workspace/orphan' })) - seed.setWorktreeMetaForHost(LIVE_WORKTREE, 'local', { displayName: 'Live' }) - seed.setWorktreeMetaForHost(GONE_WORKTREE, 'local', { displayName: 'Orphan' }) + seed.setWorktreeMeta(hostWorktreeTarget({ id: LIVE_WORKTREE, hostId: 'local' }), { + displayName: 'Live' + }) + seed.setWorktreeMeta(hostWorktreeTarget({ id: GONE_WORKTREE, hostId: 'local' }), { + displayName: 'Orphan' + }) seed.setWorkspaceSession(sessionFor(GONE_WORKTREE), 'local') seed.flush() @@ -249,7 +256,9 @@ describe('deregistered repo residue', () => { it('leaves a profile with no orphans byte-identical across reloads', async () => { const seed = await createStore() seed.addRepo(makeRepo({ id: LIVE_REPO, path: '/workspace/live' })) - seed.setWorktreeMetaForHost(LIVE_WORKTREE, 'local', { displayName: 'Live' }) + seed.setWorktreeMeta(hostWorktreeTarget({ id: LIVE_WORKTREE, hostId: 'local' }), { + displayName: 'Live' + }) seed.setWorkspaceSession(sessionFor(LIVE_WORKTREE), 'local') seed.flush() diff --git a/src/main/persistence-duplicate-repo-id-host-scope.test.ts b/src/main/persistence-duplicate-repo-id-host-scope.test.ts index fc7cc463bbd..eea33910f0c 100644 --- a/src/main/persistence-duplicate-repo-id-host-scope.test.ts +++ b/src/main/persistence-duplicate-repo-id-host-scope.test.ts @@ -20,6 +20,7 @@ import { getDefaultPersistedState } from '../shared/constants' import { toRuntimeExecutionHostId } from '../shared/execution-host' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' import { resetRetirementCollisionKeyCacheForTests } from './worktree-name-retirement' +import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity' let hasCreatedStoreInCase = false @@ -130,7 +131,9 @@ describe('deleting one host copy of a repo id shared by two hosts', () => { const store = await createStoreFromState({ repos: duplicateIdRepos().map((repo) => ({ ...repo, kind })) }) - store.setWorktreeMetaForHost('dup::/remote/dup', 'ssh:ssh-1', { displayName: 'SSH wt' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'dup::/remote/dup', hostId: 'ssh:ssh-1' }), { + displayName: 'SSH wt' + }) store.removeProjectForHost({ id: 'dup', executionHostId: 'local' }) expect(store.getRepos().map((repo) => repo.path)).toEqual(['/remote/dup']) diff --git a/src/main/persistence-host-partitioned-sessions.test.ts b/src/main/persistence-host-partitioned-sessions.test.ts index c73ac216059..5d925dd425a 100644 --- a/src/main/persistence-host-partitioned-sessions.test.ts +++ b/src/main/persistence-host-partitioned-sessions.test.ts @@ -21,6 +21,7 @@ import { getDefaultWorkspaceSession } from '../shared/constants' import { isTerminalLeafId } from '../shared/stable-pane-id' import { worktreeWorkspaceKey } from '../shared/workspace-scope' +import { removeWorktreeMetaRow } from './persistence/host-qualified-worktree-meta' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -463,9 +464,11 @@ describe('Store host-partitioned workspace sessions', () => { store.setWorkspaceSession(session, 'local') store.setWorkspaceSession(session, 'runtime:env-a') store.setWorkspaceSession(session, 'runtime:env-b') - store.setWorktreeMeta(worktreeId, { hostId: 'runtime:env-a' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'runtime:env-a' }), { + hostId: 'runtime:env-a' + }) - store.removeWorktreeMeta(worktreeId) + removeWorktreeMetaRow(store, worktreeId, undefined) expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toBeUndefined() // Only the owning host's partition and the local blob it may spill into are cleaned; a same-id @@ -489,9 +492,11 @@ describe('Store host-partitioned workspace sessions', () => { // terminal state to the host's own `ssh:*` partition. Removal must clear both. store.setWorkspaceSession(makeSession(), 'local') store.setWorkspaceSession(makeSession(), 'ssh:conn-1') - store.setWorktreeMeta(worktreeId, { hostId: 'ssh:conn-1' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:conn-1' }), { + hostId: 'ssh:conn-1' + }) - store.removeWorktreeMeta(worktreeId, 'ssh:conn-1') + store.removeWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:conn-1' })) for (const hostId of ['local', 'ssh:conn-1'] as const) { expect(store.getWorkspaceSession(hostId)).toMatchObject({ @@ -527,9 +532,11 @@ describe('Store host-partitioned workspace sessions', () => { }, 'runtime:env-a' ) - store.setWorktreeMeta(worktreeId, { hostId: 'runtime:env-a' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'runtime:env-a' }), { + hostId: 'runtime:env-a' + }) - store.removeWorktreeMeta(worktreeId, 'local') + store.removeWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' })) expect( store.getWorkspaceSession('runtime:env-a').terminalTopologyRevisionByRepoId?.['repo-split'] @@ -552,17 +559,22 @@ describe('Store host-partitioned workspace sessions', () => { } store.setWorkspaceSession(session, 'local') store.setWorkspaceSession(session, 'runtime:env-b') - store.setWorktreeMeta(worktreeId, { hostId: 'local' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + hostId: 'local' + }) const worktreeLineage = makeWorktreeLineage({ worktreeId }) const workspaceLineage = makeWorkspaceLineage({ childWorkspaceKey: worktreeWorkspaceKey(worktreeId) }) - store.setWorktreeLineage(worktreeId, worktreeLineage) + store.setWorktreeLineage( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), + worktreeLineage + ) store.setWorkspaceLineage(workspaceLineage) // The confirmed removal target is env-b. Bare metadata belongs to the same-id // local owner and must not redirect the post-delete purge back to local. - store.removeWorktreeMeta(worktreeId, 'runtime:env-b') + store.removeWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'runtime:env-b' })) expect(store.getWorktreeMeta(worktreeId)?.hostId).toBe('local') expect(store.getWorktreeLineage(worktreeId)).toEqual(worktreeLineage) @@ -597,9 +609,11 @@ describe('Store host-partitioned workspace sessions', () => { } store.setWorkspaceSession(localSession, 'local') store.setWorkspaceSession(remoteSession, 'ssh:ssh-b') - store.setWorktreeMeta(worktreeId, { hostId: 'ssh:ssh-b' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:ssh-b' }), { + hostId: 'ssh:ssh-b' + }) - store.removeWorktreeMeta(worktreeId, 'ssh:ssh-b') + store.removeWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:ssh-b' })) expect(store.getWorkspaceSession('local').tabsByWorktree[worktreeId]).toEqual([ expect.objectContaining({ id: 'same-id-local-tab' }) @@ -620,7 +634,7 @@ describe('Store host-partitioned workspace sessions', () => { store.setWorkspaceSession(session, 'runtime:env-a') store.setWorkspaceSession(session, 'local') - store.removeWorktreeMeta(worktreeId, 'runtime:env-a') + store.removeWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'runtime:env-a' })) expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toBeUndefined() expect(store.getWorkspaceSession('local').tabsByWorktree[worktreeId]).toBeUndefined() diff --git a/src/main/persistence-orcad-migration-catalog.test.ts b/src/main/persistence-orcad-migration-catalog.test.ts index 6d8621798e7..eac21dc8326 100644 --- a/src/main/persistence-orcad-migration-catalog.test.ts +++ b/src/main/persistence-orcad-migration-catalog.test.ts @@ -24,6 +24,7 @@ import { testState, writeDataFile } from './persistence-test-harness' +import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity' const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ trackMock: vi.fn(), @@ -267,7 +268,7 @@ describe('orcad migration catalog persistence', () => { it('rejects dormant-state conflicts before publishing any catalog row', () => { const store = createStore() - store.setWorktreeMeta(DORMANT_WORKTREE_ID, { + store.setWorktreeMeta(hostWorktreeTarget({ id: DORMANT_WORKTREE_ID, hostId: 'local' }), { ...dormantState().worktreeMeta[0].meta, comment: 'Different owner' }) diff --git a/src/main/persistence-remote-session-startup.test.ts b/src/main/persistence-remote-session-startup.test.ts index 89f082d665c..467d39f5b89 100644 --- a/src/main/persistence-remote-session-startup.test.ts +++ b/src/main/persistence-remote-session-startup.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { getDefaultWorkspaceSession } from '../shared/constants' import type { BrowserPage, BrowserWorkspace } from '../shared/browser-workspace-types' +import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity' vi.mock('./ssh/config/ssh-config-parser', () => ({ loadUserSshConfig: vi.fn(), @@ -88,7 +89,9 @@ describe('remote session startup ownership', () => { it('retains remote metadata when no session or local catalog row names its repo', () => { const seed = createStore() - seed.setWorktreeMetaForHost(WORKTREE, HOST, { displayName: 'Remote work' }) + seed.setWorktreeMeta(hostWorktreeTarget({ id: WORKTREE, hostId: HOST }), { + displayName: 'Remote work' + }) seed.flush() const reloaded = createStore() expect(reloaded.getWorktreeMeta(WORKTREE)).toMatchObject({ displayName: 'Remote work' }) @@ -114,7 +117,9 @@ describe('remote session startup ownership', () => { const seed = createStore() seed.setWorkspaceSession(browserSession(), HOST) seed.setWorkspaceSession(browserSession(), OTHER_HOST) - seed.setWorktreeMetaForHost(WORKTREE, HOST, { displayName: 'Removed host' }) + seed.setWorktreeMeta(hostWorktreeTarget({ id: WORKTREE, hostId: HOST }), { + displayName: 'Removed host' + }) seed.flush() const reloaded = createStore() diff --git a/src/main/persistence-repo-lifecycle.test.ts b/src/main/persistence-repo-lifecycle.test.ts index 43574be2e0b..6dc555947ad 100644 --- a/src/main/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence-repo-lifecycle.test.ts @@ -29,7 +29,10 @@ import { getLocalWorktreeScanGeneration, isLocalWorktreeScanGenerationCurrent } from './local-worktree-scan-generation' +import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity' +import type { ExecutionHostId } from '../shared/execution-host' +const at = (id: string, hostId: ExecutionHostId) => hostWorktreeTarget({ id, hostId }) // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ loadUserSshConfigMock: vi.fn(), @@ -289,9 +292,9 @@ describe('Store', () => { store.addRepo(makeRepo({ id: 'r1' })) store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) - store.setWorktreeMeta('r1::/path/wt1', { displayName: 'wt1' }) - store.setWorktreeMeta('r1::/path/wt2', { displayName: 'wt2' }) - store.setWorktreeMeta('r2::/other', { displayName: 'other' }) + store.setWorktreeMeta(at('r1::/path/wt1', 'local'), { displayName: 'wt1' }) + store.setWorktreeMeta(at('r1::/path/wt2', 'local'), { displayName: 'wt2' }) + store.setWorktreeMeta(at('r2::/other', 'local'), { displayName: 'other' }) store.removeProjectForHost({ id: 'r1', executionHostId: 'local' }) @@ -323,8 +326,8 @@ describe('Store', () => { store.addRepo(makeRepo({ id: 'r1' })) store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) - store.setWorktreeMeta('r1::/path/wt1', { displayName: 'wt1' }) - store.setWorktreeMeta('r2::/other', { displayName: 'other' }) + store.setWorktreeMeta(at('r1::/path/wt1', 'local'), { displayName: 'wt1' }) + store.setWorktreeMeta(at('r2::/other', 'local'), { displayName: 'other' }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), @@ -342,7 +345,7 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) - store.setWorktreeMeta('r1::/path/wt1', { displayName: 'wt1' }) + store.setWorktreeMeta(at('r1::/path/wt1', 'local'), { displayName: 'wt1' }) const hostId = 'ssh:host-a' store.setWorkspaceSession( @@ -391,21 +394,21 @@ describe('Store', () => { store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) store.setWorktreeLineage( - 'r1::/path/child', + at('r1::/path/child', 'local'), makeWorktreeLineage({ worktreeId: 'r1::/path/child', parentWorktreeId: 'r1::/path/parent' }) ) store.setWorktreeLineage( - 'r2::/other-child', + at('r2::/other-child', 'local'), makeWorktreeLineage({ worktreeId: 'r2::/other-child', parentWorktreeId: 'r1::/path/parent' }) ) store.setWorktreeLineage( - 'r2::/other', + at('r2::/other', 'local'), makeWorktreeLineage({ worktreeId: 'r2::/other', parentWorktreeId: 'r2::/parent' @@ -451,10 +454,9 @@ describe('Store', () => { executionHostId: 'ssh:ssh-old' }) ) - store.setWorktreeMeta('shared::/local/repo/wt', { displayName: 'local-wt', hostId: 'local' }) - store.setWorktreeMeta('shared::/remote/repo/wt', { - displayName: 'remote-wt', - hostId: 'ssh:ssh-old' + store.setWorktreeMeta(at('shared::/local/repo/wt', 'local'), { displayName: 'local-wt' }) + store.setWorktreeMeta(at('shared::/remote/repo/wt', 'ssh:ssh-old'), { + displayName: 'remote-wt' }) store.removeProjectForHost({ id: 'shared', executionHostId: 'ssh:ssh-old' }) @@ -487,7 +489,7 @@ describe('Store', () => { executionHostId: 'ssh:ssh-a' }) ) - store.setWorktreeMeta('shared::/repo', { displayName: 'local', hostId: 'local' }) + store.setWorktreeMeta(at('shared::/repo', 'local'), { displayName: 'local' }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), @@ -521,7 +523,7 @@ describe('Store', () => { executionHostId: 'ssh:ssh-a' }) ) - store.setWorktreeMeta('shared::/repo', { displayName: 'local', hostId: 'local' }) + store.setWorktreeMeta(at('shared::/repo', 'local'), { displayName: 'local' }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), @@ -592,7 +594,7 @@ describe('Store', () => { executionHostId: 'ssh:ssh-b' }) ) - store.setWorktreeMeta('shared::/repo', { displayName: 'local', hostId: 'local' }) + store.setWorktreeMeta(at('shared::/repo', 'local'), { displayName: 'local' }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), @@ -693,12 +695,11 @@ describe('Store', () => { }) ) // SSH worktree meta correctly carries hostId (the normal, stamped case). - store.setWorktreeMeta('shared::/remote/repo/wt', { - displayName: 'remote-wt', - hostId: 'ssh:ssh-old' + store.setWorktreeMeta(at('shared::/remote/repo/wt', 'ssh:ssh-old'), { + displayName: 'remote-wt' }) // A hostId-less meta is treated as local and left behind (never delete the wrong host's meta). - store.setWorktreeMeta('shared::/local/repo/wt', { displayName: 'local-wt' }) + store.setWorktreeMeta(at('shared::/local/repo/wt', 'local'), { displayName: 'local-wt' }) store.removeProjectForHost({ id: 'shared', executionHostId: 'ssh:ssh-old' }) @@ -709,7 +710,7 @@ describe('Store', () => { it('removeProjectForHost prunes everything when the id exists on no other host', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'only', connectionId: 'ssh-x', executionHostId: 'ssh:ssh-x' })) - store.setWorktreeMeta('only::/repo/wt', { displayName: 'wt', hostId: 'ssh:ssh-x' }) + store.setWorktreeMeta(at('only::/repo/wt', 'ssh:ssh-x'), { displayName: 'wt' }) store.removeProjectForHost({ id: 'only', executionHostId: 'ssh:ssh-x' }) @@ -755,7 +756,7 @@ describe('Store', () => { it('reassignSshTargetId re-points repos and worktree metas onto the new id', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' })) - store.setWorktreeMeta('r1::/repo/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' }) + store.setWorktreeMeta(at('r1::/repo/wt', 'ssh:ssh-old'), { displayName: 'wt' }) const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') @@ -805,7 +806,7 @@ describe('Store', () => { // persisted, not memory-only. The repo id stays registered so the load-time orphan sweep, // which only reads repo ids, leaves the row alone. store.addRepo(makeRepo({ id: 'r1', path: '/r1' })) - store.setWorktreeMeta('r1::/remote/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' }) + store.setWorktreeMeta(at('r1::/remote/wt', 'ssh:ssh-old'), { displayName: 'wt' }) const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') expect(repoIds).toEqual([]) // no repo matched diff --git a/src/main/persistence-update-repo.test.ts b/src/main/persistence-update-repo.test.ts index 89ea94e9598..f21fd2a44a9 100644 --- a/src/main/persistence-update-repo.test.ts +++ b/src/main/persistence-update-repo.test.ts @@ -23,6 +23,8 @@ import { getLocalWorktreeScanGeneration, isLocalWorktreeScanGenerationCurrent } from './local-worktree-scan-generation' +import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity' +import { removeWorktreeMetaRow } from './persistence/host-qualified-worktree-meta' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -365,7 +367,9 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1', path: '/repo', kind: 'folder', displayName: 'Repo' })) const workspaceId = 'r1::/repo::workspace:11111111-1111-4111-8111-111111111111' - store.setWorktreeMeta(workspaceId, { displayName: 'Second workspace' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: workspaceId, hostId: 'local' }), { + displayName: 'Second workspace' + }) expect(() => store.updateProjectHostSetup({ @@ -375,7 +379,7 @@ describe('Store', () => { ).toThrow("This folder project has 1 other workspace that a Git project can't show") expect(store.getRepo('r1')).toMatchObject({ kind: 'folder', displayName: 'Repo' }) - store.removeWorktreeMeta(workspaceId) + removeWorktreeMetaRow(store, workspaceId, undefined) expect( store.updateProjectHostSetup({ setupId: 'r1', updates: { kind: 'git' } })?.repo ).toMatchObject({ kind: 'git' }) @@ -460,7 +464,9 @@ describe('Store', () => { it('deletes repo-backed project host setups by removing the compatibility repo', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1', path: '/repo' })) - store.setWorktreeMeta('r1::/path/wt1', { displayName: 'wt1' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'r1::/path/wt1', hostId: 'local' }), { + displayName: 'wt1' + }) const result = store.deleteProjectHostSetup({ setupId: 'r1' }) diff --git a/src/main/persistence-workspace-attachments.test.ts b/src/main/persistence-workspace-attachments.test.ts index 99976cf9023..6a695153434 100644 --- a/src/main/persistence-workspace-attachments.test.ts +++ b/src/main/persistence-workspace-attachments.test.ts @@ -12,6 +12,7 @@ import { import { getWorkspaceAttachments } from '../shared/workspace-attachments' import type { WorkspaceAttachment } from '../shared/worktree/types' import { mergeWorktree } from './ipc/worktree-metadata-merge' +import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity' vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -35,8 +36,12 @@ describe('workspace attachment persistence', () => { it('round-trips collections and active selection through host-qualified metadata and git projections', async () => { writeDataFile({ repos: [makeRepo({ id: 'repo', path: '/work', connectionId: 'test' })] }) const store = await createStore() - store.setWorktreeMetaForHost('repo::/work', 'ssh:test', { linkedPR: 7 }) - store.setWorktreeMetaForHost('repo::/work', 'ssh:test', { linkedPR: 8 }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'repo::/work', hostId: 'ssh:test' }), { + linkedPR: 7 + }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'repo::/work', hostId: 'ssh:test' }), { + linkedPR: 8 + }) store.flush() const restored = await createStore() const meta = restored.getWorktreeMetaForHost('repo::/work', 'ssh:test') @@ -49,7 +54,9 @@ describe('workspace attachment persistence', () => { ) expect(projected.linkedItems).toEqual([pr(7), pr(8)]) expect( - restored.setWorktreeMetaForHost('repo::/work', 'ssh:test', { linkedItems: [pr(7)] }).linkedPR + restored.setWorktreeMeta(hostWorktreeTarget({ id: 'repo::/work', hostId: 'ssh:test' }), { + linkedItems: [pr(7)] + }).linkedPR ).toBe(7) restored.flush() expect( @@ -67,7 +74,7 @@ describe('workspace attachment persistence', () => { const store = await createStore() expect(getWorkspaceAttachments(store.getWorktreeMeta('legacy'))).toEqual([pr(7)]) expect(store.getWorktreeMeta('repaired')?.linkedItems).toEqual([pr(7), pr(8)]) - store.setWorktreeMeta('legacy', { linkedPR: 8 }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'legacy', hostId: 'local' }), { linkedPR: 8 }) expect(store.getWorktreeMeta('legacy')?.linkedItems).toEqual([pr(7), pr(8)]) }) @@ -97,11 +104,13 @@ describe('workspace attachment persistence', () => { }) it('applies stale client add/remove deltas atomically for worktrees and folders', async () => { const store = await createStore() - store.setWorktreeMeta('wt', { linkedItems: [pr(1)] }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'wt', hostId: 'local' }), { + linkedItems: [pr(1)] + }) const firstClient = { linkedItemsBase: [pr(1)], linkedItems: [pr(1), pr(2)] } const secondClient = { linkedItemsBase: [pr(1)], linkedItems: [pr(3)] } - store.setWorktreeMeta('wt', firstClient) - store.setWorktreeMeta('wt', secondClient) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'wt', hostId: 'local' }), firstClient) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'wt', hostId: 'local' }), secondClient) expect(store.getWorktreeMeta('wt')?.linkedItems).toEqual([pr(2), pr(3)]) expect(store.getWorktreeMeta('wt')).not.toHaveProperty('linkedItemsBase') const group = store.createProjectGroup({ @@ -123,8 +132,12 @@ describe('workspace attachment persistence', () => { it('keeps the current review target when a stale collection edit resolved another review', async () => { const store = await createStore() const currentTarget = { remoteName: 'origin', branchName: 'pr-2' } - store.setWorktreeMeta('wt', { linkedItems: [pr(2)], linkedPR: 2, pushTarget: currentTarget }) - store.setWorktreeMeta('wt', { + store.setWorktreeMeta(hostWorktreeTarget({ id: 'wt', hostId: 'local' }), { + linkedItems: [pr(2)], + linkedPR: 2, + pushTarget: currentTarget + }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'wt', hostId: 'local' }), { linkedItemsBase: [], linkedItems: [pr(1)], linkedPR: 1, @@ -136,7 +149,7 @@ describe('workspace attachment persistence', () => { linkedItems: [pr(2), pr(1)], pushTarget: currentTarget }) - store.setWorktreeMeta('wt', { + store.setWorktreeMeta(hostWorktreeTarget({ id: 'wt', hostId: 'local' }), { linkedItemsBase: [pr(2), pr(1)], linkedItems: [pr(2), pr(1)], linkedPR: 1, diff --git a/src/main/persistence-worktree-deletion-fencing.test.ts b/src/main/persistence-worktree-deletion-fencing.test.ts index 496ba1af89c..1dcf80578d8 100644 --- a/src/main/persistence-worktree-deletion-fencing.test.ts +++ b/src/main/persistence-worktree-deletion-fencing.test.ts @@ -11,6 +11,8 @@ import { tmpdir } from 'node:os' import { getDefaultWorkspaceSession } from '../shared/constants' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' +import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity' +import { removeWorktreeMetaRow } from './persistence/host-qualified-worktree-meta' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -156,7 +158,9 @@ describe('Store', () => { it('keeps worktree deletion authoritative against stale writes and later same-path reuse', async () => { const store = await createStore() - store.setWorktreeMeta('wt1', { displayName: 'Worktree' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'wt1', hostId: 'local' }), { + displayName: 'Worktree' + }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: { @@ -177,7 +181,7 @@ describe('Store', () => { }) const stale = structuredClone(store.getWorkspaceSession()) - store.removeWorktreeMeta('wt1') + removeWorktreeMetaRow(store, 'wt1', undefined) expect(store.getWorkspaceSession().tabsByWorktree.wt1).toBeUndefined() expect(store.getWorkspaceSession().terminalLayoutsByTabId['old-tab']).toBeUndefined() @@ -202,7 +206,9 @@ describe('Store', () => { it('fences a delayed terminal snapshot after an empty worktree is deleted', async () => { const store = await createStore() const worktreeId = 'repo::/empty-worktree' - store.setWorktreeMeta(worktreeId, { displayName: 'Empty worktree' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + displayName: 'Empty worktree' + }) const stale = { ...getDefaultWorkspaceSession(), tabsByWorktree: { @@ -210,7 +216,7 @@ describe('Store', () => { } } - store.removeWorktreeMeta(worktreeId) + removeWorktreeMetaRow(store, worktreeId, undefined) expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo).toBe(1) store.setWorkspaceSession(stale) @@ -221,7 +227,9 @@ describe('Store', () => { it('advances deletion authority when only a legacy retirement fence existed', async () => { const store = await createStore() const worktreeId = 'repo::/legacy-tombstone' - store.setWorktreeMeta(worktreeId, { displayName: 'Legacy worktree' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + displayName: 'Legacy worktree' + }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), terminalSurfaceTombstonesByPaneKey: { @@ -238,7 +246,7 @@ describe('Store', () => { const revisionBeforeDelete = store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo ?? 0 - store.removeWorktreeMeta(worktreeId) + removeWorktreeMetaRow(store, worktreeId, undefined) expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo).toBe( revisionBeforeDelete + 1 @@ -287,14 +295,16 @@ describe('Store', () => { for (let index = 0; index < 25; index += 1) { const worktreeId = `repo::/worktree-${index}` - store.setWorktreeMeta(worktreeId, { displayName: `Worktree ${index}` }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + displayName: `Worktree ${index}` + }) await store.persistPtyBinding({ worktreeId, tabId: `tab-${index}`, leafId: TEST_LEAF_1, ptyId: `pty-${index}` }) - store.removeWorktreeMeta(worktreeId) + removeWorktreeMetaRow(store, worktreeId, undefined) } const session = store.getWorkspaceSession() @@ -305,7 +315,9 @@ describe('Store', () => { it('does not remove colliding worktree ids from other execution-host partitions', async () => { const store = await createStore() const worktreeId = 'repo::/same-path' - store.setWorktreeMeta(worktreeId, { displayName: 'Local worktree' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + displayName: 'Local worktree' + }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: { @@ -331,7 +343,7 @@ describe('Store', () => { 'runtime:env-b' ) - store.removeWorktreeMeta(worktreeId) + removeWorktreeMetaRow(store, worktreeId, undefined) expect(store.getWorkspaceSession().tabsByWorktree[worktreeId]).toBeUndefined() expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]?.[0]?.id).toBe( diff --git a/src/main/persistence-worktree-lineage-and-backups.test.ts b/src/main/persistence-worktree-lineage-and-backups.test.ts index f90c6779378..574a9768022 100644 --- a/src/main/persistence-worktree-lineage-and-backups.test.ts +++ b/src/main/persistence-worktree-lineage-and-backups.test.ts @@ -20,6 +20,8 @@ import { restoreProfileStateJsonExport } from './persistence/profile-state/legac import { acquireProfileStateMaintenance } from './persistence/profile-state/profile-state-access' import type { Repo } from '../shared/repo-types' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity' +import { removeWorktreeMetaRow } from './persistence/host-qualified-worktree-meta' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -77,8 +79,8 @@ describe('Store', () => { it('getAllWorktreeMeta returns all entries', async () => { const store = await createStore() - store.setWorktreeMeta('a', { displayName: 'A' }) - store.setWorktreeMeta('b', { displayName: 'B' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'a', hostId: 'local' }), { displayName: 'A' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'b', hostId: 'local' }), { displayName: 'B' }) const all = store.getAllWorktreeMeta() expect(Object.keys(all)).toHaveLength(2) expect(all['a'].displayName).toBe('A') @@ -89,9 +91,9 @@ describe('Store', () => { it('removeWorktreeMeta deletes a single entry', async () => { const store = await createStore() - store.setWorktreeMeta('a', { displayName: 'A' }) - store.setWorktreeMeta('b', { displayName: 'B' }) - store.removeWorktreeMeta('a') + store.setWorktreeMeta(hostWorktreeTarget({ id: 'a', hostId: 'local' }), { displayName: 'A' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'b', hostId: 'local' }), { displayName: 'B' }) + removeWorktreeMetaRow(store, 'a', undefined) expect(store.getWorktreeMeta('a')).toBeUndefined() expect(store.getWorktreeMeta('b')).toBeDefined() }) @@ -100,13 +102,18 @@ describe('Store', () => { const store = await createStore() const lineage = makeWorktreeLineage() - store.setWorktreeMeta(lineage.worktreeId, { displayName: 'child' }) - store.setWorktreeLineage(lineage.worktreeId, lineage) + store.setWorktreeMeta(hostWorktreeTarget({ id: lineage.worktreeId, hostId: 'local' }), { + displayName: 'child' + }) + store.setWorktreeLineage( + hostWorktreeTarget({ id: lineage.worktreeId, hostId: 'local' }), + lineage + ) expect(store.getWorktreeLineage(lineage.worktreeId)).toEqual(lineage) expect(store.getAllWorktreeLineage()).toEqual({ [lineage.worktreeId]: lineage }) - store.removeWorktreeLineage(lineage.worktreeId) + store.removeWorktreeLineage(hostWorktreeTarget({ id: lineage.worktreeId, hostId: 'local' })) expect(store.getWorktreeLineage(lineage.worktreeId)).toBeUndefined() expect(store.getWorktreeMeta(lineage.worktreeId)).toBeDefined() @@ -116,10 +123,15 @@ describe('Store', () => { const store = await createStore() const lineage = makeWorktreeLineage() - store.setWorktreeMeta(lineage.worktreeId, { displayName: 'child' }) - store.setWorktreeLineage(lineage.worktreeId, lineage) + store.setWorktreeMeta(hostWorktreeTarget({ id: lineage.worktreeId, hostId: 'local' }), { + displayName: 'child' + }) + store.setWorktreeLineage( + hostWorktreeTarget({ id: lineage.worktreeId, hostId: 'local' }), + lineage + ) - store.removeWorktreeMeta(lineage.worktreeId) + removeWorktreeMetaRow(store, lineage.worktreeId, undefined) expect(store.getWorktreeMeta(lineage.worktreeId)).toBeUndefined() expect(store.getWorktreeLineage(lineage.worktreeId)).toBeUndefined() @@ -129,13 +141,15 @@ describe('Store', () => { const store = await createStore() const lineage = makeWorkspaceLineage() - store.setWorktreeMeta('r1::/path/child', { displayName: 'child' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'r1::/path/child', hostId: 'local' }), { + displayName: 'child' + }) store.setWorkspaceLineage(lineage) expect(store.getWorkspaceLineage(lineage.childWorkspaceKey)).toEqual(lineage) expect(store.getAllWorkspaceLineage()).toEqual({ [lineage.childWorkspaceKey]: lineage }) - store.removeWorktreeMeta('r1::/path/child') + removeWorktreeMetaRow(store, 'r1::/path/child', undefined) expect(store.getWorkspaceLineage(lineage.childWorkspaceKey)).toBeUndefined() }) diff --git a/src/main/persistence-worktree-meta-and-folder-workspaces.test.ts b/src/main/persistence-worktree-meta-and-folder-workspaces.test.ts index d7f17f6ecb0..7c1fcb6628d 100644 --- a/src/main/persistence-worktree-meta-and-folder-workspaces.test.ts +++ b/src/main/persistence-worktree-meta-and-folder-workspaces.test.ts @@ -14,6 +14,7 @@ import { tmpdir } from 'node:os' import type { PersistedState } from '../shared/persisted-state-types' import { getDefaultWorkspaceSession } from '../shared/constants' import { folderWorkspaceKey } from '../shared/workspace-scope' +import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -71,7 +72,9 @@ describe('Store', () => { it('setWorktreeMeta creates meta with defaults for missing fields', async () => { const store = await createStore() - const meta = store.setWorktreeMeta('wt1', { displayName: 'my-wt' }) + const meta = store.setWorktreeMeta(hostWorktreeTarget({ id: 'wt1', hostId: 'local' }), { + displayName: 'my-wt' + }) expect(meta.displayName).toBe('my-wt') expect(meta.comment).toBe('') @@ -82,8 +85,13 @@ describe('Store', () => { it('setWorktreeMeta merges with existing meta', async () => { const store = await createStore() - store.setWorktreeMeta('wt1', { displayName: 'first', comment: 'hello' }) - const updated = store.setWorktreeMeta('wt1', { comment: 'updated' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'wt1', hostId: 'local' }), { + displayName: 'first', + comment: 'hello' + }) + const updated = store.setWorktreeMeta(hostWorktreeTarget({ id: 'wt1', hostId: 'local' }), { + comment: 'updated' + }) expect(updated.displayName).toBe('first') expect(updated.comment).toBe('updated') @@ -113,7 +121,10 @@ describe('Store', () => { accountLabel: 'ada@example.com' } - store.setWorktreeMeta('wt-jira', { linkedWorkItem, linkedTaskSourceContext }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'wt-jira', hostId: 'local' }), { + linkedWorkItem, + linkedTaskSourceContext + }) store.flush() const restored = await createStore() @@ -122,12 +133,12 @@ describe('Store', () => { linkedTaskSourceContext }) expect( - restored.setWorktreeMeta('wt-jira', { + restored.setWorktreeMeta(hostWorktreeTarget({ id: 'wt-jira', hostId: 'local' }), { linkedTaskSourceContext: { ...linkedTaskSourceContext, provider: 'linear' } }).linkedTaskSourceContext ).toBeNull() expect( - restored.setWorktreeMeta('wt-jira', { + restored.setWorktreeMeta(hostWorktreeTarget({ id: 'wt-jira', hostId: 'local' }), { linkedTaskSourceContext: { ...linkedTaskSourceContext, providerIdentity: { diff --git a/src/main/persistence-worktree-meta-host-target-twins.test.ts b/src/main/persistence-worktree-meta-host-target-twins.test.ts new file mode 100644 index 00000000000..b8d24c56606 --- /dev/null +++ b/src/main/persistence-worktree-meta-host-target-twins.test.ts @@ -0,0 +1,185 @@ +import { + closeTestStores, + createStore as createFreshStore, + testState +} from './persistence-test-harness' +/** + * A local row and an SSH row can share a repo id, so they share every worktree id under it. + * Worktree meta and lineage writes take the row's host (R4-2 family b), and a write for one + * twin must never land on the other. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { Repo } from '../shared/repo-types' +import type { WorktreeLineage } from '../shared/worktree/lineage-types' +import { getDefaultPersistedState } from '../shared/constants' +import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity' +import { worktreeWorkspaceKey } from '../shared/workspace-scope' +import { + removeWorktreeMetaRow, + resolveWorktreeMetaOwner +} from './persistence/host-qualified-worktree-meta' +import { persistRuntimeManagedWorktreeSortOrder } from './runtime/runtime-managed-worktree-metadata' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) + +const WORKTREE_ID = 'dup::/wt' +const LOCAL = hostWorktreeTarget({ id: WORKTREE_ID, hostId: 'local' }) +const SSH = hostWorktreeTarget({ id: WORKTREE_ID, hostId: 'ssh:ssh-1' }) + +const twinRepos: Repo[] = [ + { id: 'dup', path: '/wt', displayName: 'Local', badgeColor: '#000', addedAt: 1 }, + { + id: 'dup', + path: '/wt', + displayName: 'Remote', + badgeColor: '#000', + addedAt: 2, + connectionId: 'ssh-1' + } +] + +function lineage(instanceId: string): WorktreeLineage { + return { + worktreeId: WORKTREE_ID, + worktreeInstanceId: instanceId, + parentWorktreeId: 'dup::/parent', + parentWorktreeInstanceId: 'parent', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } +} + +function createTwinStore(state: Record = {}) { + mkdirSync(testState.dir, { recursive: true }) + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ ...getDefaultPersistedState(testState.dir), repos: twinRepos, ...state }), + 'utf-8' + ) + return createFreshStore() +} + +beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-worktree-meta-twins-')) +}) + +afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) +}) + +describe('worktree meta and lineage writes for same-id twins', () => { + it('writes each twin its own meta', async () => { + const store = await createTwinStore() + store.setWorktreeMeta(LOCAL, { displayName: 'local copy' }) + store.setWorktreeMeta(SSH, { displayName: 'ssh copy' }) + + expect(store.getWorktreeMetaForHost(WORKTREE_ID, 'local')?.displayName).toBe('local copy') + expect(store.getWorktreeMetaForHost(WORKTREE_ID, 'ssh:ssh-1')?.displayName).toBe('ssh copy') + expect(store.getWorktreeMeta(WORKTREE_ID)?.displayName).toBe('local copy') + }) + + it('removes one twin without touching the other', async () => { + const store = await createTwinStore() + store.setWorktreeMeta(LOCAL, { displayName: 'local copy' }) + store.setWorktreeMeta(SSH, { displayName: 'ssh copy' }) + store.setWorktreeLineage(LOCAL, lineage('local')) + + store.removeWorktreeMeta(SSH) + + expect(store.getWorktreeMetaForHost(WORKTREE_ID, 'ssh:ssh-1')).toBeUndefined() + expect(store.getWorktreeMetaForHost(WORKTREE_ID, 'local')?.displayName).toBe('local copy') + expect(store.getWorktreeLineage(WORKTREE_ID)?.worktreeInstanceId).toBe('local') + }) + + it('keeps the owning twin lineage when the other twin writes or removes it', async () => { + const store = await createTwinStore() + store.setWorktreeMeta(LOCAL, { displayName: 'local copy' }) + store.setWorktreeLineage(LOCAL, lineage('local')) + store.setWorkspaceLineage({ + childWorkspaceKey: worktreeWorkspaceKey(WORKTREE_ID), + childInstanceId: 'local', + parentWorkspaceKey: worktreeWorkspaceKey('dup::/parent'), + parentInstanceId: 'parent', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + }) + + expect(store.setWorktreeLineage(SSH, lineage('ssh'))).toBeUndefined() + store.removeWorktreeLineage(SSH) + + expect(store.getWorktreeLineage(WORKTREE_ID)?.worktreeInstanceId).toBe('local') + expect(store.getWorkspaceLineage(worktreeWorkspaceKey(WORKTREE_ID))).toBeDefined() + + store.removeWorktreeLineage(LOCAL) + expect(store.getWorktreeLineage(WORKTREE_ID)).toBeUndefined() + expect(store.getWorkspaceLineage(worktreeWorkspaceKey(WORKTREE_ID))).toBeUndefined() + }) + + it('renames only the named twin', async () => { + const store = await createTwinStore() + store.setWorktreeMeta(LOCAL, { displayName: 'local copy' }) + store.setWorktreeMeta(SSH, { displayName: 'ssh copy' }) + + store.migrateWorktreeIdentity(SSH, 'dup::/renamed') + + expect(store.getWorktreeMetaForHost('dup::/renamed', 'ssh:ssh-1')?.displayName).toBe('ssh copy') + expect(store.getWorktreeMeta(WORKTREE_ID)?.displayName).toBe('local copy') + expect(store.getWorktreeMetaForHost('dup::/renamed', 'local')).toBeUndefined() + }) + + it('removes an unhosted row only when nothing stamped it', async () => { + const store = await createTwinStore() + store.setWorktreeMeta(LOCAL, { displayName: 'local copy' }) + + expect(store.removeUnhostedWorktreeMeta(WORKTREE_ID)).toBe(false) + expect(store.getWorktreeMeta(WORKTREE_ID)?.displayName).toBe('local copy') + }) + + it('a removal with no host takes the stamped owner, not every twin', async () => { + const store = await createTwinStore() + store.setWorktreeMeta(LOCAL, { displayName: 'local copy' }) + store.setWorktreeMeta(SSH, { displayName: 'ssh copy' }) + + removeWorktreeMetaRow(store, WORKTREE_ID, undefined) + + expect(store.getWorktreeMetaForHost(WORKTREE_ID, 'local')).toBeUndefined() + expect(store.getWorktreeMetaForHost(WORKTREE_ID, 'ssh:ssh-1')?.displayName).toBe('ssh copy') + }) + + it('neither saves nor reports a sort order for an unstamped twin row', async () => { + const store = await createTwinStore({ + worktreeMeta: { [WORKTREE_ID]: { displayName: 'legacy', sortOrder: 1 } } + }) + const notifyChanged = vi.fn() + + const result = persistRuntimeManagedWorktreeSortOrder({ + orderedIds: [WORKTREE_ID], + store, + invalidateResolved: vi.fn(), + notifyChanged + }) + + expect(result).toEqual({ updated: 0 }) + expect(notifyChanged).not.toHaveBeenCalled() + expect(store.getWorktreeMeta(WORKTREE_ID)?.sortOrder).toBe(1) + }) + + it('resolves an id-only caller to the stamp, and refuses an unstamped twin', async () => { + const store = await createTwinStore() + expect(resolveWorktreeMetaOwner(store, WORKTREE_ID)).toBeUndefined() + + store.setWorktreeMeta(SSH, { displayName: 'ssh copy' }) + expect(resolveWorktreeMetaOwner(store, WORKTREE_ID)).toEqual(SSH) + }) +}) diff --git a/src/main/persistence/host-qualified-worktree-meta.ts b/src/main/persistence/host-qualified-worktree-meta.ts index 6267311f471..5107b97796c 100644 --- a/src/main/persistence/host-qualified-worktree-meta.ts +++ b/src/main/persistence/host-qualified-worktree-meta.ts @@ -1,6 +1,11 @@ import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../shared/worktree/host-qualified-identity' /** * The listing paths take partial store shapes (`Pick`), so the @@ -14,12 +19,6 @@ export type HostQualifiedWorktreeMetaStore = { executionHostId: ExecutionHostId ) => WorktreeMeta | undefined getAllWorktreeMetaForHost?: (executionHostId: ExecutionHostId) => Record - setWorktreeMeta: (worktreeId: string, meta: Partial) => WorktreeMeta - setWorktreeMetaForHost?: ( - worktreeId: string, - executionHostId: ExecutionHostId, - meta: Partial - ) => WorktreeMeta } /** Canonical host snapshot, with a filtered legacy fallback for partial test/compatibility stores. */ @@ -73,14 +72,59 @@ export function readWorktreeMetaForRepo( return readWorktreeMetaForHost(store, worktreeId, getRepoExecutionHostId(repo)) } -export function writeWorktreeMetaForHost( - store: Pick, +/** + * The row an id-only caller (an agent hook, a sort list) means: the id's own host stamp, else its + * repo's only row. Undefined when same-id twins leave it ambiguous: callers skip, never guess. + */ +export function resolveWorktreeMetaOwner( + store: { + getWorktreeMeta: (worktreeId: string) => { hostId?: ExecutionHostId } | undefined + getRepos: () => readonly Repo[] + }, + worktreeId: string +): HostWorktreeTarget | undefined { + const stamped = store.getWorktreeMeta(worktreeId)?.hostId + if (stamped) { + return hostWorktreeTarget({ id: worktreeId, hostId: stamped }) + } + const repoId = getRepoIdFromWorktreeId(worktreeId) + const owners = store.getRepos().filter((repo) => repo.id === repoId) + return owners.length === 1 && owners[0] + ? hostWorktreeTargetForRepo(worktreeId, owners[0]) + : undefined +} + +/** A worktree row's own host when it carries one; otherwise as `resolveWorktreeMetaOwner`. */ +export function worktreeRowTarget( + store: Parameters[0], + worktree: { id: string; hostId?: ExecutionHostId } +): HostWorktreeTarget | undefined { + return worktree.hostId + ? hostWorktreeTarget({ id: worktree.id, hostId: worktree.hostId }) + : resolveWorktreeMetaOwner(store, worktree.id) +} + +/** Removes by the caller's host, else the row's own stamp; only an unstamped row goes on every host. */ +export function removeWorktreeMetaRow( + store: { + getWorktreeMeta: (worktreeId: string) => WorktreeMeta | undefined + removeWorktreeMeta: (target: HostWorktreeTarget) => void + removeUnhostedWorktreeMeta?: (worktreeId: string) => boolean + }, worktreeId: string, - executionHostId: ExecutionHostId, - meta: Partial -): WorktreeMeta { - return ( - store.setWorktreeMetaForHost?.(worktreeId, executionHostId, meta) ?? - store.setWorktreeMeta(worktreeId, { ...meta, hostId: executionHostId }) - ) + hostId: ExecutionHostId | undefined +): void { + const owner = hostId ?? store.getWorktreeMeta(worktreeId)?.hostId + if (owner) { + store.removeWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: owner })) + } else { + store.removeUnhostedWorktreeMeta?.(worktreeId) + } +} + +export function hostWorktreeTargetForRepo( + worktreeId: string, + repo: Pick +): HostWorktreeTarget { + return hostWorktreeTarget({ id: worktreeId, hostId: getRepoExecutionHostId(repo) }) } diff --git a/src/main/persistence/loading-store/metadata-lineage-operations.ts b/src/main/persistence/loading-store/metadata-lineage-operations.ts index 1b08b6c0c7a..6619305b713 100644 --- a/src/main/persistence/loading-store/metadata-lineage-operations.ts +++ b/src/main/persistence/loading-store/metadata-lineage-operations.ts @@ -3,7 +3,8 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' import type { WorktreeMeta } from '../../../shared/worktree/meta-types' import type { Repo } from '../../../shared/repo-types' -import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { HostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' @@ -31,7 +32,6 @@ import { setWorktreeMetaForHost as setWorktreeMetaForHostOperation, WORKTREE_METADATA_DOMAINS } from './worktree-identity-metadata' -import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization' import { captureNativeLocalWorktreeMetadataScanExpectation as captureNativeLocalWorktreeMetadataScanExpectationOperation, pruneSessionlessMissingLocalWorktreeMetadataForRepo as pruneSessionlessMissingLocalWorktreeMetadataForRepoOperation, @@ -75,20 +75,6 @@ export class MetadataLineageOperations { ) } - setWorktreeMetaForHost( - worktreeId: string, - executionHostId: ExecutionHostId, - meta: Partial & WorkspaceAttachmentMutation - ): WorktreeMeta { - return setWorktreeMetaForHostOperation( - this[metadataLineageOperationsContext].runtime, - this[metadataLineageOperationsContext].scheduling, - worktreeId, - executionHostId, - meta - ) - } - getAllWorktreeMeta(): Record { return this[metadataLineageOperationsContext].runtime.state.worktreeMeta } @@ -110,28 +96,32 @@ export class MetadataLineageOperations { } setWorktreeMeta( - worktreeId: string, + target: HostWorktreeTarget, meta: Partial & WorkspaceAttachmentMutation ): WorktreeMeta { - const state = this[metadataLineageOperationsContext].runtime.state - const stored = state.worktreeMeta[worktreeId] - const executionHostId = meta.hostId ?? stored?.hostId - if (executionHostId) { - return setWorktreeMetaForHostOperation( - this[metadataLineageOperationsContext].runtime, - this[metadataLineageOperationsContext].scheduling, - worktreeId, - executionHostId, - meta - ) - } - const updated = mergeWorktreeMetaForWrite(stored, meta) - state.worktreeMeta[worktreeId] = updated - scheduleSave(this[metadataLineageOperationsContext].scheduling, ['worktreeMeta']) - return updated + return setWorktreeMetaForHostOperation( + this[metadataLineageOperationsContext].runtime, + this[metadataLineageOperationsContext].scheduling, + target.id, + target.hostId, + meta + ) } - removeWorktreeMeta(worktreeId: string, hostId?: ExecutionHostId | null): void { + removeWorktreeMeta(target: HostWorktreeTarget): void { + this.removeWorktreeMetaRows(target.id, target.hostId) + } + + /** For a row no host stamped: removes every host's copy, and refuses a stamped row. */ + removeUnhostedWorktreeMeta(worktreeId: string): boolean { + if (this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId]?.hostId) { + return false + } + this.removeWorktreeMetaRows(worktreeId, undefined) + return true + } + + private removeWorktreeMetaRows(worktreeId: string, hostId: ExecutionHostId | undefined): void { // A host-qualified removal names the owner; the persisted host is the fallback. const persistedOwner = this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId]?.hostId @@ -154,7 +144,7 @@ export class MetadataLineageOperations { removeWorktreeMetadataForHost( this[metadataLineageOperationsContext].runtime.state, worktreeId, - hostId === undefined ? undefined : (hostId ?? undefined) + hostId ) // Skip partitions main never wrote: materializing one fences every sibling worktree of the repo. const partitions = new Set( @@ -242,36 +232,41 @@ export class MetadataLineageOperations { return this[metadataLineageOperationsContext].runtime.state.worktreeLineageById } - setWorktreeLineage(worktreeId: string, lineage: WorktreeLineage): WorktreeLineage { - this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] = lineage + /** Undefined when the id's lineage bucket belongs to a same-id twin on another host. */ + setWorktreeLineage( + target: HostWorktreeTarget, + lineage: WorktreeLineage + ): WorktreeLineage | undefined { + const state = this[metadataLineageOperationsContext].runtime.state + if (!ownsRawIdBucket(state, target)) { + return undefined + } + state.worktreeLineageById[target.id] = lineage scheduleSave(this[metadataLineageOperationsContext].scheduling) return lineage } - removeWorktreeLineage(worktreeId: string): void { - delete this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] + /** Drops both lineage records of a worktree, unless a same-id twin on another host owns them. */ + removeWorktreeLineage(target: HostWorktreeTarget): void { + const state = this[metadataLineageOperationsContext].runtime.state + if (!ownsRawIdBucket(state, target)) { + return + } + delete state.worktreeLineageById[target.id] + delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(target.id)] scheduleSave(this[metadataLineageOperationsContext].scheduling) } - migrateWorktreeIdentity( - oldWorktreeId: string, - newWorktreeId: string, - executionHostId?: ExecutionHostId - ): void { + migrateWorktreeIdentity(target: HostWorktreeTarget, newWorktreeId: string): void { const state = this[metadataLineageOperationsContext].runtime.state - const persistedOwner = state.worktreeMeta[oldWorktreeId]?.hostId - const mover = executionHostId ?? persistedOwner ?? LOCAL_EXECUTION_HOST_ID const legacyChanged = - executionHostId !== undefined && - persistedOwner !== undefined && - persistedOwner !== executionHostId - ? false - : migrateWorktreeIdentityOperation(state, oldWorktreeId, newWorktreeId) + ownsRawIdBucket(state, target) && + migrateWorktreeIdentityOperation(state, target.id, newWorktreeId) const canonicalChanged = migrateWorktreeMetadataLocator( state, - oldWorktreeId, + target.id, newWorktreeId, - mover + target.hostId ) if (legacyChanged || canonicalChanged) { // Legacy identity moves also re-key sessions, lineage, mobile selections, and UI state. @@ -299,13 +294,15 @@ export class MetadataLineageOperations { scheduleSave(this[metadataLineageOperationsContext].scheduling) return lineage } +} - removeWorkspaceLineage(childWorkspaceKey: WorkspaceKey): void { - delete this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ - childWorkspaceKey - ] - scheduleSave(this[metadataLineageOperationsContext].scheduling) - } +// Raw-id buckets (legacy meta, lineage) belong to the first host that stamped the id (D7). +function ownsRawIdBucket( + state: MetadataLineageOperationsRuntime['state'], + target: HostWorktreeTarget +): boolean { + const owner = state.worktreeMeta[target.id]?.hostId + return !owner || owner === target.hostId } export function removeWorkspaceLineageForFolderParent( diff --git a/src/main/persistence/loading-store/profile-state-direct-flush.test.ts b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts index a000e04dfac..bb12ec806c8 100644 --- a/src/main/persistence/loading-store/profile-state-direct-flush.test.ts +++ b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts @@ -7,6 +7,7 @@ import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqli import { readProfileStateDomain } from '../profile-state/profile-state-domain-reader' import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' import { Store } from './store' +import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' vi.mock('electron', () => ({ app: { @@ -112,7 +113,7 @@ describe('SQLite durability barriers with another selective write pending', () = seed.workspaceSession.activeWorktreeId = oldId seed.ui.showDotfilesByWorktree = { [oldId]: true } const state = fixture(seed) - state.store.migrateWorktreeIdentity(oldId, newId) + state.store.migrateWorktreeIdentity(hostWorktreeTarget({ id: oldId, hostId: 'local' }), newId) state.store.flushOrThrow() expect(state.read('workspaceSession')).toMatchObject({ kind: 'value', diff --git a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts index 829337efe4c..e672113dc70 100644 --- a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts +++ b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts @@ -25,6 +25,7 @@ import { } from '../profile-state/profile-state-database' import { profileStateJsonExportPath } from '../profile-state/legacy-json/profile-state-export-path' import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' vi.mock('electron', () => ({ app: { @@ -656,9 +657,12 @@ describe('Store with an injected SQLite profile-state authority', () => { const writeDomains = vi.spyOn(authority, 'writeSerializedDomains') const store = new Store({ dataFile, profileStateAuthority: authority }) store.flushOrThrow() - store.setWorktreeMetaForHost('repo-local::/fixture/local', 'local', { - displayName: 'Updated fixture local' - }) + store.setWorktreeMeta( + hostWorktreeTarget({ id: 'repo-local::/fixture/local', hostId: 'local' }), + { + displayName: 'Updated fixture local' + } + ) store.flushOrThrow() expect(writeDomains).toHaveBeenCalledTimes(1) diff --git a/src/main/persistence/loading-store/state-write-listeners.spec.ts b/src/main/persistence/loading-store/state-write-listeners.spec.ts index c6ca0a628b7..ea6476be6f0 100644 --- a/src/main/persistence/loading-store/state-write-listeners.spec.ts +++ b/src/main/persistence/loading-store/state-write-listeners.spec.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest' import { fixture } from './profile-state-delayed-authority-fixture' import { WORKTREE_METADATA_DOMAINS } from './worktree-identity-metadata' +import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' export function registerStateWriteListenersTests(): void { it('tells each write the domains it changed, undefined for a full save', async () => { @@ -8,10 +9,14 @@ export function registerStateWriteListenersTests(): void { const seen: (readonly string[] | undefined)[] = [] const detach = store.onStateWritten((domains) => seen.push(domains)) - store.setWorktreeMeta('repo-1::/r/a', { isPinned: true }) - store.setWorktreeMetaForHost('repo-1::/r/b', 'local', { isUnread: true }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'repo-1::/r/a', hostId: 'local' }), { + isPinned: true + }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'repo-1::/r/b', hostId: 'local' }), { + isUnread: true + }) store.patchWorkspaceSession({ activeTabId: 'tab-1' }) - store.setWorktreeLineage('repo-1::/r/b', { + store.setWorktreeLineage(hostWorktreeTarget({ id: 'repo-1::/r/b', hostId: 'local' }), { worktreeId: 'repo-1::/r/b', worktreeInstanceId: 'b-1', parentWorktreeId: 'repo-1::/r/a', @@ -21,10 +26,13 @@ export function registerStateWriteListenersTests(): void { createdAt: 1 }) detach() - store.setWorktreeMeta('repo-1::/r/a', { isPinned: false }) + store.setWorktreeMeta(hostWorktreeTarget({ id: 'repo-1::/r/a', hostId: 'local' }), { + isPinned: false + }) + // Every metadata write also writes the host-qualified identity row. expect(seen).toEqual([ - ['worktreeMeta'], + WORKTREE_METADATA_DOMAINS, WORKTREE_METADATA_DOMAINS, ['workspaceSession'], undefined diff --git a/src/main/persistence/loading-store/store-prune-gate-signals.test.ts b/src/main/persistence/loading-store/store-prune-gate-signals.test.ts index fe8b31e82bb..0eb4b8e4c46 100644 --- a/src/main/persistence/loading-store/store-prune-gate-signals.test.ts +++ b/src/main/persistence/loading-store/store-prune-gate-signals.test.ts @@ -10,6 +10,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { removeWorktreeMetaRow } from '../host-qualified-worktree-meta' vi.mock('electron', () => ({ app: { @@ -88,10 +89,13 @@ describe('store signals to the worktree metadata prune gate', () => { it('re-arms the gate when a metadata row is removed', () => { const store = createStore() - store.setWorktreeMeta(WORKTREE_ID, { displayName: 'a', hostId: 'local' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: WORKTREE_ID, hostId: 'local' }), { + displayName: 'a', + hostId: 'local' + }) parkGate() - store.removeWorktreeMeta(WORKTREE_ID) + removeWorktreeMetaRow(store, WORKTREE_ID, undefined) expect(isLocalWorktreeMetadataPruneDue(REPO_ID)).toBe(true) }) @@ -100,8 +104,14 @@ describe('store signals to the worktree metadata prune gate', () => { const store = createStore() parkGate() - store.setWorktreeMeta(WORKTREE_ID, { displayName: 'a', hostId: 'local' }) - store.setWorktreeMeta(WORKTREE_ID, { displayName: 'b', hostId: 'local' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: WORKTREE_ID, hostId: 'local' }), { + displayName: 'a', + hostId: 'local' + }) + store.setWorktreeMeta(hostWorktreeTarget({ id: WORKTREE_ID, hostId: 'local' }), { + displayName: 'b', + hostId: 'local' + }) // Why this matters: the worktree listing itself stamps metadata on every scan, so a gate that // re-armed on ordinary writes would restore the storm it exists to stop (#17775). diff --git a/src/main/persistence/profile-state-cutover-soak.test.ts b/src/main/persistence/profile-state-cutover-soak.test.ts index 2a46f6aedbf..995f351e5e2 100644 --- a/src/main/persistence/profile-state-cutover-soak.test.ts +++ b/src/main/persistence/profile-state-cutover-soak.test.ts @@ -17,6 +17,7 @@ import { type ProfileStateStoreFactoryResult } from './profile-state/profile-state-store-factory' import { profileStateDatabaseFile } from './profile-state/profile-state-database' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('electron', () => ({ app: { @@ -185,10 +186,13 @@ describe('profile-state candidate cutover soak', () => { } ] }) - reopened.store.setWorktreeMeta('repo-local::/fixture/local', { - comment: `soak-${round}`, - linkedPR: 100 + round - }) + reopened.store.setWorktreeMeta( + hostWorktreeTarget({ id: 'repo-local::/fixture/local', hostId: 'local' }), + { + comment: `soak-${round}`, + linkedPR: 100 + round + } + ) reopened.store.createAutomationRun(currentAutomation, 100 + round, 'manual') reopened.store.flushOrThrow() reopened.store.freezeWrites() diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts index 813dbfae064..b178f74b4e7 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts @@ -9,6 +9,7 @@ import { } from './acknowledged-terminal-tab-retirement-fixture' import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' import { delegatedMobileSessionTabClose } from './mobile-session-tab-close-outcome' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' const fixtures: ReturnType[] = [] afterEach(async () => { @@ -140,7 +141,9 @@ it('rechecks current pins after renderer acknowledgement', async () => { it('keeps persistence writable when worktree teardown finds remaining terminal rows', async () => { const f = fixture() f.store.updateRepo({ id: 'repo1' }, { executionHostId: 'ssh:target' }) - f.store.setWorktreeMeta(ACK_WORKTREE, { hostId: 'ssh:target' }) + f.store.setWorktreeMeta(hostWorktreeTarget({ id: ACK_WORKTREE, hostId: 'ssh:target' }), { + hostId: 'ssh:target' + }) f.store.setWorkspaceSession(f.store.getWorkspaceSession(), 'ssh:target') vi.spyOn(f.runtime, 'closeMobileSessionTab').mockResolvedValue(delegatedMobileSessionTabClose()) await expect(f.runtime.closeTerminalsForWorktree(`id:${ACK_WORKTREE}`)).rejects.toThrow( diff --git a/src/main/runtime/construction/runtime-file-command-state.ts b/src/main/runtime/construction/runtime-file-command-state.ts index 2a189ebdda2..9dd7b754d7b 100644 --- a/src/main/runtime/construction/runtime-file-command-state.ts +++ b/src/main/runtime/construction/runtime-file-command-state.ts @@ -31,6 +31,7 @@ import type { RuntimeStore } from '../runtime-store-contract' import type { ResolvedWorktree } from '../runtime-worktree-path-identity' import type { Repo } from '../../../shared/repo-types' import { isWslMoveWorktreeBlocked } from '../../persistence/moving-wsl-projects/wsl-move-background-backoff' +import { worktreeRowTarget } from '../../persistence/host-qualified-worktree-meta' export abstract class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreservedBranchCleanup { abstract resolveRuntimeFileTarget(worktreeSelector: string): Promise<{ @@ -202,16 +203,14 @@ export abstract class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreserv // Why (#17828 review follow-up): RuntimeGitSyncCommands materializes with no store to // avoid unrelated side effects; this is its only way back into the persisted // `pushTarget.remoteCreated` flag that #17842's orphan sweep relies on. - persistMaterializedPushTarget: (worktreeId, pushTarget) => { + persistMaterializedPushTarget: (worktree, pushTarget) => { const store = this.store - if (!store?.setWorktreeMeta) { - return - } + const target = store ? worktreeRowTarget(store, worktree) : undefined // Bookkeeping incidental to the push: a project a WSL move is taking skips it, not the push. - if (isWslMoveWorktreeBlocked(worktreeId, store.getWorktreeMeta?.(worktreeId)?.hostId)) { + if (!store || !target || isWslMoveWorktreeBlocked(target.id, target.hostId)) { return } - store.setWorktreeMeta(worktreeId, { pushTarget }) + store.setWorktreeMeta(target, { pushTarget }) } }) diff --git a/src/main/runtime/folder-upgrade-runtime-listing.test.ts b/src/main/runtime/folder-upgrade-runtime-listing.test.ts index d307c891952..68e26e6ff42 100644 --- a/src/main/runtime/folder-upgrade-runtime-listing.test.ts +++ b/src/main/runtime/folder-upgrade-runtime-listing.test.ts @@ -26,6 +26,7 @@ vi.mock('./repo-worktree-admin-fingerprint', () => ({ })) import { OrcaRuntimeService } from './orca-runtime' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' const REPO_ID = 'repo-upgraded' const FOLDER_PATH = 'D:\\project\\a' @@ -52,11 +53,11 @@ function makeRuntime(): OrcaRuntimeService { getRepos: () => repos, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (id: string) => metaById[id], - setWorktreeMeta: (id: string, meta: Record) => { + setWorktreeMeta: ({ id }: HostWorktreeTarget, meta: Record) => { metaById[id] = { ...metaById[id], ...meta } return metaById[id] }, - removeWorktreeMeta: (id: string) => { + removeWorktreeMeta: ({ id }: HostWorktreeTarget) => { delete metaById[id] }, getAllWorktreeLineage: () => ({}), diff --git a/src/main/runtime/folder-workspace-pty-identity.test.ts b/src/main/runtime/folder-workspace-pty-identity.test.ts index 684546b3ad6..571e42538a2 100644 --- a/src/main/runtime/folder-workspace-pty-identity.test.ts +++ b/src/main/runtime/folder-workspace-pty-identity.test.ts @@ -77,7 +77,7 @@ function createRuntimeInternals( getRepo: (id: string) => (id === REPO_ID ? REPO : undefined), getAllWorktreeMeta: () => meta, getWorktreeMeta: (worktreeId: string) => meta[worktreeId], - setWorktreeMeta: (worktreeId: string, patch: Record) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, patch: Record) => { meta[worktreeId] = { ...meta[worktreeId], ...patch } return meta[worktreeId] }, diff --git a/src/main/runtime/integrations/linear/linear-commands.ts b/src/main/runtime/integrations/linear/linear-commands.ts index d3df12b83b6..2d8b6d2b021 100644 --- a/src/main/runtime/integrations/linear/linear-commands.ts +++ b/src/main/runtime/integrations/linear/linear-commands.ts @@ -2,6 +2,7 @@ import type { RuntimeTerminalShow } from '../../../../shared/runtime-terminal-co import type { ResolvedWorktree } from '../../runtime-worktree-path-identity' import { OrcaRuntimeWithFileCommands } from '../../construction/runtime-file-command-state' import { RuntimeLinearCommands } from '../../runtime-linear-connection-commands' +import { worktreeRowTarget } from '../../../persistence/host-qualified-worktree-meta' export abstract class OrcaRuntimeWithLinearCommands extends OrcaRuntimeWithFileCommands { abstract showTerminal(handle: string): Promise @@ -15,7 +16,12 @@ export abstract class OrcaRuntimeWithLinearCommands extends OrcaRuntimeWithFileC showTerminal: (handle) => this.showTerminal(handle), resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), listResolvedWorktrees: () => this.listResolvedWorktrees(), - setWorktreeMeta: (worktreeId, meta) => this.store!.setWorktreeMeta(worktreeId, meta), + setWorktreeMeta: (worktree, meta) => { + const target = worktreeRowTarget(this.store!, worktree) + if (target) { + this.store!.setWorktreeMeta(target, meta) + } + }, emitClientEvent: (event) => this.emitClientEvent(event) }) } diff --git a/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts b/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts index 9b9bcfac784..cbcd92cd919 100644 --- a/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts +++ b/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts @@ -7,6 +7,7 @@ import type * as GitStatusModule from '../git/status' import type * as CommitMessageTextGenerationModule from '../text-generation/commit-message-text-generation' import type * as WorktreeModule from '../git/worktree' import { OrcaRuntimeService } from './orca-runtime' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' const mocks = vi.hoisted(() => ({ listWorktrees: vi.fn(), @@ -79,7 +80,7 @@ function makeStore(worktreePath: string) { : undefined, getAllWorktreeMeta: () => worktreeMeta, getWorktreeMeta: (id: string) => worktreeMeta[id], - setWorktreeMeta: (id: string, updates: Partial) => { + setWorktreeMeta: ({ id }: HostWorktreeTarget, updates: Partial) => { worktreeMeta[id] = { ...worktreeMeta[id], ...updates } return worktreeMeta[id] }, diff --git a/src/main/runtime/orca-runtime-startup-draft-launch-parity.test.ts b/src/main/runtime/orca-runtime-startup-draft-launch-parity.test.ts index 28d3016c14e..ad8bcbedc49 100644 --- a/src/main/runtime/orca-runtime-startup-draft-launch-parity.test.ts +++ b/src/main/runtime/orca-runtime-startup-draft-launch-parity.test.ts @@ -11,6 +11,7 @@ import type { WorktreeMeta } from './orca-runtime-test-mocks.spec' import { makeWorktreeMeta, store } from './orca-runtime-test-fixtures.spec' import { createWorktreeWithStartupAgent } from '../agent-launch/startup-agent-worktree-create' import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' type DraftSettings = { defaultTuiAgent: 'claude' | null @@ -37,9 +38,9 @@ async function runCreate( }), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { - metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } - return metaById[worktreeId] + setWorktreeMeta: ({ id }: HostWorktreeTarget, meta: Partial) => { + metaById[id] = { ...(metaById[id] ?? makeWorktreeMeta()), ...meta } + return metaById[id] } }) const spawn = vi.fn().mockResolvedValue({ id: `pty-${route}` }) diff --git a/src/main/runtime/orca-runtime-test-fixtures.spec.ts b/src/main/runtime/orca-runtime-test-fixtures.spec.ts index 22944b86cfe..0f8c72449a3 100644 --- a/src/main/runtime/orca-runtime-test-fixtures.spec.ts +++ b/src/main/runtime/orca-runtime-test-fixtures.spec.ts @@ -28,6 +28,7 @@ import { InMemoryOrchestrationMessages } from './orca-runtime-test-orchestration import type { OrchestrationDb } from './orchestration/db' import type { PtyProcessInspection } from '../providers/pty-process-inspection' import type { Store } from '../persistence' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' type RuntimeService = InstanceType type HeadlessTerminal = InstanceType @@ -375,14 +376,14 @@ function createStaleRuntimeWorktreeStore( const metaById: Record = { [worktreeId]: makeWorktreeMeta(metaOverrides) } - const removeWorktreeMeta = vi.fn((id: string) => { + const removeWorktreeMeta = vi.fn(({ id }: HostWorktreeTarget) => { delete metaById[id] }) const runtimeStore = { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (id: string) => metaById[id], - setWorktreeMeta: (id: string, meta: Partial) => { + setWorktreeMeta: ({ id }: HostWorktreeTarget, meta: Partial) => { metaById[id] = { ...(metaById[id] ?? makeWorktreeMeta()), ...meta } return metaById[id] }, @@ -428,7 +429,7 @@ const store = { } }), getWorktreeMeta: (worktreeId: string) => store.getAllWorktreeMeta()[worktreeId], - setWorktreeMeta: (_worktreeId: string, meta: Record) => + setWorktreeMeta: (_target: HostWorktreeTarget, meta: Record) => ({ ...store.getAllWorktreeMeta()[TEST_WORKTREE_ID], ...meta diff --git a/src/main/runtime/orca-runtime-tests/browser-capabilities.spec.ts b/src/main/runtime/orca-runtime-tests/browser-capabilities.spec.ts index 3ff28d0e44b..c65c22f7a0a 100644 --- a/src/main/runtime/orca-runtime-tests/browser-capabilities.spec.ts +++ b/src/main/runtime/orca-runtime-tests/browser-capabilities.spec.ts @@ -31,6 +31,7 @@ import { resetBrowserIdentityModeStoreForTests } from '../../browser/browser-identity-mode-store' import { runtimeAdvertisesBrowserClientHosting } from '../../../shared/browser-client-hosting-eligibility' +import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' describe('OrcaRuntimeService', () => { it('advertises client hosting when its factory implements client page creation', () => { @@ -688,7 +689,9 @@ describe('OrcaRuntimeService', () => { 'runtime:env-b' ) - expect(removeWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'runtime:env-b') + expect(removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: TEST_WORKTREE_ID, hostId: 'runtime:env-b' }) + ) expect(internals.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)).toBe(localSession) }) @@ -721,7 +724,9 @@ describe('OrcaRuntimeService', () => { 'ssh:ssh-1' ) - expect(removeWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'ssh:ssh-1') + expect(removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: TEST_WORKTREE_ID, hostId: 'ssh:ssh-1' }) + ) expect(internals.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)).toBe(survivingSession) expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-02.spec.ts index 080a7f0617b..81abead9edd 100644 --- a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-02.spec.ts @@ -8,6 +8,10 @@ import { makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../../shared/worktree/host-qualified-identity' const anyCatalogVersion = { epoch: expect.any(String), sequence: expect.any(Number) } @@ -16,10 +20,12 @@ describe('OrcaRuntimeService', () => { const metaById: Record = { [TEST_WORKTREE_ID]: makeWorktreeMeta({ instanceId: 'child-instance' }) } - const setWorktreeMeta = vi.fn((worktreeId: string, meta: Partial) => { - metaById[worktreeId] = { ...metaById[worktreeId], ...meta } - return metaById[worktreeId] - }) + const setWorktreeMeta = vi.fn( + ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + } + ) const runtimeStore = { ...store, getAllWorktreeMeta: () => metaById, @@ -35,7 +41,10 @@ describe('OrcaRuntimeService', () => { orcaCreationWorkspaceLayout: { path: '/tmp', nestWorkspaces: false } }) - expect(setWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, { comment: 'keep me' }) + expect(setWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: TEST_WORKTREE_ID, hostId: 'local' }), + { comment: 'keep me' } + ) }) it('ignores stale instance-mismatched lineage when validating manual cycle repairs', async () => { @@ -47,12 +56,12 @@ describe('OrcaRuntimeService', () => { [parentId]: makeWorktreeMeta({ instanceId: 'new-parent-instance' }), [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) } - const setWorktreeLineage = vi.fn((_worktreeId, lineage) => lineage) + const setWorktreeLineage = vi.fn((_target: HostWorktreeTarget, lineage) => lineage) const runtimeStore = { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...metaById[worktreeId], ...meta } return metaById[worktreeId] }, @@ -93,7 +102,7 @@ describe('OrcaRuntimeService', () => { }) expect(setWorktreeLineage).toHaveBeenCalledWith( - childId, + hostWorktreeTarget({ id: childId, hostId: 'local' }), expect.objectContaining({ worktreeId: childId, worktreeInstanceId: 'child-instance', @@ -116,12 +125,12 @@ describe('OrcaRuntimeService', () => { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...metaById[worktreeId], ...meta } return metaById[worktreeId] }, getWorktreeLineage: () => undefined, - setWorktreeLineage: vi.fn((_worktreeId: string, lineage) => lineage) + setWorktreeLineage: vi.fn((_target: HostWorktreeTarget, lineage) => lineage) } vi.mocked(listWorktrees).mockResolvedValue([ { @@ -170,18 +179,18 @@ describe('OrcaRuntimeService', () => { createdAt: 1 } } - const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const setWorktreeLineage = vi.fn((_target: HostWorktreeTarget, lineage) => lineage) const runtimeStore = { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...metaById[worktreeId], ...meta } return metaById[worktreeId] }, getWorktreeLineage: (worktreeId: string) => lineageById[worktreeId], getAllWorktreeLineage: () => lineageById, - removeWorktreeLineage: vi.fn((worktreeId: string) => { + removeWorktreeLineage: vi.fn(({ id: worktreeId }: HostWorktreeTarget) => { delete lineageById[worktreeId] }), setWorktreeLineage @@ -227,7 +236,7 @@ describe('OrcaRuntimeService', () => { }) expect(setWorktreeLineage).toHaveBeenCalledWith( - childId, + hostWorktreeTarget({ id: childId, hostId: 'local' }), expect.objectContaining({ worktreeInstanceId: 'child-instance', parentWorktreeInstanceId: rotatedParentInstance @@ -255,17 +264,19 @@ describe('OrcaRuntimeService', () => { createdAt: 1 } } - const removeWorktreeLineage = vi.fn((worktreeId: string) => { + const removeWorktreeLineage = vi.fn(({ id: worktreeId }: HostWorktreeTarget) => { delete lineageById[worktreeId] }) const runtimeStore = { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: vi.fn((worktreeId: string, meta: Partial) => { - metaById[worktreeId] = { ...metaById[worktreeId], ...meta } - return metaById[worktreeId] - }), + setWorktreeMeta: vi.fn( + ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + } + ), getAllWorktreeLineage: () => lineageById, removeWorktreeLineage } @@ -549,13 +560,14 @@ describe('OrcaRuntimeService', () => { createdAt: 1 } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial store mock; the code under test reads only the members it defines. const runtime = new OrcaRuntimeService({ ...store, getRepos: () => [folderRepo], getRepo: (id: string) => (id === folderRepo.id ? folderRepo : undefined), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] }, diff --git a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-03.spec.ts index 782bbffaa3c..f14ce7cc9fe 100644 --- a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-03.spec.ts @@ -13,6 +13,7 @@ import { store, withPlatform } from '../orca-runtime-test-fixtures.spec' +import type { HostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' describe('OrcaRuntimeService', () => { it('hides agent scratch created inside a linked checkout from runtime listings', async () => { @@ -235,7 +236,7 @@ describe('OrcaRuntimeService', () => { [firstId]: { sortOrder: 200 }, [secondId]: { sortOrder: 100 } } - const setWorktreeMeta = vi.fn((id: string, updates: Partial) => { + const setWorktreeMeta = vi.fn(({ id }: HostWorktreeTarget, updates: Partial) => { metaById[id] = { ...metaById[id], ...updates } }) const runtime = new OrcaRuntimeService({ @@ -453,7 +454,7 @@ describe('OrcaRuntimeService', () => { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...metaById[worktreeId], ...meta } return metaById[worktreeId] }, @@ -499,7 +500,7 @@ describe('OrcaRuntimeService', () => { createdAt: 1 } } - const removeWorktreeLineage = vi.fn((worktreeId: string) => { + const removeWorktreeLineage = vi.fn(({ id: worktreeId }: HostWorktreeTarget) => { delete lineageById[worktreeId] }) const runtimeStore = { @@ -508,10 +509,12 @@ describe('OrcaRuntimeService', () => { getRepos: () => [remoteRepo], getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: vi.fn((worktreeId: string, meta: Partial) => { - metaById[worktreeId] = { ...metaById[worktreeId], ...meta } - return metaById[worktreeId] - }), + setWorktreeMeta: vi.fn( + ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + } + ), getAllWorktreeLineage: () => lineageById, removeWorktreeLineage } diff --git a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-04.spec.ts index 965cd357e97..6e8d9bb57da 100644 --- a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-04.spec.ts @@ -14,6 +14,10 @@ import { makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../../shared/worktree/host-qualified-identity' describe('OrcaRuntimeService', () => { it('exposes valid parent and child lineage in CLI worktree records', async () => { @@ -46,7 +50,7 @@ describe('OrcaRuntimeService', () => { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...metaById[worktreeId], ...meta } return metaById[worktreeId] }, @@ -103,7 +107,7 @@ describe('OrcaRuntimeService', () => { displayName: 'coordinator' }) } - const setWorktreeLineage = vi.fn((worktreeId: string, lineage) => { + const setWorktreeLineage = vi.fn(({ id: worktreeId }: HostWorktreeTarget, lineage) => { metaById[worktreeId] = metaById[worktreeId] ?? makeWorktreeMeta() return lineage }) @@ -111,7 +115,7 @@ describe('OrcaRuntimeService', () => { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { const existing = metaById[worktreeId] ?? makeWorktreeMeta({ instanceId: 'child-instance' }) metaById[worktreeId] = { ...existing, ...meta } return metaById[worktreeId] @@ -167,7 +171,10 @@ describe('OrcaRuntimeService', () => { }) expect(result.lineage).not.toHaveProperty('createdByTerminalHandle') expect(result.warnings).toEqual([]) - expect(setWorktreeLineage).toHaveBeenCalledWith(childId, expect.any(Object)) + expect(setWorktreeLineage).toHaveBeenCalledWith( + hostWorktreeTarget({ id: childId, hostId: 'local' }), + expect.any(Object) + ) }) it('enriches caller-terminal lineage with active orchestration dispatch context', async () => { @@ -185,12 +192,12 @@ describe('OrcaRuntimeService', () => { displayName: 'worker' }) } - const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const setWorktreeLineage = vi.fn((_target: HostWorktreeTarget, lineage) => lineage) const runtimeStore = { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { const existing = metaById[worktreeId] ?? makeWorktreeMeta() metaById[worktreeId] = { ...existing, ...meta } return metaById[worktreeId] @@ -287,7 +294,7 @@ describe('OrcaRuntimeService', () => { createdByTerminalHandle: workerHandle }) expect(setWorktreeLineage).toHaveBeenCalledWith( - childId, + hostWorktreeTarget({ id: childId, hostId: 'local' }), expect.objectContaining({ worktreeInstanceId: expect.not.stringMatching(/^old-/), parentWorktreeInstanceId: 'worker-instance' diff --git a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-06.spec.ts index 8d2cbade59f..1470ff8cf21 100644 --- a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-06.spec.ts +++ b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-06.spec.ts @@ -15,6 +15,10 @@ import { makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../../shared/worktree/host-qualified-identity' describe('OrcaRuntimeService', () => { it.each(['failed', 'circuit_broken'] as const)( @@ -125,12 +129,12 @@ describe('OrcaRuntimeService', () => { const metaById: Record = { [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) } - const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const setWorktreeLineage = vi.fn((_target: HostWorktreeTarget, lineage) => lineage) const runtimeStore = { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { const existing = metaById[worktreeId] ?? makeWorktreeMeta() metaById[worktreeId] = { ...existing, ...meta } return metaById[worktreeId] @@ -181,7 +185,10 @@ describe('OrcaRuntimeService', () => { childWorktreeIds: [], lineage: result.lineage }) - expect(setWorktreeLineage).toHaveBeenCalledWith(childId, expect.any(Object)) + expect(setWorktreeLineage).toHaveBeenCalledWith( + hostWorktreeTarget({ id: childId, hostId: 'local' }), + expect.any(Object) + ) }) it('keeps cwd-inferred lineage best-effort when the cwd parent cannot be resolved', async () => { @@ -235,12 +242,12 @@ describe('OrcaRuntimeService', () => { displayName: 'worker' }) } - const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const setWorktreeLineage = vi.fn((_target: HostWorktreeTarget, lineage) => lineage) const runtimeStore = { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { const existing = metaById[worktreeId] ?? makeWorktreeMeta() metaById[worktreeId] = { ...existing, ...meta } return metaById[worktreeId] @@ -315,7 +322,7 @@ describe('OrcaRuntimeService', () => { taskId: 'task_abc123' }) expect(setWorktreeLineage).toHaveBeenCalledWith( - childId, + hostWorktreeTarget({ id: childId, hostId: 'local' }), expect.objectContaining({ parentWorktreeInstanceId: 'worker-instance' }) @@ -333,12 +340,12 @@ describe('OrcaRuntimeService', () => { displayName: 'parent' }) } - const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const setWorktreeLineage = vi.fn((_target: HostWorktreeTarget, lineage) => lineage) const runtimeStore = { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { const existing = metaById[worktreeId] ?? makeWorktreeMeta() metaById[worktreeId] = { ...existing, ...meta } return metaById[worktreeId] @@ -413,7 +420,7 @@ describe('OrcaRuntimeService', () => { taskId: 'task_creator123' }) expect(setWorktreeLineage).toHaveBeenCalledWith( - childId, + hostWorktreeTarget({ id: childId, hostId: 'local' }), expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' }) diff --git a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache.spec.ts b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache.spec.ts index b167144e570..9e23051ee10 100644 --- a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache.spec.ts +++ b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache.spec.ts @@ -21,6 +21,10 @@ import { store } from '../orca-runtime-test-fixtures.spec' import { REPO_SEARCH_REFS_MAX_LIMIT } from '../../../shared/repo-search-limits' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../../shared/worktree/host-qualified-identity' describe('OrcaRuntimeService', () => { it('rejects invalid positive limits for bounded list commands', async () => { @@ -75,14 +79,16 @@ describe('OrcaRuntimeService', () => { [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }), [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) } - const setWorktreeLineage = vi.fn((_worktreeId: string, lineage: WorktreeLineage) => lineage) + const setWorktreeLineage = vi.fn( + (_target: HostWorktreeTarget, lineage: WorktreeLineage) => lineage + ) const runtimeStore = { ...store, getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), getRepos: () => [remoteRepo], getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] }, @@ -96,7 +102,7 @@ describe('OrcaRuntimeService', () => { }) expect(setWorktreeLineage).toHaveBeenCalledWith( - childId, + hostWorktreeTarget({ id: childId, hostId: 'ssh:ssh-1' }), expect.objectContaining({ worktreeId: childId, worktreeInstanceId: 'child-instance', @@ -127,12 +133,12 @@ describe('OrcaRuntimeService', () => { getRepos: () => [remoteRepo], getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] }, getWorktreeLineage: () => undefined, - setWorktreeLineage: vi.fn((_worktreeId: string, lineage: WorktreeLineage) => lineage) + setWorktreeLineage: vi.fn((_target: HostWorktreeTarget, lineage: WorktreeLineage) => lineage) } const runtime = new OrcaRuntimeService(runtimeStore as never) @@ -152,7 +158,9 @@ describe('OrcaRuntimeService', () => { const childId = `${repoId}::${childPath}` const parentId = `${repoId}::${parentPath}` const metaById: Record = {} - const setWorktreeLineage = vi.fn((_worktreeId: string, lineage: WorktreeLineage) => lineage) + const setWorktreeLineage = vi.fn( + (_target: HostWorktreeTarget, lineage: WorktreeLineage) => lineage + ) const runtimeStore = { ...store, getRepo: (id: string) => @@ -176,7 +184,7 @@ describe('OrcaRuntimeService', () => { ], getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] }, @@ -227,13 +235,15 @@ describe('OrcaRuntimeService', () => { [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) } - const setWorktreeLineage = vi.fn((_worktreeId: string, lineage: WorktreeLineage) => lineage) + const setWorktreeLineage = vi.fn( + (_target: HostWorktreeTarget, lineage: WorktreeLineage) => lineage + ) const setWorkspaceLineage = vi.fn((lineage: WorkspaceLineage) => lineage) const runtimeStore = { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...metaById[worktreeId], ...meta } return metaById[worktreeId] }, @@ -264,7 +274,7 @@ describe('OrcaRuntimeService', () => { }) expect(setWorktreeLineage).toHaveBeenCalledWith( - childId, + hostWorktreeTarget({ id: childId, hostId: 'local' }), expect.objectContaining({ parentWorktreeId: parentId, parentWorktreeInstanceId: 'parent-instance', @@ -325,7 +335,9 @@ describe('OrcaRuntimeService', () => { [childId]: makeWorktreeMeta({ instanceId: 'child-instance', ...scenario.childMeta }), [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance', ...scenario.parentMeta }) } - const setWorktreeLineage = vi.fn() + const setWorktreeLineage = vi.fn( + (_target: HostWorktreeTarget, lineage: WorktreeLineage) => lineage + ) const setWorkspaceLineage = vi.fn() const runtimeStore = { ...store, @@ -333,7 +345,7 @@ describe('OrcaRuntimeService', () => { getRepo: (id: string) => repos.find((repo) => repo.id === id), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...metaById[worktreeId], ...meta } return metaById[worktreeId] }, @@ -353,7 +365,7 @@ describe('OrcaRuntimeService', () => { if (scenario.accepted) { await update expect(setWorktreeLineage).toHaveBeenCalledWith( - childId, + hostWorktreeTarget({ id: childId, hostId: 'local' }), expect.objectContaining({ parentWorktreeId: parentId }) ) expect(setWorkspaceLineage).toHaveBeenCalled() @@ -371,17 +383,15 @@ describe('OrcaRuntimeService', () => { [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) } const removeWorktreeLineage = vi.fn() - const removeWorkspaceLineage = vi.fn() const runtimeStore = { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...metaById[worktreeId], ...meta } return metaById[worktreeId] }, - removeWorktreeLineage, - removeWorkspaceLineage + removeWorktreeLineage } vi.mocked(listWorktrees).mockResolvedValue([ { @@ -398,7 +408,8 @@ describe('OrcaRuntimeService', () => { lineage: { noParent: true } }) - expect(removeWorktreeLineage).toHaveBeenCalledWith(childId) - expect(removeWorkspaceLineage).toHaveBeenCalledWith(`worktree:${childId}`) + expect(removeWorktreeLineage).toHaveBeenCalledWith( + hostWorktreeTarget({ id: childId, hostId: 'local' }) + ) }) }) diff --git a/src/main/runtime/orca-runtime-tests/local-worktree-creation.spec.ts b/src/main/runtime/orca-runtime-tests/local-worktree-creation.spec.ts index 91a815c2e21..b173e633020 100644 --- a/src/main/runtime/orca-runtime-tests/local-worktree-creation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/local-worktree-creation.spec.ts @@ -20,6 +20,7 @@ import { makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec' +import type { HostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' describe('OrcaRuntimeService', () => { it('creates additional workspace metadata for folder-mode repos through runtime create', async () => { @@ -47,11 +48,11 @@ describe('OrcaRuntimeService', () => { getRepo: (id: string) => (id === folderRepo.id ? folderRepo : undefined), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] }, - removeWorktreeMeta: (worktreeId: string) => { + removeWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget) => { delete metaById[worktreeId] } } diff --git a/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts index dd235d9449a..301ce56ceac 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts @@ -17,6 +17,7 @@ import { store, withPlatform } from '../orca-runtime-test-fixtures.spec' +import type { HostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' describe('OrcaRuntimeService', () => { it('tui-idle times out when PTY data has no agent OSC title transitions', async () => { @@ -268,7 +269,7 @@ describe('OrcaRuntimeService', () => { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] }, diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-02.spec.ts index 6c3e6675625..b6be4066e0a 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-02.spec.ts @@ -17,6 +17,10 @@ import { makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../../shared/worktree/host-qualified-identity' describe('OrcaRuntimeService', () => { it('reports the wake as unsupported when a phone activates a worktree on headless serve', async () => { @@ -138,15 +142,19 @@ describe('OrcaRuntimeService', () => { const metaById: Record = { [TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false, instanceId: 'wt-instance' }) } - const setWorktreeMeta = vi.fn((worktreeId: string, meta: Partial) => { - metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } - return metaById[worktreeId] - }) + const setWorktreeMeta = vi.fn( + ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + ) const worktreesChanged = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial store mock; the code under test reads only the members it defines. const runtime = new OrcaRuntimeService({ ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + getWorktreeMetaForHost: (worktreeId: string) => metaById[worktreeId], setWorktreeMeta } as never) runtime.setNotifier({ @@ -180,7 +188,10 @@ describe('OrcaRuntimeService', () => { await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { notifyClients: false }) expect(setWorktreeMeta).toHaveBeenCalledTimes(1) - expect(setWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, { isUnread: false }) + expect(setWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: TEST_WORKTREE_ID, hostId: 'local' }), + { isUnread: false } + ) expect(worktreesChanged).toHaveBeenCalledTimes(1) expect(worktreesChanged).toHaveBeenCalledWith(TEST_REPO_ID) }) @@ -189,14 +200,18 @@ describe('OrcaRuntimeService', () => { const metaById: Record = { [TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: true }) } - const setWorktreeMeta = vi.fn((worktreeId: string, meta: Partial) => { - metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } - return metaById[worktreeId] - }) + const setWorktreeMeta = vi.fn( + ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial store mock; the code under test reads only the members it defines. const runtime = new OrcaRuntimeService({ ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + getWorktreeMetaForHost: (worktreeId: string) => metaById[worktreeId], setWorktreeMeta } as never) runtime.setNotifier({ diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs.spec.ts index fe8f7e08fab..a4b178cdaeb 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs.spec.ts @@ -12,6 +12,10 @@ import { makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../../shared/worktree/host-qualified-identity' describe('OrcaRuntimeService', () => { it('retires exited saved PTY bindings instead of publishing a pending ghost', async () => { @@ -528,16 +532,20 @@ describe('OrcaRuntimeService', () => { const metaById: Record = { [TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: true }) } - const setWorktreeMeta = vi.fn((worktreeId: string, meta: Partial) => { - metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } - return metaById[worktreeId] - }) + const setWorktreeMeta = vi.fn( + ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + ) const activateWorktree = vi.fn() const worktreesChanged = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial store mock; the code under test reads only the members it defines. const runtime = new OrcaRuntimeService({ ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + getWorktreeMetaForHost: (worktreeId: string) => metaById[worktreeId], setWorktreeMeta } as never) runtime.setNotifier({ @@ -560,7 +568,10 @@ describe('OrcaRuntimeService', () => { await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { notifyClients: false }) - expect(setWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, { isUnread: false }) + expect(setWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: TEST_WORKTREE_ID, hostId: 'local' }), + { isUnread: false } + ) expect(metaById[TEST_WORKTREE_ID]?.isUnread).toBe(false) expect(worktreesChanged).toHaveBeenCalledWith(TEST_REPO_ID) expect(activateWorktree).not.toHaveBeenCalled() @@ -573,10 +584,12 @@ describe('OrcaRuntimeService', () => { } const activateWorktree = vi.fn() const resumeSleepingAgents = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial store mock; the code under test reads only the members it defines. const runtime = new OrcaRuntimeService({ ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + getWorktreeMetaForHost: (worktreeId: string) => metaById[worktreeId], getWorkspaceSession: () => ({ ...getDefaultWorkspaceSession(), sleepingAgentSessionsByPaneKey: { diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts index 31492db4702..b2fa8eac9e4 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts @@ -15,6 +15,7 @@ import { makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec' +import type { HostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' // Why: this process serves no SSH git, so an SSH repo lists its stored rows. function storedWorktreeMeta( @@ -254,7 +255,7 @@ describe('OrcaRuntimeService', () => { getRepo, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] } diff --git a/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle.spec.ts b/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle.spec.ts index bfd91777ad4..de680b417b2 100644 --- a/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle.spec.ts +++ b/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle.spec.ts @@ -18,6 +18,7 @@ import { makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec' +import type { HostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' describe('OrcaRuntimeService', () => { it('rejects when every exact PR branch checkout path suffix is occupied', async () => { @@ -93,7 +94,7 @@ describe('OrcaRuntimeService', () => { ...createFolderWorkspaceRuntimeStore(), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] }, @@ -138,7 +139,7 @@ describe('OrcaRuntimeService', () => { ...createFolderWorkspaceRuntimeStore(), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] }, diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec.ts index 74a53fdea04..04b73f5ab34 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec.ts @@ -42,6 +42,7 @@ import { syncSinglePty } from '../orca-runtime-test-fixtures.spec' import { createWorktreeRemovalRuntime } from '../orca-runtime-test-scenario-builders.spec' +import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' // Why: these fixtures register an SSH provider, which models a connected relay session — and a // connected session has always read the host's `$HOME`. The removal guards refuse without it. @@ -394,7 +395,9 @@ describe('OrcaRuntimeService', () => { }) // The repo resolved to the local host, so the metadata purge names it — // an unqualified purge would evict a same-id row owned by another host. - expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) } finally { gitSpy.mockRestore() } diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts index 65b07f7bc6d..e1aaa317124 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts @@ -30,6 +30,7 @@ import { store } from '../orca-runtime-test-fixtures.spec' import { createWorktreeRemovalRuntime } from '../orca-runtime-test-scenario-builders.spec' +import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' // Why: these fixtures register an SSH provider, which models a connected relay session — and a // connected session has always read the host's `$HOME`. The removal guards refuse without it. @@ -226,7 +227,9 @@ describe('OrcaRuntimeService', () => { expect(removeWorktree).not.toHaveBeenCalled() // The repo resolved to the local host, so the metadata purge names it — // an unqualified purge would evict a same-id row owned by another host. - expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalledWith(runtimeStore, TEST_REPO_ID) expect(notifier.worktreesChanged).toHaveBeenCalledWith(TEST_REPO_ID) @@ -252,7 +255,9 @@ describe('OrcaRuntimeService', () => { expect(removeWorktree).not.toHaveBeenCalled() // The repo resolved to the local host, so the metadata purge names it — // an unqualified purge would evict a same-id row owned by another host. - expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalledWith(runtimeStore, TEST_REPO_ID) expect(notifier.worktreesChanged).toHaveBeenCalledWith(TEST_REPO_ID) @@ -313,7 +318,9 @@ describe('OrcaRuntimeService', () => { expect(removeWorktree).not.toHaveBeenCalled() // The repo resolved to the local host, so the metadata purge names it — // an unqualified purge would evict a same-id row owned by another host. - expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() expect(notifier.worktreesChanged).toHaveBeenCalledWith(TEST_REPO_ID) @@ -386,7 +393,9 @@ describe('OrcaRuntimeService', () => { expect(removeWorktree).not.toHaveBeenCalled() // The repo resolved to the local host, so the metadata purge names it — // an unqualified purge would evict a same-id row owned by another host. - expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() expect(notifier.worktreesChanged).toHaveBeenCalledWith(TEST_REPO_ID) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec.ts index bfb343219db..c39b08d5354 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec.ts @@ -26,6 +26,10 @@ import { writeFile } from '../orca-runtime-test-mocks.spec' import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../../shared/worktree/host-qualified-identity' import { TEST_REPO_ID, TEST_REPO_PATH, @@ -62,7 +66,7 @@ describe('OrcaRuntimeService', () => { orcaCreationSource: 'ssh' }) } - const removeWorktreeMeta = vi.fn((id: string) => { + const removeWorktreeMeta = vi.fn(({ id }: HostWorktreeTarget) => { delete metaById[id] }) const runtimeStore = { @@ -71,7 +75,7 @@ describe('OrcaRuntimeService', () => { getRepo: (id: string) => (id === repo.id ? repo : undefined), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (id: string) => metaById[id], - setWorktreeMeta: (id: string, meta: Partial) => { + setWorktreeMeta: ({ id }: HostWorktreeTarget, meta: Partial) => { metaById[id] = { ...(metaById[id] ?? makeWorktreeMeta()), ...meta } return metaById[id] }, @@ -405,7 +409,9 @@ describe('OrcaRuntimeService', () => { await rm(orphanPath, { recursive: true, force: true }) vi.mocked(listWorktrees).mockResolvedValue([]) await expect(runtime.removeManagedWorktree(worktreeId)).resolves.toEqual({}) - expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }) + ) } finally { gitSpy.mockRestore() await rm(parentDir, { recursive: true, force: true }) @@ -747,7 +753,7 @@ describe('OrcaRuntimeService', () => { updateRepo: () => undefined as never, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { const existingMeta = metaById[worktreeId] const nextMeta: WorktreeMeta = { displayName: meta.displayName ?? existingMeta?.displayName ?? '', diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts index 89f516dfac2..a6481cacae5 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts @@ -37,6 +37,7 @@ import { import { createWorktreeRemovalRuntime } from '../orca-runtime-test-scenario-builders.spec' import { getLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation' import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' +import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' describe('OrcaRuntimeService', () => { it('creates the first terminal by id when duplicate repo entries expose the same path', async () => { @@ -573,7 +574,9 @@ describe('OrcaRuntimeService', () => { warning: expect.stringContaining(TEST_WORKTREE_PATH) }) - expect(removeWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'runtime:env-1') + expect(removeWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: TEST_WORKTREE_ID, hostId: 'runtime:env-1' }) + ) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(TEST_WORKTREE_ID) expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() expect(removeWorktree).not.toHaveBeenCalled() diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts index 8395212c755..3727b586b25 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts @@ -25,6 +25,7 @@ import { _settlePendingWorktreeRemovalsForTests } from '../../worktree-background-removal' import { RuntimeWorktreeRemovalInFlight } from '../runtime-worktree-removal-in-flight' +import type { HostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' const REMOTE_REPO_PATH = '/remote/repo' @@ -42,8 +43,8 @@ function makeRemoteRepoStore( const metaById: Record = { [TEST_WORKTREE_ID]: makeWorktreeMeta({ hostId: executionHostId, ...metaOverrides }) } - const removeWorktreeMeta = vi.fn((worktreeId: string, hostId?: string) => { - if (!hostId || metaById[worktreeId]?.hostId === hostId) { + const removeWorktreeMeta = vi.fn(({ id: worktreeId, hostId }: HostWorktreeTarget) => { + if (metaById[worktreeId]?.hostId === hostId) { delete metaById[worktreeId] } }) @@ -57,7 +58,7 @@ function makeRemoteRepoStore( getRepo: (id: string) => (id === repo.id ? repo : undefined), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] }, diff --git a/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts index 793150e9a2b..0e49e07f922 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts @@ -30,6 +30,7 @@ import { makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec' +import type { HostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' const LOCAL_CHILD_REPO = { id: 'child-repo', @@ -191,7 +192,7 @@ describe('OrcaRuntimeService', () => { getRepo: () => remoteRepo, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...metaById[worktreeId], ...meta } return metaById[worktreeId] } diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts index c877d386de1..d2d50e157ce 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts @@ -21,6 +21,7 @@ import { store, makeRuntimeStoreWithWorkspaceSession } from '../orca-runtime-test-fixtures.spec' +import type { HostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' describe('OrcaRuntimeService', () => { it('does not surface the new workspace when a background create opens Setup', async () => { @@ -193,7 +194,7 @@ describe('OrcaRuntimeService', () => { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] } @@ -256,7 +257,7 @@ describe('OrcaRuntimeService', () => { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] } @@ -331,7 +332,7 @@ describe('OrcaRuntimeService', () => { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] } @@ -419,7 +420,7 @@ describe('OrcaRuntimeService', () => { ...store, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] } @@ -515,7 +516,7 @@ describe('OrcaRuntimeService', () => { }), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] } @@ -770,7 +771,7 @@ describe('OrcaRuntimeService', () => { }), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] } diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts index 063327d3559..fbf7b4fcbb3 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts @@ -22,6 +22,7 @@ import { store, makeRuntimeStoreWithWorkspaceSession } from '../orca-runtime-test-fixtures.spec' +import type { HostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' describe('OrcaRuntimeService', () => { it('sends follow-up prompts for CLI-created stdin-after-start startup agents', async () => { @@ -34,7 +35,7 @@ describe('OrcaRuntimeService', () => { }), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] } @@ -107,7 +108,7 @@ describe('OrcaRuntimeService', () => { }), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] } @@ -177,7 +178,7 @@ describe('OrcaRuntimeService', () => { }), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] } @@ -248,7 +249,7 @@ describe('OrcaRuntimeService', () => { }), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] } @@ -476,7 +477,7 @@ describe('OrcaRuntimeService', () => { }), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] } @@ -562,7 +563,7 @@ describe('OrcaRuntimeService', () => { }), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, meta: Partial) => { metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } return metaById[worktreeId] } diff --git a/src/main/runtime/orchestration-worker-workspace-resolution.test.ts b/src/main/runtime/orchestration-worker-workspace-resolution.test.ts index 85313d07b00..94ffc0e1d33 100644 --- a/src/main/runtime/orchestration-worker-workspace-resolution.test.ts +++ b/src/main/runtime/orchestration-worker-workspace-resolution.test.ts @@ -27,6 +27,7 @@ import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import { OrcaRuntimeService } from './orca-runtime' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' const REPO_ID = 'repo-1' const REPO_PATH = '/repo' @@ -74,7 +75,7 @@ function makeStore( getRepos: () => repos, getAllWorktreeMeta: () => meta, getWorktreeMeta: (id: string) => meta[id], - setWorktreeMeta: (id: string, patch: Partial) => { + setWorktreeMeta: ({ id }: HostWorktreeTarget, patch: Partial) => { meta[id] = { ...(meta[id] ?? makeMeta('')), ...patch } return meta[id] }, diff --git a/src/main/runtime/pty-inventory-partial-relay-liveness.spec.ts b/src/main/runtime/pty-inventory-partial-relay-liveness.spec.ts index d045a0d8e0e..ac564a76acf 100644 --- a/src/main/runtime/pty-inventory-partial-relay-liveness.spec.ts +++ b/src/main/runtime/pty-inventory-partial-relay-liveness.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import { makePaneKey } from '../../shared/stable-pane-id' import { OrcaRuntimeService } from './orca-runtime' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' export function registerPtyInventoryPartialRelayLivenessTests(): void { // STA-517: the worktree.ps liveness refresh is the only thing that retires an exited PTY, and @@ -58,7 +59,7 @@ export function registerPtyInventoryPartialRelayLivenessTests(): void { getRepo: (id: string) => (id === REPO_ID ? REPO : undefined), getAllWorktreeMeta: () => meta, getWorktreeMeta: (worktreeId: string) => meta[worktreeId], - setWorktreeMeta: (worktreeId: string, patch: Record) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, patch: Record) => { meta[worktreeId] = { ...meta[worktreeId], ...patch } return meta[worktreeId] }, diff --git a/src/main/runtime/repo-worktree-row-resolution.test.ts b/src/main/runtime/repo-worktree-row-resolution.test.ts index c6fcd82b051..d611bd4daf0 100644 --- a/src/main/runtime/repo-worktree-row-resolution.test.ts +++ b/src/main/runtime/repo-worktree-row-resolution.test.ts @@ -13,6 +13,7 @@ import { resolveScopedWorktreeIdRow, type RepoWorktreeRowDeps } from './repo-worktree-row-resolution' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' function repo( id: string, @@ -46,13 +47,14 @@ function createDeps(repos: Repo[]): RepoWorktreeRowDeps & { listFolderWorkspaces: ReturnType> } { const metaById: Record = {} + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial store mock; the code under test reads only the members it defines. const store = { getRepos: () => repos, getAllWorktreeMeta: () => metaById, getAllWorktreeLineage: () => ({}), getProjects: () => [], getSettings: () => ({}), - setWorktreeMeta: (worktreeId: string, updates: Partial) => { + setWorktreeMeta: ({ id: worktreeId }: HostWorktreeTarget, updates: Partial) => { const next = { ...metaById[worktreeId], ...updates } as WorktreeMeta metaById[worktreeId] = next return next diff --git a/src/main/runtime/repo-worktree-row-resolution.ts b/src/main/runtime/repo-worktree-row-resolution.ts index 764e3dae409..3ed8f114945 100644 --- a/src/main/runtime/repo-worktree-row-resolution.ts +++ b/src/main/runtime/repo-worktree-row-resolution.ts @@ -7,9 +7,9 @@ import { import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { readAllWorktreeMetaForHost, - readWorktreeMetaForHost, - writeWorktreeMetaForHost + readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' import { isFolderRepo } from '../../shared/repo-kind' import { getConnectedWorktreeLineageIds, @@ -41,11 +41,9 @@ type RepoWorktreeRowStore = Pick< | 'getWorktreeMeta' | 'getWorktreeMetaForHost' | 'setWorktreeMeta' - | 'setWorktreeMetaForHost' | 'getAllWorktreeLineage' | 'getAllWorkspaceLineage' | 'removeWorktreeLineage' - | 'removeWorkspaceLineage' > /** @@ -172,7 +170,7 @@ export async function resolveRepoWorktreeRows( const meta = ownedExistingMeta?.instanceId ? ownedExistingMeta : ownedExistingMeta || (!existingMeta && repoOwnerCount === 1) - ? writeWorktreeMetaForHost(store, worktreeId, expectedHostId, {}) + ? store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: expectedHostId }), {}) : undefined const merged = { ...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), diff --git a/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts index 2768c6454c5..8743852aa23 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts @@ -41,6 +41,7 @@ import { OrchestrationDb } from '../../../../orchestration/db' import { OrcaRuntimeService } from '../../../../orca-runtime' import { ORCHESTRATION_WORKER_LIST_METHOD } from './worker-list-method' import { projectFleetWorkerPage } from './worker-observation' +import type { HostWorktreeTarget } from '../../../../../../shared/worktree/host-qualified-identity' const PANE_KEY = 'tab-census:leaf-census' const TERMINAL_HANDLE = 'term_census' @@ -119,7 +120,7 @@ function censusStore() { ], getAllWorktreeMeta: () => metaById, getWorktreeMeta: (id: string) => metaById[id], - setWorktreeMeta: (id: string, meta: Record) => { + setWorktreeMeta: ({ id }: HostWorktreeTarget, meta: Record) => { metaById[id] = { ...(metaById[id] as object), ...meta } return metaById[id] }, diff --git a/src/main/runtime/runtime-created-worktree-terminal-target.test.ts b/src/main/runtime/runtime-created-worktree-terminal-target.test.ts index f3a0131792c..166b4c13d12 100644 --- a/src/main/runtime/runtime-created-worktree-terminal-target.test.ts +++ b/src/main/runtime/runtime-created-worktree-terminal-target.test.ts @@ -15,6 +15,7 @@ import type { Repo } from '../../shared/repo-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { Worktree } from '../../shared/worktree/types' import type { WorktreeLineage } from '../../shared/worktree/lineage-types' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' import type { RuntimeStore } from './runtime-store-contract' import type { ResolvedWorktree } from './runtime-worktree-path-identity' import { mergeWorktree } from '../ipc/worktree-metadata-merge' @@ -89,9 +90,10 @@ function makeFixture(repos: Repo[] = [owner()], hostId: ExecutionHostId = 'local getAllWorktreeMeta: () => metadata, getWorktreeMeta: (id) => metadata[id], getAllWorktreeLineage: () => lineageById, - setWorktreeMeta: (id, patch) => (metadata[id] = { ...meta, ...metadata[id], ...patch }), - removeWorktreeMeta: (id) => { - delete metadata[id] + setWorktreeMeta: (target, patch) => + (metadata[target.id] = { ...meta, ...metadata[target.id], ...patch }), + removeWorktreeMeta: (target) => { + delete metadata[target.id] }, getGitHubCache: vi.fn(), getSettings: () => ({ @@ -385,8 +387,11 @@ describe('workspace activation and metadata reentry', () => { expect(store.getWorktreeMeta(runtime.selectedWorktree.id)?.comment).toBe('updated') expect(notify.mock.calls).toEqual([['repo']]) const otherId = `repo::${join(directory, 'other')}` - store.setWorktreeMeta(runtime.selectedWorktree.id, { sortOrder: 0 }) - store.setWorktreeMeta(otherId, { sortOrder: 0 }) + store.setWorktreeMeta( + hostWorktreeTarget({ id: runtime.selectedWorktree.id, hostId: 'local' }), + { sortOrder: 0 } + ) + store.setWorktreeMeta(hostWorktreeTarget({ id: otherId, hostId: 'local' }), { sortOrder: 0 }) expect(runtime.persistManagedWorktreeSortOrder([runtime.selectedWorktree.id, otherId])).toEqual( { updated: 2 diff --git a/src/main/runtime/runtime-folder-worktree-create.test.ts b/src/main/runtime/runtime-folder-worktree-create.test.ts index af0d3b8b998..6ab3579a950 100644 --- a/src/main/runtime/runtime-folder-worktree-create.test.ts +++ b/src/main/runtime/runtime-folder-worktree-create.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' import { createRuntimeFolderWorktree } from './runtime-folder-worktree-create' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' type CreateArgs = Parameters[0] @@ -20,7 +21,7 @@ function createDeps() { .mockResolvedValue({ handle: 'term-1', worktreeId: 'folder-1', title: null }) const store = { getSettings: () => ({ workspaceDir: '/ws', nestWorkspaces: false }), - setWorktreeMeta: (_id: string, meta: Record) => meta, + setWorktreeMeta: (_target: HostWorktreeTarget, meta: Record) => meta, getProjectHostSetups: () => [] } const deps: CreateArgs['deps'] = { diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index 5735db33823..19d07cb24d4 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -20,6 +20,7 @@ import type { WorktreeStartupDraftPaste, WorktreeStartupFollowup } from './runtime-worktree-agent-startup' +import { hostWorktreeTargetForRepo } from '../persistence/host-qualified-worktree-meta' type RuntimeFolderWorktreeCreateDeps = { store: RuntimeStore @@ -74,7 +75,7 @@ export async function createRuntimeFolderWorktree(args: { request.nameWasGenerated === true ) const resolvedFolderDisplayName = displayNameRequest.value - const meta = deps.store.setWorktreeMeta(worktreeId, { + const meta = deps.store.setWorktreeMeta(hostWorktreeTargetForRepo(worktreeId, repo), { instanceId, ...getProjectHostSetupWorktreeMeta(deps.store.getProjectHostSetups?.() ?? [], repo), displayName: resolvedFolderDisplayName ?? request.name, diff --git a/src/main/runtime/runtime-git-command-target.ts b/src/main/runtime/runtime-git-command-target.ts index 0bf28b28ca6..b8c3f6535d0 100644 --- a/src/main/runtime/runtime-git-command-target.ts +++ b/src/main/runtime/runtime-git-command-target.ts @@ -47,7 +47,10 @@ export type RuntimeGitCommandHost = { * no store (avoids unrelated ownership-inheritance/refspec-migration side effects), so a * lazily-minted remote still needs a way back into the store's `pushTarget.remoteCreated` * for #17842's orphan sweep. Called only when materialize reports `remoteCreated: true`. */ - persistMaterializedPushTarget?(worktreeId: string, pushTarget: GitPushTarget): void + persistMaterializedPushTarget?( + worktree: Pick, + pushTarget: GitPushTarget + ): void } /** diff --git a/src/main/runtime/runtime-git-sync-commands.ts b/src/main/runtime/runtime-git-sync-commands.ts index e511fff551d..2e6d12061d3 100644 --- a/src/main/runtime/runtime-git-sync-commands.ts +++ b/src/main/runtime/runtime-git-sync-commands.ts @@ -36,7 +36,7 @@ export class RuntimeGitSyncCommands { ) : undefined if (materialized?.remoteCreated) { - this.host.persistMaterializedPushTarget?.(target.worktree.id, materialized) + this.host.persistMaterializedPushTarget?.(target.worktree, materialized) } return { provider, pushTarget: materialized } } diff --git a/src/main/runtime/runtime-linear-command-base.ts b/src/main/runtime/runtime-linear-command-base.ts index 9043659f9dd..994528bec17 100644 --- a/src/main/runtime/runtime-linear-command-base.ts +++ b/src/main/runtime/runtime-linear-command-base.ts @@ -9,7 +9,7 @@ export type RuntimeLinearCommandPorts = { resolveWorktreeSelector: (selector: string) => Promise listResolvedWorktrees: () => Promise setWorktreeMeta: ( - worktreeId: string, + worktree: Pick, meta: { linkedLinearIssueWorkspaceId: string linkedLinearIssueOrganizationUrlKey: string | null @@ -41,13 +41,13 @@ export class RuntimeLinearCommandBase extends RuntimeLinearBrowseCommands { } protected setWorktreeMeta( - worktreeId: string, + worktree: Pick, meta: { linkedLinearIssueWorkspaceId: string linkedLinearIssueOrganizationUrlKey: string | null } ): void { - this.ports.setWorktreeMeta(worktreeId, meta) + this.ports.setWorktreeMeta(worktree, meta) } protected emitClientEvent(event: LinearLinkedIssueUpdatedEvent): void { diff --git a/src/main/runtime/runtime-linear-team-write-commands.ts b/src/main/runtime/runtime-linear-team-write-commands.ts index 15926c19ab8..d0f897d6a74 100644 --- a/src/main/runtime/runtime-linear-team-write-commands.ts +++ b/src/main/runtime/runtime-linear-team-write-commands.ts @@ -271,7 +271,7 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands if (backfill?.workspaceId) { // The answer still serves this lookup; persisting it waits out a WSL move. if (!isWslMoveWorktreeBlocked(worktree.id, worktree.hostId, worktree.path)) { - this.setWorktreeMeta(worktree.id, { + this.setWorktreeMeta(worktree, { linkedLinearIssueWorkspaceId: backfill.workspaceId, linkedLinearIssueOrganizationUrlKey: backfill.organizationUrlKey ?? null }) diff --git a/src/main/runtime/runtime-local-worktree-create.test.ts b/src/main/runtime/runtime-local-worktree-create.test.ts index 0420e137e79..2e649df19a5 100644 --- a/src/main/runtime/runtime-local-worktree-create.test.ts +++ b/src/main/runtime/runtime-local-worktree-create.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { resolve } from 'node:path' import type { Store } from '../persistence' import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' import type { Repo } from '../../shared/repo-types' import { getDefaultRepoHookSettings } from '../../shared/constants' import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' @@ -120,7 +121,7 @@ function createWorktree( refreshLocalBaseRefOnWorktreeCreate: false, branchPrefix: '' }), - setWorktreeMeta: (_id: string, updates: Partial) => { + setWorktreeMeta: (_target: HostWorktreeTarget, updates: Partial) => { writtenMeta.push(updates) return updates }, diff --git a/src/main/runtime/runtime-local-worktree-materialization.test.ts b/src/main/runtime/runtime-local-worktree-materialization.test.ts index 4966ac862f8..c2c15196c42 100644 --- a/src/main/runtime/runtime-local-worktree-materialization.test.ts +++ b/src/main/runtime/runtime-local-worktree-materialization.test.ts @@ -22,6 +22,7 @@ vi.mock('../git/worktree-shared-directories', () => ({ import { materializeRuntimeLocalWorktree } from './runtime-local-worktree-materialization' import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' describe('materializeRuntimeLocalWorktree', () => { it('records lineage immediately after metadata and before filesystem setup', async () => { @@ -32,7 +33,10 @@ describe('materializeRuntimeLocalWorktree', () => { }) const store = { getProjectHostSetups: () => [], - setWorktreeMeta: vi.fn((_id, updates) => ({ ...updates, hostId: 'local' })) + setWorktreeMeta: vi.fn((_target: HostWorktreeTarget, updates) => ({ + ...updates, + hostId: 'local' + })) } await expect( diff --git a/src/main/runtime/runtime-local-worktree-materialization.ts b/src/main/runtime/runtime-local-worktree-materialization.ts index 92dc5d22852..8af73de28e9 100644 --- a/src/main/runtime/runtime-local-worktree-materialization.ts +++ b/src/main/runtime/runtime-local-worktree-materialization.ts @@ -22,6 +22,7 @@ import type { RemoteTrackingBase } from './runtime-remote-fetch-controller' import type { RuntimeStore } from './runtime-store-contract' import { attributedSparsePresetId } from '../ipc/sparse-preset-attribution' import type { WorktreeCreateTimingRecorder } from '../worktree-create-timing' +import { hostWorktreeTargetForRepo } from '../persistence/host-qualified-worktree-meta' export async function materializeRuntimeLocalWorktree(args: { request: RuntimeManagedWorktreeCreateArgs @@ -74,7 +75,7 @@ export async function materializeRuntimeLocalWorktree(args: { { requestedName: effectiveRequestedName, sanitizedName: effectiveSanitizedName } ) const meta = timing.timeSync('persist_metadata', () => - store.setWorktreeMeta(worktreeId, { + store.setWorktreeMeta(hostWorktreeTargetForRepo(worktreeId, repo), { instanceId: randomUUID(), ...getProjectHostSetupWorktreeMeta(store.getProjectHostSetups?.() ?? [], repo), lastActivityAt: now, diff --git a/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts index 4913b31bd7d..4601b6d4df2 100644 --- a/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts +++ b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts @@ -11,6 +11,7 @@ import { testState, createStore, makeRepo } from '../persistence-test-harness' import type { Store } from '../persistence/loading-store/store' import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-queries' import type { RuntimeStore } from './runtime-store-contract' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: vi.fn(), @@ -68,7 +69,9 @@ describe('runtime detected-worktree listing sweeps missing local metadata', () = const repo = makeRepo({ id: 'repo-1', path: repoPath }) store.addRepo(repo) const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}` - store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: missingId, hostId: 'local' }), { + displayName: 'Gone' + }) expect(store.getWorktreeMeta(missingId)).toBeDefined() await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo) @@ -83,7 +86,9 @@ describe('runtime detected-worktree listing sweeps missing local metadata', () = const livePath = join(testState.dir, 'live-worktree') mkdirSync(livePath, { recursive: true }) const liveId = `${repo.id}::${livePath}` - store.setWorktreeMetaForHost(liveId, 'local', { displayName: 'Live' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: liveId, hostId: 'local' }), { + displayName: 'Live' + }) await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo) @@ -96,7 +101,9 @@ describe('runtime detected-worktree listing sweeps missing local metadata', () = const repo = makeRepo({ id: 'repo-1', path: repoPath }) store.addRepo(repo) const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}` - store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: missingId, hostId: 'local' }), { + displayName: 'Gone' + }) await queries(store, repo, [], false).listDetected(repo) @@ -115,7 +122,9 @@ describe('runtime detected-worktree listing sweeps missing local metadata', () = const repo = makeRepo({ id: 'repo-1', path: repoPath, connectionId: 'build-box' }) store.addRepo(repo) const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}` - store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: missingId, hostId: 'local' }), { + displayName: 'Gone' + }) await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo) diff --git a/src/main/runtime/runtime-managed-worktree-metadata-wsl-move.test.ts b/src/main/runtime/runtime-managed-worktree-metadata-wsl-move.test.ts index a2b11a8ef35..26376ab7ec3 100644 --- a/src/main/runtime/runtime-managed-worktree-metadata-wsl-move.test.ts +++ b/src/main/runtime/runtime-managed-worktree-metadata-wsl-move.test.ts @@ -16,6 +16,7 @@ import { updateRuntimeManagedWorktreeMetadata } from './runtime-managed-worktree-metadata' import type { ResolvedWorktree } from './runtime-worktree-path-identity' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -113,8 +114,8 @@ describe('updateRuntimeManagedWorktreeMetadata under a WSL move', () => { describe('persistRuntimeManagedWorktreeSortOrder under a WSL move', () => { it('drops the moving project from the batch and writes the rest', () => { - store.setWorktreeMeta(MOVING, { sortOrder: 1 }) - store.setWorktreeMeta(STAYING, { sortOrder: 2 }) + store.setWorktreeMeta(hostWorktreeTarget({ id: MOVING, hostId: 'local' }), { sortOrder: 1 }) + store.setWorktreeMeta(hostWorktreeTarget({ id: STAYING, hostId: 'local' }), { sortOrder: 2 }) const result = persistRuntimeManagedWorktreeSortOrder({ orderedIds: [MOVING, STAYING], store, diff --git a/src/main/runtime/runtime-managed-worktree-metadata.test.ts b/src/main/runtime/runtime-managed-worktree-metadata.test.ts index 4cfd3f45efc..c3ce8fd3f66 100644 --- a/src/main/runtime/runtime-managed-worktree-metadata.test.ts +++ b/src/main/runtime/runtime-managed-worktree-metadata.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' import type { Worktree } from '../../shared/worktree/types' import { updateRuntimeManagedWorktreeMetadata } from './runtime-managed-worktree-metadata' import type { ResolvedWorktree } from './runtime-worktree-path-identity' @@ -15,12 +16,10 @@ describe('updateRuntimeManagedWorktreeMetadata', () => { instanceId: 'instance-1' } as unknown as ResolvedWorktree const setWorktreeMeta = vi.fn() - const setWorktreeMetaForHost = vi.fn() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the update only reads these store members. const store = { getRepos: () => [], - setWorktreeMeta, - setWorktreeMetaForHost + setWorktreeMeta } as unknown as RuntimeStore const ports = { resolveWorktree: vi.fn(async () => worktree), @@ -38,10 +37,10 @@ describe('updateRuntimeManagedWorktreeMetadata', () => { ports }) - expect(setWorktreeMetaForHost).toHaveBeenCalledWith(worktree.id, 'ssh:build-box', { - comment: 'remote row only' - }) - expect(setWorktreeMeta).not.toHaveBeenCalled() + expect(setWorktreeMeta).toHaveBeenCalledExactlyOnceWith( + hostWorktreeTarget({ id: worktree.id, hostId: 'ssh:build-box' }), + { comment: 'remote row only' } + ) }) describe('a folder workspace removed while its resolution was in flight (#22712)', () => { @@ -57,14 +56,12 @@ describe('updateRuntimeManagedWorktreeMetadata', () => { function run(meta: Partial | undefined) { const setWorktreeMeta = vi.fn() - const setWorktreeMetaForHost = vi.fn() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the update only reads these store members. const store = { getRepos: () => [folderRepo], getWorktreeMeta: () => meta, getWorktreeMetaForHost: () => meta, - setWorktreeMeta, - setWorktreeMetaForHost + setWorktreeMeta } as unknown as RuntimeStore const result = updateRuntimeManagedWorktreeMetadata({ selector: `id:${worktree.id}`, @@ -79,24 +76,24 @@ describe('updateRuntimeManagedWorktreeMetadata', () => { showWorktree: vi.fn(async () => worktree as unknown as Worktree) } }) - return { result, setWorktreeMeta, setWorktreeMetaForHost } + return { result, setWorktreeMeta } } it('refuses to recreate the removed workspace as a blank row', async () => { - const { result, setWorktreeMeta, setWorktreeMetaForHost } = run(undefined) + const { result, setWorktreeMeta } = run(undefined) await expect(result).rejects.toThrow('selector_not_found') expect(setWorktreeMeta).not.toHaveBeenCalled() - expect(setWorktreeMetaForHost).not.toHaveBeenCalled() }) it('still writes to a folder workspace that exists', async () => { - const { result, setWorktreeMetaForHost } = run({ instanceId: 'instance-1' }) + const { result, setWorktreeMeta } = run({ instanceId: 'instance-1' }) await result - expect(setWorktreeMetaForHost).toHaveBeenCalledWith(worktree.id, 'local', { - lastActivityAt: 2 - }) + expect(setWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktree.id, hostId: 'local' }), + { lastActivityAt: 2 } + ) }) }) }) diff --git a/src/main/runtime/runtime-managed-worktree-metadata.ts b/src/main/runtime/runtime-managed-worktree-metadata.ts index 4fe2f2769ce..6c89387bb48 100644 --- a/src/main/runtime/runtime-managed-worktree-metadata.ts +++ b/src/main/runtime/runtime-managed-worktree-metadata.ts @@ -8,6 +8,10 @@ import { } from '../../shared/worktree/sort-order-update' import { stripOrcaProvenanceMetaUpdates } from '../worktree-removal-safety' import { isRemovedFolderWorkspaceInstance } from '../folder-workspace-meta-presence' +import { + resolveWorktreeMetaOwner, + worktreeRowTarget +} from '../persistence/host-qualified-worktree-meta' import type { RuntimeStore } from './runtime-store-contract' import { isWslMoveSourceHost, @@ -55,6 +59,10 @@ export async function updateRuntimeManagedWorktreeMetadata(args: { ) { return worktree } + const target = worktreeRowTarget(args.store, { id: worktree.id, hostId: executionHostId }) + if (!target) { + throw new Error('selector_ambiguous') + } if (lineage?.parentWorktree) { args.ports.invalidateResolved() args.ports.invalidateScan(worktree.repoId) @@ -77,8 +85,7 @@ export async function updateRuntimeManagedWorktreeMetadata(args: { persisted.pushTarget = undefined } if (lineage?.noParent === true) { - args.store.removeWorktreeLineage?.(worktree.id) - args.store.removeWorkspaceLineage?.(worktreeWorkspaceKey(worktree.id)) + args.store.removeWorktreeLineage?.(target) } else if (lineage?.parentWorktree) { const parent = await args.ports.resolveWorktree(lineage.parentWorktree) args.ports.validateParent(worktree, parent) @@ -96,7 +103,7 @@ export async function updateRuntimeManagedWorktreeMetadata(args: { ) } const createdAt = Date.now() - args.store.setWorktreeLineage(worktree.id, { + const recorded = args.store.setWorktreeLineage(target, { worktreeId: worktree.id, worktreeInstanceId: worktree.instanceId, parentWorktreeId: parent.id, @@ -105,6 +112,12 @@ export async function updateRuntimeManagedWorktreeMetadata(args: { capture: { source: 'manual-action', confidence: 'explicit' }, createdAt }) + if (!recorded) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_CONFLICT', + 'A same-id workspace on another host owns this lineage.' + ) + } args.store.setWorkspaceLineage?.({ childWorkspaceKey: worktreeWorkspaceKey(worktree.id), childInstanceId: worktree.instanceId, @@ -120,11 +133,7 @@ export async function updateRuntimeManagedWorktreeMetadata(args: { if (isRemovedFolderWorkspaceInstance(args.store, worktree.id, executionHostId)) { throw new Error('selector_not_found') } - if (executionHostId && args.store.setWorktreeMetaForHost) { - args.store.setWorktreeMetaForHost(worktree.id, executionHostId, metadataUpdates) - } else { - args.store.setWorktreeMeta(worktree.id, metadataUpdates) - } + args.store.setWorktreeMeta(target, metadataUpdates) // Why: CLI callers need an explicit push for metadata changed outside the renderer's optimistic update path. args.ports.invalidateResolved() args.ports.notifyChanged(worktree.repoId) @@ -149,10 +158,16 @@ export function persistRuntimeManagedWorktreeSortOrder(args: { args.orderedIds, (worktreeId) => args.store.getWorktreeMeta(worktreeId), Date.now() - ).filter((update) => !isWslMoveWorkspaceBlocked(args.store, update.worktreeId)) - for (const update of updates) { - args.store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder }) - } + ).filter((update) => { + const target = isWslMoveWorkspaceBlocked(args.store, update.worktreeId) + ? undefined + : resolveWorktreeMetaOwner(args.store, update.worktreeId) + // Only what was saved is reported and published. + if (target) { + args.store.setWorktreeMeta(target, { sortOrder: update.sortOrder }) + } + return target !== undefined + }) if (updates.length === 0) { return { updated: 0 } } diff --git a/src/main/runtime/runtime-managed-worktree-queries.test.ts b/src/main/runtime/runtime-managed-worktree-queries.test.ts index 32f4aeb64ff..0dbead66ccf 100644 --- a/src/main/runtime/runtime-managed-worktree-queries.test.ts +++ b/src/main/runtime/runtime-managed-worktree-queries.test.ts @@ -32,6 +32,7 @@ import { getLocalWorktreeScanGeneration, resetLocalWorktreeScanGenerationsForTests } from '../local-worktree-scan-generation' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' const settings = { workspaceDir: '/worktrees', @@ -139,12 +140,13 @@ describe('RuntimeManagedWorktreeQueries.listDetected', () => { it('omits host-owned source defaults for clients that do not support them', async () => { const repo = folderRepo({ path: '/source/app' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial store mock; the code under test reads only the members it defines. const store = { getRepos: () => [repo], getRepo: () => repo, getAllWorktreeMeta: () => ({}), getWorktreeMeta: () => undefined, - setWorktreeMeta: vi.fn((_id, updates) => metadata(updates)), + setWorktreeMeta: vi.fn((_target: HostWorktreeTarget, updates) => metadata(updates)), getAllWorktreeLineage: () => ({}), getSettings: () => ({ ...settings, diff --git a/src/main/runtime/runtime-provisioned-root-adoption.test.ts b/src/main/runtime/runtime-provisioned-root-adoption.test.ts index 364b3a90f30..b5a298d2f94 100644 --- a/src/main/runtime/runtime-provisioned-root-adoption.test.ts +++ b/src/main/runtime/runtime-provisioned-root-adoption.test.ts @@ -15,6 +15,7 @@ import { adoptRuntimeProvisionedRoot, type RuntimeProvisionedRootPorts } from './runtime-provisioned-root-adoption' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' const root = '/workspace/repo' const repo: Repo = { @@ -67,7 +68,7 @@ describe('adoptRuntimeProvisionedRoot', () => { ) expect(result.worktree.id).toBe(`repo-vm::${root}`) expect(setWorktreeMeta).toHaveBeenCalledWith( - `repo-vm::${root}`, + hostWorktreeTarget({ id: `repo-vm::${root}`, hostId: 'local' }), expect.objectContaining({ ephemeralVmCheckoutMode: 'provisioned-root', displayName: 'fix-login', diff --git a/src/main/runtime/runtime-provisioned-root-adoption.ts b/src/main/runtime/runtime-provisioned-root-adoption.ts index 42673dd0002..691aaaafbe1 100644 --- a/src/main/runtime/runtime-provisioned-root-adoption.ts +++ b/src/main/runtime/runtime-provisioned-root-adoption.ts @@ -17,6 +17,7 @@ import { import { gitExecFileAsync } from '../git/runner' import type { RuntimeStore } from './runtime-store-contract' import { assertWslMoveRepoWritable } from '../persistence/moving-wsl-projects/wsl-move-scope-guard' +import { hostWorktreeTargetForRepo } from '../persistence/host-qualified-worktree-meta' export type RuntimeProvisionedRootRequest = ProvisionedRootRequest & { expectedPath: string } @@ -58,7 +59,7 @@ export async function adoptRuntimeProvisionedRoot( }) const worktreeId = `${repo.id}::${gitWorktree.path}` ports.store.setWorktreeMeta( - worktreeId, + hostWorktreeTargetForRepo(worktreeId, repo), buildProvisionedRootMeta(ports.store, repo, request, { branchName: gitWorktree.branch.replace(/^refs\/heads\//, ''), now: Date.now(), diff --git a/src/main/runtime/runtime-rpc-worktree-store-fixtures.ts b/src/main/runtime/runtime-rpc-worktree-store-fixtures.ts index d075672984f..c5687d6a3f7 100644 --- a/src/main/runtime/runtime-rpc-worktree-store-fixtures.ts +++ b/src/main/runtime/runtime-rpc-worktree-store-fixtures.ts @@ -1,3 +1,4 @@ +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' export const makeStore = (overrides?: { isUnread?: boolean }) => ({ getRepo: (id: string) => makeStore(overrides) @@ -36,7 +37,7 @@ export const makeStore = (overrides?: { isUnread?: boolean }) => ({ worktreeId === 'repo-1::/tmp/worktree-a' ? (makeStore(overrides).getAllWorktreeMeta()[worktreeId] as never) : undefined, - setWorktreeMeta: (_worktreeId: string, meta: Record) => + setWorktreeMeta: (_target: HostWorktreeTarget, meta: Record) => ({ ...makeStore(overrides).getAllWorktreeMeta()['repo-1::/tmp/worktree-a'], ...meta diff --git a/src/main/runtime/runtime-sort-order-scan-cache.test.ts b/src/main/runtime/runtime-sort-order-scan-cache.test.ts index 34ca93efbf0..40bc01c3579 100644 --- a/src/main/runtime/runtime-sort-order-scan-cache.test.ts +++ b/src/main/runtime/runtime-sort-order-scan-cache.test.ts @@ -13,6 +13,7 @@ import { } from './orca-runtime-test-fixtures.spec' import { registerRuntimeWindowLifecycle } from '../window/runtime-window-lifecycle' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' vi.mock('electron', () => ({ ipcMain: { on: vi.fn(), removeListener: vi.fn() } })) @@ -30,7 +31,7 @@ async function createScannedRuntime(): Promise metaById, getWorktreeMeta: (id: string) => metaById[id], - setWorktreeMeta: (id: string, meta: Partial) => { + setWorktreeMeta: ({ id }: HostWorktreeTarget, meta: Partial) => { metaById[id] = { ...metaById[id], ...meta } return metaById[id] } diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 2b40d8d386a..2ed414e5502 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -37,16 +37,15 @@ export type RuntimeStore = { getProfileStorageDirectory?: Store['getProfileStorageDirectory'] getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] - setWorktreeMetaForHost?: Store['setWorktreeMetaForHost'] getWorktreeMetaForHost?: Store['getWorktreeMetaForHost'] removeWorktreeMeta: Store['removeWorktreeMeta'] + removeUnhostedWorktreeMeta?: Store['removeUnhostedWorktreeMeta'] getWorktreeLineage?: Store['getWorktreeLineage'] getAllWorktreeLineage?: Store['getAllWorktreeLineage'] setWorktreeLineage?: Store['setWorktreeLineage'] removeWorktreeLineage?: Store['removeWorktreeLineage'] getAllWorkspaceLineage?: Store['getAllWorkspaceLineage'] setWorkspaceLineage?: Store['setWorkspaceLineage'] - removeWorkspaceLineage?: Store['removeWorkspaceLineage'] getGitHubCache: Store['getGitHubCache'] getWorkspaceSession?: Store['getWorkspaceSession'] getWorkspaceSessionHostIds?: Store['getWorkspaceSessionHostIds'] diff --git a/src/main/runtime/runtime-worktree-filesystem.ts b/src/main/runtime/runtime-worktree-filesystem.ts index 94093e28e8e..423c8401081 100644 --- a/src/main/runtime/runtime-worktree-filesystem.ts +++ b/src/main/runtime/runtime-worktree-filesystem.ts @@ -19,6 +19,7 @@ import { } from './runtime-folder-workspace' import type { RuntimeStore } from './runtime-store-contract' import { isWslMoveRepoBlocked } from '../persistence/moving-wsl-projects/wsl-move-background-backoff' +import { hostWorktreeTargetForRepo } from '../persistence/host-qualified-worktree-meta' /** Callers hold a local removal route; an `ssh:` or `runtime:` host is refused before this. */ export async function isLocalRuntimeWorktreePathMissing( @@ -62,7 +63,7 @@ export function listRuntimeFolderWorkspaces( const meta: Partial = existing?.instanceId ? existing : canWrite && (existing || repoOwnerCount === 1) - ? store.setWorktreeMeta(worktreeId, { + ? store.setWorktreeMeta(hostWorktreeTargetForRepo(worktreeId, repo), { instanceId: getRuntimeFolderWorkspaceInstanceIdentity(repo, worktreeId), ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) }) diff --git a/src/main/runtime/runtime-worktree-lineage-controller.ts b/src/main/runtime/runtime-worktree-lineage-controller.ts index 47a71fa72a5..89702882fb6 100644 --- a/src/main/runtime/runtime-worktree-lineage-controller.ts +++ b/src/main/runtime/runtime-worktree-lineage-controller.ts @@ -9,6 +9,7 @@ import { import { sharesResolvedWorktreeLineageBoundary } from '../../shared/resolved-worktree-lineage' import type { OrchestrationDb } from './orchestration/db' import type { RuntimeStore } from './runtime-store-contract' +import { worktreeRowTarget } from '../persistence/host-qualified-worktree-meta' import type { ResolvedWorktree } from './runtime-worktree-path-identity' import { isWslMoveWorktreeBlocked } from '../persistence/moving-wsl-projects/wsl-move-background-backoff' import { @@ -182,7 +183,11 @@ export class RuntimeWorktreeLineageController { } catch { continue } - store.setWorktreeLineage(worktree.id, { + const target = worktreeRowTarget(store, worktree) + if (!target) { + continue + } + store.setWorktreeLineage(target, { worktreeId: worktree.id, worktreeInstanceId: worktree.instanceId, parentWorktreeId: candidate.parent.worktree.id, diff --git a/src/main/runtime/runtime-worktree-lineage-recording.ts b/src/main/runtime/runtime-worktree-lineage-recording.ts index b876c7dc136..d3bc97f54f2 100644 --- a/src/main/runtime/runtime-worktree-lineage-recording.ts +++ b/src/main/runtime/runtime-worktree-lineage-recording.ts @@ -7,6 +7,7 @@ import type { } from '../../shared/worktree/lineage-types' import { worktreeWorkspaceKey } from '../../shared/workspace-scope' import type { RuntimeStore } from './runtime-store-contract' +import { worktreeRowTarget } from '../persistence/host-qualified-worktree-meta' type LineageParent = | { @@ -37,7 +38,7 @@ export type WorktreeLineageRecordingResolution = export function recordCreatedWorktreeLineage( store: RuntimeStore | null, - worktree: Pick, + worktree: Pick, resolution: WorktreeLineageRecordingResolution ): { lineage: WorktreeLineage | null @@ -54,29 +55,43 @@ export function recordCreatedWorktreeLineage( const childInstanceId = worktree.instanceId const parentInstanceId = resolution.parent.instanceId const createdAt = Date.now() + const target = store ? worktreeRowTarget(store, worktree) : undefined if ( resolution.parent.type === 'worktree' && childInstanceId && parentInstanceId && + target && store?.setWorktreeLineage ) { - lineage = store.setWorktreeLineage(worktree.id, { - worktreeId: worktree.id, - worktreeInstanceId: childInstanceId, - parentWorktreeId: resolution.parent.worktree.id, - parentWorktreeInstanceId: parentInstanceId, - origin: resolution.origin, - capture: resolution.capture, - ...(resolution.orchestrationRunId - ? { orchestrationRunId: resolution.orchestrationRunId } - : {}), - ...(resolution.taskId ? { taskId: resolution.taskId } : {}), - ...(resolution.coordinatorHandle ? { coordinatorHandle: resolution.coordinatorHandle } : {}), - ...(resolution.createdByTerminalHandle - ? { createdByTerminalHandle: resolution.createdByTerminalHandle } - : {}), - createdAt - }) + lineage = + store.setWorktreeLineage(target, { + worktreeId: worktree.id, + worktreeInstanceId: childInstanceId, + parentWorktreeId: resolution.parent.worktree.id, + parentWorktreeInstanceId: parentInstanceId, + origin: resolution.origin, + capture: resolution.capture, + ...(resolution.orchestrationRunId + ? { orchestrationRunId: resolution.orchestrationRunId } + : {}), + ...(resolution.taskId ? { taskId: resolution.taskId } : {}), + ...(resolution.coordinatorHandle + ? { coordinatorHandle: resolution.coordinatorHandle } + : {}), + ...(resolution.createdByTerminalHandle + ? { createdByTerminalHandle: resolution.createdByTerminalHandle } + : {}), + createdAt + }) ?? null + if (!lineage) { + // The id's lineage belongs to a same-id twin on another host; its workspace row too. + warnings.push({ + code: 'LINEAGE_PARENT_CONTEXT_CONFLICT', + message: + 'Worktree created, but its lineage is held by a workspace at the same path on another host.' + }) + return { lineage, workspaceLineage, warnings } + } } else if (resolution.parent.type === 'worktree') { warnings.push({ code: 'LINEAGE_PARENT_CONTEXT_MISSING', diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-sort-stamps.test.ts b/src/main/runtime/sidebar-inputs/orca-runtime-sort-stamps.test.ts index 298cc45fa4e..8b08bf07a0b 100644 --- a/src/main/runtime/sidebar-inputs/orca-runtime-sort-stamps.test.ts +++ b/src/main/runtime/sidebar-inputs/orca-runtime-sort-stamps.test.ts @@ -5,6 +5,7 @@ import type { WorktreeMeta } from '../../../shared/worktree/meta-types' import { AgentHookServer } from '../../agent-hooks/server' import { OrcaRuntimeService } from '../orca-runtime' import type { RuntimeStore } from '../runtime-store-contract' +import type { HostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' vi.mock('../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true })) @@ -17,12 +18,13 @@ function createHeadlessRuntime( [WT]: stored } const store = { - getRepos: () => [], + // An unstamped row's writes resolve their host from its repo's only registration. + getRepos: () => [{ id: 'repo-1', connectionId: null }], getRepo: (id: string) => (id === 'repo-1' ? { id, connectionId: null } : undefined), getSettings: () => ({}), getWorktreeMeta: (id: string) => meta[id], getAllWorktreeMeta: () => meta, - setWorktreeMeta: (id: string, updates: Partial) => { + setWorktreeMeta: ({ id }: HostWorktreeTarget, updates: Partial) => { meta[id] = { ...meta[id], ...updates } return meta[id] } diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-terminal-use.test.ts b/src/main/runtime/sidebar-inputs/orca-runtime-terminal-use.test.ts index 6c2d56d32a5..d6780999f9f 100644 --- a/src/main/runtime/sidebar-inputs/orca-runtime-terminal-use.test.ts +++ b/src/main/runtime/sidebar-inputs/orca-runtime-terminal-use.test.ts @@ -10,6 +10,7 @@ import { import { OrcaRuntimeService } from '../orca-runtime' import type { RuntimeStore } from '../runtime-store-contract' import { TERMINAL_USE_THROTTLE_MS } from './terminal-use-producer' +import type { HostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' vi.mock('../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true })) @@ -21,12 +22,13 @@ function createHeadlessRuntime(ptyId = 'pty-1') { const meta: Record> = { [WT]: { lastActivityAt: 1 } } const writes: Partial[] = [] const store = { - getRepos: () => [], + // An unstamped row's writes resolve their host from its repo's only registration. + getRepos: () => [{ id: 'repo-1', connectionId: null }], getRepo: (id: string) => (id === 'repo-1' ? { id, connectionId: null } : undefined), getSettings: () => ({}), getWorktreeMeta: (id: string) => meta[id], getAllWorktreeMeta: () => meta, - setWorktreeMeta: (id: string, updates: Partial) => { + setWorktreeMeta: ({ id }: HostWorktreeTarget, updates: Partial) => { writes.push(updates) meta[id] = { ...meta[id], ...updates } return meta[id] diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-workspace-unread.test.ts b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-unread.test.ts index 5042dcbedf3..473673ad28f 100644 --- a/src/main/runtime/sidebar-inputs/orca-runtime-workspace-unread.test.ts +++ b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-unread.test.ts @@ -19,6 +19,7 @@ import { BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { HostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' vi.mock('../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true })) @@ -66,11 +67,10 @@ function createHeadlessHost() { getSettings: () => ({}), getWorktreeMeta: (id: string) => meta[id], getAllWorktreeMeta: () => meta, - setWorktreeMeta: (id: string, updates: Partial) => Object.assign(meta[id], updates), + setWorktreeMeta: ({ id, hostId }: HostWorktreeTarget, updates: Partial) => + Object.assign(hostId === 'ssh:build-box' ? sshMeta[id] : meta[id], updates), getWorktreeMetaForHost: (id: string, hostId: string) => hostId === 'ssh:build-box' ? sshMeta[id] : undefined, - setWorktreeMetaForHost: (id: string, _hostId: string, updates: Partial) => - Object.assign(sshMeta[id], updates), getFolderWorkspace: (id: string) => folders[id], updateFolderWorkspace: (id: string, updates: Partial) => Object.assign(folders[id], updates) diff --git a/src/main/runtime/sidebar-inputs/sort-stamp-records.ts b/src/main/runtime/sidebar-inputs/sort-stamp-records.ts index 28fc87d964c..cae0c1dfa95 100644 --- a/src/main/runtime/sidebar-inputs/sort-stamp-records.ts +++ b/src/main/runtime/sidebar-inputs/sort-stamp-records.ts @@ -6,8 +6,18 @@ import type { SortStamps } from '../../../shared/sidebar-list/sort-stamps' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope' +import type { Repo } from '../../../shared/repo-types' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../../shared/worktree/host-qualified-identity' +import { resolveWorktreeMetaOwner } from '../../persistence/host-qualified-worktree-meta' -type StampedRecord = { lastActivityAt: number; isUnread?: boolean } & SmartStampFields +type StampedRecord = { + lastActivityAt: number + isUnread?: boolean + hostId?: ExecutionHostId +} & SmartStampFields type StampUpdates = { lastActivityAt: number smartTier: SmartTier | undefined @@ -16,14 +26,10 @@ type StampUpdates = { /** The per-workspace record reads and writes, which the runtime's store satisfies. */ export type WorkspaceRecordStore = { - getWorktreeMeta: (worktreeId: string) => TRecord | undefined - setWorktreeMeta: (worktreeId: string, updates: TUpdates) => unknown + getWorktreeMeta: (worktreeId: string) => (TRecord & { hostId?: ExecutionHostId }) | undefined + setWorktreeMeta: (target: HostWorktreeTarget, updates: TUpdates) => unknown + getRepos: () => readonly Repo[] getWorktreeMetaForHost?: (worktreeId: string, hostId: ExecutionHostId) => TRecord | undefined - setWorktreeMetaForHost?: ( - worktreeId: string, - hostId: ExecutionHostId, - updates: TUpdates - ) => unknown getFolderWorkspace?: (folderWorkspaceId: string) => TRecord | undefined updateFolderWorkspace?: (folderWorkspaceId: string, updates: TUpdates) => unknown } @@ -57,10 +63,14 @@ export function writeWorkspaceRecord( const scope = parseWorkspaceKey(worktreeId) if (scope?.type === 'folder') { store?.updateFolderWorkspace?.(scope.folderWorkspaceId, updates) - } else if (sshHostId) { - store?.setWorktreeMetaForHost?.(worktreeId, sshHostId, updates) - } else { - store?.setWorktreeMeta(worktreeId, updates) + return + } + // No SSH host: the row the read above found, by its own stamp. + const target = sshHostId + ? hostWorktreeTarget({ id: worktreeId, hostId: sshHostId }) + : store && resolveWorktreeMetaOwner(store, worktreeId) + if (target) { + store?.setWorktreeMeta(target, updates) } } diff --git a/src/main/runtime/sidebar-inputs/sort-stamps-producer.test.ts b/src/main/runtime/sidebar-inputs/sort-stamps-producer.test.ts index 79aebdd7aae..80916fe41df 100644 --- a/src/main/runtime/sidebar-inputs/sort-stamps-producer.test.ts +++ b/src/main/runtime/sidebar-inputs/sort-stamps-producer.test.ts @@ -5,6 +5,8 @@ import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wi import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject' import type { RuntimeClientEvent } from '../../../shared/runtime-client-events' import type { TerminalSideEffectFact } from '../../../shared/terminal-side-effect-facts' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { Repo } from '../../../shared/repo-types' import { RECENT_STAMP_THROTTLE_MS, type SmartStampFields @@ -32,9 +34,16 @@ const T0 = 1_000_000 type Stamps = { lastActivityAt: number; isUnread?: boolean } & SmartStampFields const SSH_HOST = 'ssh:build-box' +const LOCAL_REPO: Repo = { + id: 'repo-1', + path: '/tmp', + displayName: 'repo-1', + badgeColor: '#000', + addedAt: 0 +} function createHarness() { - const meta = new Map([[WT, { lastActivityAt: 0 }]]) + const meta = new Map([[WT, { lastActivityAt: 0 }]]) const sshMeta = new Map([[WT, { lastActivityAt: 0 }]]) const folders = new Map([ [FOLDER_ID, { lastActivityAt: 0 }] @@ -48,15 +57,17 @@ function createHarness() { const viewing = { isViewing: (_id: string, _pane: string | null): boolean => true } const store: SortStampsStore = { getWorktreeMeta: (id) => meta.get(id), - setWorktreeMeta: (id, updates) => { + setWorktreeMeta: ({ id, hostId }, updates) => { + if (hostId === SSH_HOST) { + writes.push(`${hostId}|${id}`) + sshMeta.set(id, { lastActivityAt: 0, ...sshMeta.get(id), ...updates }) + return + } writes.push(id) meta.set(id, { lastActivityAt: 0, ...meta.get(id), ...updates }) }, + getRepos: () => [LOCAL_REPO], getWorktreeMetaForHost: (id, hostId) => (hostId === SSH_HOST ? sshMeta.get(id) : undefined), - setWorktreeMetaForHost: (id, hostId, updates) => { - writes.push(`${hostId}|${id}`) - sshMeta.set(id, { lastActivityAt: 0, ...sshMeta.get(id), ...updates }) - }, getFolderWorkspace: (id) => folders.get(id), updateFolderWorkspace: (id, updates) => { writes.push(`folder:${id}`) diff --git a/src/main/runtime/sidebar-inputs/terminal-use-producer.test.ts b/src/main/runtime/sidebar-inputs/terminal-use-producer.test.ts index 76d298e0a32..ac6c54b6511 100644 --- a/src/main/runtime/sidebar-inputs/terminal-use-producer.test.ts +++ b/src/main/runtime/sidebar-inputs/terminal-use-producer.test.ts @@ -8,9 +8,11 @@ function harness(workspace: AgentPaneWorkspace | null, fenced = false) { const producer = new TerminalUseProducer({ store: () => ({ getWorktreeMeta: (id) => (records.has(id) ? {} : undefined), - setWorktreeMeta: (id, u) => writes.push(`local ${id} ${u.lastTerminalUseAt}`), + setWorktreeMeta: ({ id, hostId }, u) => writes.push(`${hostId} ${id} ${u.lastTerminalUseAt}`), + getRepos: () => [ + { id: 'repo', path: '/', displayName: 'repo', badgeColor: '#000', addedAt: 0 } + ], getWorktreeMetaForHost: (id, host) => (records.has(`${host}|${id}`) ? {} : undefined), - setWorktreeMetaForHost: (id, host, u) => writes.push(`${host} ${id} ${u.lastTerminalUseAt}`), getFolderWorkspace: (id) => (records.has(id) ? {} : undefined), updateFolderWorkspace: (id, u) => writes.push(`folder ${id} ${u.lastTerminalUseAt}`) }), diff --git a/src/main/runtime/workspaces/workspace-activation.ts b/src/main/runtime/workspaces/workspace-activation.ts index ef33b88bb19..1a519ce1a35 100644 --- a/src/main/runtime/workspaces/workspace-activation.ts +++ b/src/main/runtime/workspaces/workspace-activation.ts @@ -46,6 +46,7 @@ import { readLaunchedAgentForeground, type LaunchedAgentForeground } from '../launched-agent-foreground' +import { worktreeRowTarget } from '../../persistence/host-qualified-worktree-meta' export abstract class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListManagedWorktrees { protected abstract toAgentSessionOptions( @@ -82,10 +83,15 @@ export abstract class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntime const targetsHost = navigationTargetsHost(navigation) const targetsClients = navigationTargetsClients(navigation) - if (!targetsHost && this.store?.getWorktreeMeta(worktree.id)?.isUnread) { + const target = this.store ? worktreeRowTarget(this.store, worktree) : undefined + if ( + !targetsHost && + target && + this.store?.getWorktreeMetaForHost?.(target.id, target.hostId)?.isUnread + ) { // Why: mobile/web session activation intentionally bypasses renderer // selection, so the runtime must acknowledge the unread state itself. - this.store.setWorktreeMeta(worktree.id, { isUnread: false }) + this.store.setWorktreeMeta(target, { isUnread: false }) this.notifyWorktreesChanged(repo.id) } diff --git a/src/main/runtime/workspaces/workspace-query-prototype.test.ts b/src/main/runtime/workspaces/workspace-query-prototype.test.ts index 00d71ceee8d..3a41f8cb35d 100644 --- a/src/main/runtime/workspaces/workspace-query-prototype.test.ts +++ b/src/main/runtime/workspaces/workspace-query-prototype.test.ts @@ -460,6 +460,7 @@ describe('first-work rename lazy Store capabilities', () => { branchPrefixCustom: '' }), getRepo: () => undefined, + getRepos: () => [], getWorktreeMeta: () => undefined, setWorktreeMeta: vi.fn() } diff --git a/src/main/runtime/workspaces/workspace-removal-target.ts b/src/main/runtime/workspaces/workspace-removal-target.ts index 3ee661ff5ef..e3fd2d094a0 100644 --- a/src/main/runtime/workspaces/workspace-removal-target.ts +++ b/src/main/runtime/workspaces/workspace-removal-target.ts @@ -42,6 +42,7 @@ import { resolveAgentTerminalCreateOptions, withHostAccountLaunchEnv } from '../runtime-agent-terminal-startup' +import { removeWorktreeMetaRow } from '../../persistence/host-qualified-worktree-meta' export abstract class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWithRemoveManagedWorktree { protected abstract resolveExplicitWorktreeIdScoped( @@ -158,11 +159,7 @@ export abstract class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRu ) const acceptedRendererSnapshot = this.acceptedRendererMobileSnapshotByWorktree.get(worktreeId) const storedSnapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - if (hostId) { - store.removeWorktreeMeta(worktreeId, hostId) - } else { - store.removeWorktreeMeta(worktreeId) - } + removeWorktreeMetaRow(store, worktreeId, hostId) // Why outside the same-id gate: retirement is per host and per pane, so a surviving owner keeps its own. this.dropAgentStatusForRemovedWorktreeFn?.(worktreeId, hostId ?? persistedHostId) if (!preservesSameIdOwner) { diff --git a/src/main/runtime/worktree-ps-degraded-repo-scan.test.ts b/src/main/runtime/worktree-ps-degraded-repo-scan.test.ts index 5f930d65462..09d4761a467 100644 --- a/src/main/runtime/worktree-ps-degraded-repo-scan.test.ts +++ b/src/main/runtime/worktree-ps-degraded-repo-scan.test.ts @@ -23,6 +23,10 @@ vi.mock('../git/worktree', async (importOriginal) => ({ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { OrcaRuntimeService } from './orca-runtime' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../../shared/worktree/host-qualified-identity' const REPO_ID = 'repo-remote' const REPO_PATH = '/home/user/projects/app' @@ -72,7 +76,6 @@ type StoreOptions = { connectionId?: string metaById?: Record> removeWorktreeLineage?: ReturnType - removeWorkspaceLineage?: ReturnType /** Extra `getRepos()` rows appended after the primary one, for same-id-on-two-hosts coverage. */ coHostedRepos?: { id: string; path: string; connectionId?: string }[] } @@ -103,7 +106,7 @@ function makeStore(options: StoreOptions = {}) { ], getAllWorktreeMeta: vi.fn(() => metaById), getWorktreeMeta: (id: string) => metaById[id], - setWorktreeMeta: (id: string, meta: Record) => { + setWorktreeMeta: ({ id }: HostWorktreeTarget, meta: Record) => { metaById[id] = { ...(metaById[id] ?? makeMeta()), ...meta } as never return metaById[id] }, @@ -111,7 +114,6 @@ function makeStore(options: StoreOptions = {}) { getAllWorktreeLineage: () => lineageById, getAllWorkspaceLineage: () => ({ [`worktree:${WORKTREE_ID}`]: { parentWorkspaceKey: null } }), removeWorktreeLineage: options.removeWorktreeLineage ?? vi.fn(), - removeWorkspaceLineage: options.removeWorkspaceLineage ?? vi.fn(), getGitHubCache: () => undefined as never, getSettings: () => ({ workspaceDir: '/tmp/workspaces', @@ -310,35 +312,31 @@ describe('worktree.ps on a degraded repo scan', () => { it('drops and prunes a worktree that a healthy scan no longer reports', async () => { const removeWorktreeLineage = vi.fn() - const removeWorkspaceLineage = vi.fn() listWorktreesStrictMock.mockResolvedValue([ { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true } ]) - const runtime = new OrcaRuntimeService( - makeStore({ removeWorktreeLineage, removeWorkspaceLineage }) as never - ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial store mock; the code under test reads only the members it defines. + const runtime = new OrcaRuntimeService(makeStore({ removeWorktreeLineage }) as never) const result = await runtime.getWorktreePs(10_000) expect(result.worktrees.map((worktree) => worktree.worktreeId)).not.toContain(WORKTREE_ID) - expect(removeWorktreeLineage).toHaveBeenCalledWith(WORKTREE_ID) - expect(removeWorkspaceLineage).toHaveBeenCalledWith(`worktree:${WORKTREE_ID}`) + expect(removeWorktreeLineage).toHaveBeenCalledWith( + hostWorktreeTarget({ id: WORKTREE_ID, hostId: 'local' }) + ) }) it('does not prune lineage while a scan is stalled', async () => { vi.useFakeTimers() try { const removeWorktreeLineage = vi.fn() - const removeWorkspaceLineage = vi.fn() listWorktreesStrictMock.mockImplementation(neverSettles) - const runtime = new OrcaRuntimeService( - makeStore({ removeWorktreeLineage, removeWorkspaceLineage }) as never - ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial store mock; the code under test reads only the members it defines. + const runtime = new OrcaRuntimeService(makeStore({ removeWorktreeLineage }) as never) await advancePastRepoScanBudget(runtime.getWorktreePs(10_000)) expect(removeWorktreeLineage).not.toHaveBeenCalled() - expect(removeWorkspaceLineage).not.toHaveBeenCalled() } finally { vi.useRealTimers() } diff --git a/src/main/runtime/worktree-ps-host-scope.test.ts b/src/main/runtime/worktree-ps-host-scope.test.ts index 22174a50367..9bbcfa070e5 100644 --- a/src/main/runtime/worktree-ps-host-scope.test.ts +++ b/src/main/runtime/worktree-ps-host-scope.test.ts @@ -22,6 +22,7 @@ vi.mock('../git/worktree', async (importOriginal) => ({ })) import { OrcaRuntimeService } from './orca-runtime' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' const LOCAL_REPO_ID = 'repo-local' const LOCAL_REPO_PATH = '/Users/me/dev/app' @@ -68,7 +69,7 @@ function makeStore() { ], getAllWorktreeMeta: () => metaById, getWorktreeMeta: (id: string) => metaById[id], - setWorktreeMeta: (id: string, meta: Record) => { + setWorktreeMeta: ({ id }: HostWorktreeTarget, meta: Record) => { metaById[id] = { ...(metaById[id] as object), ...meta } return metaById[id] }, diff --git a/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts b/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts index 751a5b4535b..220fb30accc 100644 --- a/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts +++ b/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts @@ -28,6 +28,7 @@ vi.mock('../git/worktree', async (importOriginal) => ({ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { OrcaRuntimeService } from './orca-runtime' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' const REPO_ID = 'repo-local' const REPO_PATH = '/srv/projects/app' @@ -60,7 +61,7 @@ function makeStore(repoPath: string = REPO_PATH) { ], getAllWorktreeMeta: vi.fn(() => metaById), getWorktreeMeta: (id: string) => metaById[id], - setWorktreeMeta: (id: string, meta: Record) => { + setWorktreeMeta: ({ id }: HostWorktreeTarget, meta: Record) => { metaById[id] = { ...metaById[id], ...meta } return metaById[id] }, diff --git a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts index b41d476b8a3..3f10f7cb4ba 100644 --- a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts +++ b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts @@ -36,6 +36,7 @@ import { } from './orca-runtime' import { RESOLVED_WORKTREE_REPO_TIMEOUT_MS } from './repo-worktree-row-resolution' import { canonicalWorktreeIdentity } from '../../shared/worktree/identity' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' const REPO_ID = 'repo-local' const REPO_PATH = '/Users/me/dev/app' @@ -92,7 +93,7 @@ function makeStore(options: { connectionId?: string; repoCount?: number; repoPat getRepos: () => repos, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (id: string) => metaById[id], - setWorktreeMeta: (id: string, meta: Record) => { + setWorktreeMeta: ({ id }: HostWorktreeTarget, meta: Record) => { metaById[id] = { ...(metaById[id] ?? makeMeta()), ...meta } as never return metaById[id] }, diff --git a/src/main/runtime/worktree-scan-execution-host-routing.test.ts b/src/main/runtime/worktree-scan-execution-host-routing.test.ts index 237a6b80e02..22c0affd221 100644 --- a/src/main/runtime/worktree-scan-execution-host-routing.test.ts +++ b/src/main/runtime/worktree-scan-execution-host-routing.test.ts @@ -29,6 +29,7 @@ vi.mock('./repo-worktree-admin-fingerprint', () => ({ })) import { OrcaRuntimeService } from './orca-runtime' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' const TARGET_ID = 'remote-1' const REPO_ID = 'repo-remote' @@ -83,7 +84,7 @@ function makeStore(repoOverrides: Record) { getRepos: () => repos, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (id: string) => metaById[id], - setWorktreeMeta: (id: string, meta: Record) => { + setWorktreeMeta: ({ id }: HostWorktreeTarget, meta: Record) => { metaById[id] = { ...(metaById[id] ?? makeMeta()), ...meta } as never return metaById[id] }, diff --git a/src/main/runtime/worktree-scan-repeated-path.test.ts b/src/main/runtime/worktree-scan-repeated-path.test.ts index 8934f52fa41..3f977f79452 100644 --- a/src/main/runtime/worktree-scan-repeated-path.test.ts +++ b/src/main/runtime/worktree-scan-repeated-path.test.ts @@ -27,6 +27,7 @@ vi.mock('./repo-worktree-admin-fingerprint', () => ({ import { OrcaRuntimeService } from './orca-runtime' import type { GitWorktreeInfo } from '../../shared/worktree/types' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' const REPO_ID = 'repo-local' const REPO_PATH = '/home/me/fileLoc' @@ -64,7 +65,7 @@ function makeRuntime(options: { connectionId?: string } = {}): OrcaRuntimeServic getRepos: () => repos, getAllWorktreeMeta: () => metaById, getWorktreeMeta: (id: string) => metaById[id], - setWorktreeMeta: (id: string, meta: Record) => { + setWorktreeMeta: ({ id }: HostWorktreeTarget, meta: Record) => { metaById[id] = { ...metaById[id], ...meta } return metaById[id] }, diff --git a/src/main/ssh/orcad-migration-repository-link.test.ts b/src/main/ssh/orcad-migration-repository-link.test.ts index 2aaf2cd7fe9..b52e6c9210b 100644 --- a/src/main/ssh/orcad-migration-repository-link.test.ts +++ b/src/main/ssh/orcad-migration-repository-link.test.ts @@ -11,6 +11,8 @@ import { } from '../../shared/runtime-environment-managed-orcad-store' import { listEnvironments } from '../../shared/runtime-environment-store' import type { Repo } from '../../shared/repo-types' +import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host' +import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' import type { SshTarget } from '../../shared/ssh-types' import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' import { Store } from '../persistence/loading-store/store' @@ -61,6 +63,9 @@ const TARGET: SshTarget = { username: 'root', generation: 2 } +const onSsh = (id: string, targetId = TARGET.id) => + hostWorktreeTarget({ id, hostId: toSshExecutionHostId(targetId) }) +const onServer = (id: string) => hostWorktreeTarget({ id, hostId: LOCAL_EXECUTION_HOST_ID }) function repo(id: string, path: string, connectionId: string | null = TARGET.id): Repo { return { @@ -246,8 +251,8 @@ describe('moving onto a server another desktop already added the same project to it('leaves the linked project’s saved state to the server, moving only the rest', async () => { otherDesktopAddsSameCheckout() - store.setWorktreeMeta('desktop-repo::/root/migrepo', { comment: 'from this desktop' }) - store.setWorktreeMeta('desktop-only::/root/tool', { comment: 'moves' }) + store.setWorktreeMeta(onSsh('desktop-repo::/root/migrepo'), { comment: 'from this desktop' }) + store.setWorktreeMeta(onSsh('desktop-only::/root/tool'), { comment: 'moves' }) await expect(convert()).resolves.toMatchObject({ outcome: 'converted' }) expect(server.getWorktreeMeta('desktop-only::/root/tool')?.comment).toBe('moves') expect(server.getWorktreeMeta('desktop-repo::/root/migrepo')).toBeUndefined() @@ -365,10 +370,14 @@ describe('moving the host again after its server was stopped', () => { } it('links the projects the server kept from the first move instead of conflicting', async () => { - store.setWorktreeMeta('desktop-repo::/root/migrepo', { comment: 'before the first move' }) + store.setWorktreeMeta(onSsh('desktop-repo::/root/migrepo'), { + comment: 'before the first move' + }) await expect(convert()).resolves.toMatchObject({ outcome: 'converted' }) // Used on the server while it was managed. - server.setWorktreeMeta('desktop-repo::/root/migrepo', { comment: 'edited on the server' }) + server.setWorktreeMeta(onServer('desktop-repo::/root/migrepo'), { + comment: 'edited on the server' + }) stopServer() await expect(convert()).resolves.toMatchObject({ @@ -664,13 +673,19 @@ describe('moving the host again after its SSH target was deleted and re-created' } it('links the projects and saved state the server kept under the old target', async () => { - store.setWorktreeMeta('desktop-repo::/root/migrepo', { comment: 'before the first move' }) + store.setWorktreeMeta(onSsh('desktop-repo::/root/migrepo'), { + comment: 'before the first move' + }) await expect(convert()).resolves.toMatchObject({ outcome: 'converted' }) - server.setWorktreeMeta('desktop-repo::/root/migrepo', { comment: 'edited on the server' }) + server.setWorktreeMeta(onServer('desktop-repo::/root/migrepo'), { + comment: 'edited on the server' + }) const recreated = recreateTarget() expect(store.getRepo('desktop-repo')?.connectionId).toBe(recreated) // Used on this desktop under the new target before it was moved again. - store.setWorktreeMeta('desktop-repo::/root/migrepo', { comment: 'edited after re-creating' }) + store.setWorktreeMeta(onSsh('desktop-repo::/root/migrepo', recreated), { + comment: 'edited after re-creating' + }) await expect(convert(true, 'exited', recreated)).resolves.toMatchObject({ outcome: 'converted' diff --git a/src/main/worktree-identity-persistence.test.ts b/src/main/worktree-identity-persistence.test.ts index 0b6dd178e84..2be4fcc0406 100644 --- a/src/main/worktree-identity-persistence.test.ts +++ b/src/main/worktree-identity-persistence.test.ts @@ -4,7 +4,10 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import type { PersistedState } from '../shared/persisted-state-types' import { canonicalWorktreeIdentity } from '../shared/worktree/identity' -import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity' +import { + composeWorktreeHostIdentity, + hostWorktreeTarget +} from '../shared/worktree/host-qualified-identity' import type { Store } from './persistence/loading-store/store' import { createStore, @@ -13,6 +16,7 @@ import { testState, writeDataFile } from './persistence-test-harness' +import { removeWorktreeMetaRow } from './persistence/host-qualified-worktree-meta' describe('host-qualified worktree metadata', () => { const worktreeId = 'repo-1::/workspace/feature' @@ -36,8 +40,12 @@ describe('host-qualified worktree metadata', () => { it('stores independent metadata for colliding locators on two hosts', () => { const store = createStore() - store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) - store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + displayName: 'Local feature' + }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:build-box' }), { + displayName: 'Remote feature' + }) expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.displayName).toBe('Local feature') expect(store.getWorktreeMetaForHost(worktreeId, 'ssh:build-box')?.displayName).toBe( @@ -50,8 +58,10 @@ describe('host-qualified worktree metadata', () => { }) it('projects canonical-only metadata for exactly one host', () => { const store = createStore() - store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) - store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + displayName: 'Local feature' + }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:build-box' }), { displayName: 'Remote feature' }) @@ -67,8 +77,12 @@ describe('host-qualified worktree metadata', () => { }) it('reloads host-specific metadata without collapsing it to the legacy locator', () => { const store = createStoreWithRepo() - store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) - store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + displayName: 'Local feature' + }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:build-box' }), { + displayName: 'Remote feature' + }) store.flush() const reloaded = createStore() @@ -79,15 +93,21 @@ describe('host-qualified worktree metadata', () => { }) it('keeps canonical metadata current for legacy writers on a known owner', () => { const store = createStore() - store.setWorktreeMetaForHost(worktreeId, 'local', { comment: 'before' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + comment: 'before' + }) - store.setWorktreeMeta(worktreeId, { comment: 'after' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + comment: 'after' + }) expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.comment).toBe('after') }) it('backfills one stable instance for legacy metadata that omitted it', () => { const seed = createStoreWithRepo() - seed.setWorktreeMeta(worktreeId, { displayName: 'Legacy feature' }) + seed.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + displayName: 'Legacy feature' + }) seed.flush() const legacy = readDataFile() as PersistedState delete legacy.worktreeMeta[worktreeId]?.instanceId @@ -98,7 +118,9 @@ describe('host-qualified worktree metadata', () => { const store = createStore() const first = store.getWorktreeMetaForHost(worktreeId, 'local') const second = store.getWorktreeMetaForHost(worktreeId, 'local') - store.setWorktreeMeta(worktreeId, { comment: 'after migration' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + comment: 'after migration' + }) store.flush() expect(first?.instanceId).toBeTruthy() @@ -112,7 +134,9 @@ describe('host-qualified worktree metadata', () => { // listing loop, taking every workspace in the repo down with it and never self-healing. it('collapses an ambiguous locator onto its most recently active instance', () => { const seed = createStoreWithRepo() - const first = seed.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'First' }) + const first = seed.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + displayName: 'First' + }) seed.flush() const persisted = readDataFile() as PersistedState const alias = composeWorktreeHostIdentity('local', worktreeId) @@ -137,7 +161,9 @@ describe('host-qualified worktree metadata', () => { const store = createStore() expect(() => - store.setWorktreeMetaForHost(worktreeId, 'local', { comment: 'ambiguous write' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + comment: 'ambiguous write' + }) ).toThrow('Worktree identity is ambiguous for this host and locator.') const repaired = createStore() @@ -153,10 +179,14 @@ describe('host-qualified worktree metadata', () => { it('removes only the selected legacy-owner metadata when locators collide', () => { const store = createStore() - store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) - store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + displayName: 'Local feature' + }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:build-box' }), { + displayName: 'Remote feature' + }) - store.removeWorktreeMeta(worktreeId, 'local') + store.removeWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' })) expect(store.getWorktreeMetaForHost(worktreeId, 'local')).toBeUndefined() expect(store.getWorktreeMetaForHost(worktreeId, 'ssh:build-box')?.displayName).toBe( @@ -166,10 +196,14 @@ describe('host-qualified worktree metadata', () => { it('removes only the selected host metadata when locators collide', () => { const store = createStore() - store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) - store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + displayName: 'Local feature' + }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:build-box' }), { + displayName: 'Remote feature' + }) - store.removeWorktreeMeta(worktreeId, 'ssh:build-box') + store.removeWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:build-box' })) expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.displayName).toBe('Local feature') expect(store.getWorktreeMetaForHost(worktreeId, 'ssh:build-box')).toBeUndefined() @@ -181,7 +215,9 @@ describe('host-qualified worktree metadata', () => { it('moves the locator alias without changing canonical identity', () => { const store = createStore() - const meta = store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Feature' }) + const meta = store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + displayName: 'Feature' + }) const identityKey = canonicalWorktreeIdentity({ worktreeId, executionHostId: 'local', @@ -189,7 +225,10 @@ describe('host-qualified worktree metadata', () => { }) const renamedId = 'repo-1::/workspace/renamed-feature' - store.migrateWorktreeIdentity(worktreeId, renamedId) + store.migrateWorktreeIdentity( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), + renamedId + ) expect(store.getWorktreeMetaForHost(worktreeId, 'local')).toBeUndefined() expect(store.getWorktreeMetaForHost(renamedId, 'local')?.instanceId).toBe(meta.instanceId) @@ -203,7 +242,7 @@ describe('host-qualified worktree metadata', () => { it('keeps the canonical identity stable when the locator changes', () => { const store = createStore() - const meta = store.setWorktreeMetaForHost(worktreeId, 'local', { + const meta = store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { displayName: 'Feature' }) @@ -225,11 +264,15 @@ describe('host-qualified worktree metadata', () => { }) it('honours a deliberate instanceId rotation instead of pinning the stored one', () => { const store = createStore() - store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Feature' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + displayName: 'Feature' + }) // worktree-lineage-pruning rotates a proven-missing parent so path reuse cannot // validate old lineage; pinning the existing id silently disarmed that guard. - const rotated = store.setWorktreeMeta(worktreeId, { instanceId: ROTATED_INSTANCE_ID }) + const rotated = store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + instanceId: ROTATED_INSTANCE_ID + }) expect(rotated.instanceId).toBe(ROTATED_INSTANCE_ID) expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.instanceId).toBe(ROTATED_INSTANCE_ID) @@ -247,11 +290,18 @@ describe('host-qualified worktree metadata', () => { it('leaves other hosts at the old locator when one host renames its folder', () => { const store = createStore() - store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) - store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + displayName: 'Local feature' + }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:build-box' }), { + displayName: 'Remote feature' + }) const renamedId = 'repo-1::/workspace/renamed-feature' - store.migrateWorktreeIdentity(worktreeId, renamedId, 'local') + store.migrateWorktreeIdentity( + hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), + renamedId + ) expect(store.getWorktreeMetaForHost(renamedId, 'local')?.displayName).toBe('Local feature') // The SSH host never moved, so its row must stay reachable at the path it still has. @@ -260,12 +310,22 @@ describe('host-qualified worktree metadata', () => { ) }) - it('drops every host identity row when a locator is removed outright', () => { + // A stamped locator has no host-blind removal: each host drops its own copy. + it('drops every host identity row once each host removes its copy', () => { const store = createStore() - store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) - store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'local' }), { + displayName: 'Local feature' + }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:build-box' }), { + displayName: 'Remote feature' + }) - store.removeWorktreeMeta(worktreeId) + expect(store.removeUnhostedWorktreeMeta(worktreeId)).toBe(false) + removeWorktreeMetaRow(store, worktreeId, undefined) + expect(store.getWorktreeMetaForHost(worktreeId, 'ssh:build-box')?.displayName).toBe( + 'Remote feature' + ) + store.removeWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:build-box' })) store.flush() const persisted = readDataFile() as PersistedState @@ -277,7 +337,9 @@ describe('host-qualified worktree metadata', () => { const oldHostId = 'ssh:old-target' as const const newHostId = 'ssh:new-target' as const const seed = createStoreWithRepo() - seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' }) + seed.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: oldHostId }), { + displayName: 'Remote feature' + }) seed.flush() const persisted = readDataFile() as PersistedState const oldAlias = composeWorktreeHostIdentity(oldHostId, worktreeId) @@ -307,8 +369,10 @@ describe('host-qualified worktree metadata', () => { const oldHostId = 'ssh:old-target' as const const newHostId = 'ssh:new-target' as const const store = createStore() - store.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Source feature' }) - store.setWorktreeMetaForHost(worktreeId, newHostId, { + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: oldHostId }), { + displayName: 'Source feature' + }) + store.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: newHostId }), { displayName: 'Destination feature' }) @@ -333,7 +397,9 @@ describe('host-qualified worktree metadata', () => { const oldHostId = 'ssh:old-target' as const const newHostId = 'ssh:new-target' as const const seed = createStoreWithRepo() - seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' }) + seed.setWorktreeMeta(hostWorktreeTarget({ id: worktreeId, hostId: oldHostId }), { + displayName: 'Remote feature' + }) seed.flush() const persisted = readDataFile() as PersistedState const oldAlias = composeWorktreeHostIdentity(oldHostId, worktreeId) diff --git a/src/main/worktree-lineage-pruning.test.ts b/src/main/worktree-lineage-pruning.test.ts index 22b34f70104..f3acc6787b6 100644 --- a/src/main/worktree-lineage-pruning.test.ts +++ b/src/main/worktree-lineage-pruning.test.ts @@ -4,6 +4,10 @@ import type { WorkspaceLineage, WorktreeLineage } from '../shared/worktree/linea import type { WorktreeMeta } from '../shared/worktree/meta-types' import { worktreeWorkspaceKey } from '../shared/workspace-scope' import { pruneLineageForMissingRepoWorktrees } from './worktree-lineage-pruning' +import { + hostWorktreeTarget, + type HostWorktreeTarget +} from '../shared/worktree/host-qualified-identity' const repo: Repo = { id: 'repo-1', @@ -47,9 +51,12 @@ function createStore( getAllWorktreeLineage: () => worktreeLineageById, getAllWorkspaceLineage: () => workspaceLineageByChildKey, getWorktreeMeta: (id: string) => metaById[id], - removeWorktreeLineage: vi.fn((id: string) => delete worktreeLineageById[id]), - removeWorkspaceLineage: vi.fn((key: string) => delete workspaceLineageByChildKey[key]), - setWorktreeMeta: vi.fn((id: string, updates: Partial) => { + // Mirrors the store: removing a worktree's lineage also drops its workspace lineage. + removeWorktreeLineage: vi.fn(({ id }: HostWorktreeTarget) => { + delete worktreeLineageById[id] + delete workspaceLineageByChildKey[worktreeWorkspaceKey(id)] + }), + setWorktreeMeta: vi.fn(({ id }: HostWorktreeTarget, updates: Partial) => { metaById[id] = { ...metaById[id], ...updates } return metaById[id] }) @@ -81,7 +88,6 @@ describe('pruneLineageForMissingRepoWorktrees', () => { ) expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - expect(store.removeWorkspaceLineage).not.toHaveBeenCalled() expect(store.setWorktreeMeta).not.toHaveBeenCalled() expect(worktreeLineageById[childId]).toBe(edge) }) @@ -126,7 +132,6 @@ describe('pruneLineageForMissingRepoWorktrees', () => { ) expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - expect(store.removeWorkspaceLineage).not.toHaveBeenCalled() expect(store.setWorktreeMeta).not.toHaveBeenCalled() expect(worktreeLineageById[childId]).toBe(edge) expect(workspaceLineageByChildKey[worktreeWorkspaceKey(childId)]).toBeDefined() @@ -148,7 +153,6 @@ describe('pruneLineageForMissingRepoWorktrees', () => { pruneLineageForMissingRepoWorktrees(store as never, repo, []) expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - expect(store.removeWorkspaceLineage).not.toHaveBeenCalled() expect(store.setWorktreeMeta).not.toHaveBeenCalled() expect(worktreeLineageById[childId]).toBe(edge) expect(workspaceLineageByChildKey[worktreeWorkspaceKey(childId)]).toBe(workspaceEdge) @@ -197,13 +201,24 @@ describe('pruneLineageForMissingRepoWorktrees', () => { { preservedMetadataCandidateIds: new Set([liveParentId, liveChildId]) } ) - expect(store.removeWorktreeLineage).toHaveBeenCalledWith(missingChildId) - expect(store.removeWorktreeLineage).not.toHaveBeenCalledWith(liveChildId) - expect(store.removeWorkspaceLineage).toHaveBeenCalledWith(worktreeWorkspaceKey(missingChildId)) - expect(store.setWorktreeMeta).toHaveBeenCalledWith(missingParentId, { - instanceId: expect.any(String) - }) - expect(store.setWorktreeMeta).not.toHaveBeenCalledWith(liveParentId, expect.anything()) + expect(store.removeWorktreeLineage).toHaveBeenCalledWith( + hostWorktreeTarget({ id: missingChildId, hostId: 'local' }) + ) + expect(store.removeWorktreeLineage).not.toHaveBeenCalledWith( + hostWorktreeTarget({ id: liveChildId, hostId: 'local' }) + ) + expect(workspaceLineageByChildKey[worktreeWorkspaceKey(missingChildId)]).toBeUndefined() + expect(workspaceLineageByChildKey[worktreeWorkspaceKey(liveChildId)]).toBeDefined() + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: missingParentId, hostId: 'local' }), + { + instanceId: expect.any(String) + } + ) + expect(store.setWorktreeMeta).not.toHaveBeenCalledWith( + hostWorktreeTarget({ id: liveParentId, hostId: 'local' }), + expect.anything() + ) expect(metaById[missingParentId].instanceId).not.toBe( missingParentEdge.parentWorktreeInstanceId ) diff --git a/src/main/worktree-lineage-pruning.ts b/src/main/worktree-lineage-pruning.ts index 0e27d98c550..909464fae8c 100644 --- a/src/main/worktree-lineage-pruning.ts +++ b/src/main/worktree-lineage-pruning.ts @@ -3,22 +3,15 @@ import type { Repo } from '../shared/repo-types' import type { WorkspaceLineage, WorktreeLineage } from '../shared/worktree/lineage-types' import type { GitWorktreeInfo } from '../shared/worktree/types' import { getRepoExecutionHostId } from '../shared/execution-host' -import { isWorkspaceKey, parseWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import { isWorkspaceKey, parseWorkspaceKey } from '../shared/workspace-scope' import { splitWorktreeId } from '../shared/worktree/id' import { worktreeRetentionPathComparisonKey } from './worktree-retention-path-comparison' import type { Store } from './persistence' import { isWslMoveRepoBlocked } from './persistence/moving-wsl-projects/wsl-move-background-backoff' +import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity' type WorktreeLineagePruningStore = Pick & - Partial< - Pick< - Store, - | 'getAllWorktreeLineage' - | 'getAllWorkspaceLineage' - | 'removeWorktreeLineage' - | 'removeWorkspaceLineage' - > - > + Partial> function worktreeIdBelongsToRepo(worktreeId: string, repoPrefix: string): boolean { return worktreeId.startsWith(repoPrefix) @@ -110,7 +103,9 @@ export function pruneLineageForMissingRepoWorktrees( !isLive(childScope.worktreeId) && isWorkspaceKey(childWorkspaceKey) ) { - store.removeWorkspaceLineage?.(childWorkspaceKey) + store.removeWorktreeLineage( + hostWorktreeTarget({ id: childScope.worktreeId, hostId: expectedHostId }) + ) } } for (const [childId, lineage] of Object.entries(worktreeLineage)) { @@ -120,8 +115,7 @@ export function pruneLineageForMissingRepoWorktrees( !isLive(childId) ) { // Why: a proven-missing path must not transfer its lineage to a future checkout at that path. - store.removeWorktreeLineage(childId) - store.removeWorkspaceLineage?.(worktreeWorkspaceKey(childId)) + store.removeWorktreeLineage(hostWorktreeTarget({ id: childId, hostId: expectedHostId })) } if ( worktreeIdBelongsToRepo(lineage.parentWorktreeId, repoPrefix) && @@ -131,7 +125,10 @@ export function pruneLineageForMissingRepoWorktrees( const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) if (parentMeta?.instanceId === lineage.parentWorktreeInstanceId) { // Why: rotate a proven-missing parent's identity once so path reuse cannot validate old lineage. - store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() }) + store.setWorktreeMeta( + hostWorktreeTarget({ id: lineage.parentWorktreeId, hostId: expectedHostId }), + { instanceId: randomUUID() } + ) } } } diff --git a/src/main/worktree-removal-close-records.test.ts b/src/main/worktree-removal-close-records.test.ts index a805273c127..07f8363a003 100644 --- a/src/main/worktree-removal-close-records.test.ts +++ b/src/main/worktree-removal-close-records.test.ts @@ -17,6 +17,8 @@ import { recordClosedTerminalTabTombstone, type ClosedTerminalTabTombstonesByTabId } from '../shared/closed-terminal-tab-tombstones' +import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity' +import { removeWorktreeMetaRow } from './persistence/host-qualified-worktree-meta' let hasCreatedStoreInCase = false @@ -92,7 +94,7 @@ describe('close records on workspace removal', () => { it('drops only the removed worktree’s records', async () => { const store = await createStore() - store.setWorktreeMeta(REMOVED, {}) + store.setWorktreeMeta(hostWorktreeTarget({ id: REMOVED, hostId: 'local' }), {}) store.setWorkspaceSession( sessionWithRecords( records([ @@ -102,7 +104,7 @@ describe('close records on workspace removal', () => { ) ) - store.removeWorktreeMeta(REMOVED) + removeWorktreeMetaRow(store, REMOVED, undefined) expect( Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}) @@ -111,7 +113,9 @@ describe('close records on workspace removal', () => { it('drops the records in the SSH host’s partition on a remote removal', async () => { const store = await createStore() - store.setWorktreeMeta(REMOVED, { hostId: SSH_HOST }) + store.setWorktreeMeta(hostWorktreeTarget({ id: REMOVED, hostId: SSH_HOST }), { + hostId: SSH_HOST + }) store.setWorkspaceSession( sessionWithRecords( records([ @@ -122,7 +126,7 @@ describe('close records on workspace removal', () => { SSH_HOST ) - store.removeWorktreeMeta(REMOVED, SSH_HOST) + store.removeWorktreeMeta(hostWorktreeTarget({ id: REMOVED, hostId: SSH_HOST })) expect( Object.keys(store.getWorkspaceSession(SSH_HOST).closedTerminalTabTombstonesByTabId ?? {}) @@ -140,11 +144,13 @@ describe('close records on workspace removal', () => { connectionId: 'target-1', executionHostId: SSH_HOST }) - store.setWorktreeMeta(REMOVED, { hostId: SSH_HOST }) + store.setWorktreeMeta(hostWorktreeTarget({ id: REMOVED, hostId: SSH_HOST }), { + hostId: SSH_HOST + }) store.setWorkspaceSession(sessionWithRecords(records([['local-tab', REMOVED]]))) store.setWorkspaceSession(sessionWithRecords(records([['ssh-tab', REMOVED]])), SSH_HOST) - store.removeWorktreeMeta(REMOVED, SSH_HOST) + store.removeWorktreeMeta(hostWorktreeTarget({ id: REMOVED, hostId: SSH_HOST })) expect( Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}) @@ -213,7 +219,7 @@ describe('close records on workspace removal', () => { { length: MAX_CLOSED_TERMINAL_TAB_TOMBSTONES - 1 }, (_, index): [string, string] => [`dead-${index}`, REMOVED] ) - store.setWorktreeMeta(REMOVED, {}) + store.setWorktreeMeta(hostWorktreeTarget({ id: REMOVED, hostId: 'local' }), {}) store.setWorkspaceSession( sessionWithRecords({ ...records(dead, now), @@ -221,7 +227,7 @@ describe('close records on workspace removal', () => { }) ) - store.removeWorktreeMeta(REMOVED) + removeWorktreeMetaRow(store, REMOVED, undefined) let map = store.getWorkspaceSession().closedTerminalTabTombstonesByTabId for (let index = 0; index < 2; index += 1) { map = recordClosedTerminalTabTombstone( diff --git a/src/main/worktree-removal-session-partition-fencing.test.ts b/src/main/worktree-removal-session-partition-fencing.test.ts index f6d7145f14a..ecd14f9d663 100644 --- a/src/main/worktree-removal-session-partition-fencing.test.ts +++ b/src/main/worktree-removal-session-partition-fencing.test.ts @@ -13,6 +13,7 @@ import { tmpdir } from 'node:os' import type { TerminalTab } from '../shared/terminal-tab-types' import { getDefaultWorkspaceSession } from '../shared/constants' import { toRuntimeExecutionHostId } from '../shared/execution-host' +import { removeWorktreeMetaRow } from './persistence/host-qualified-worktree-meta' let hasCreatedStoreInCase = false @@ -81,9 +82,9 @@ describe('worktree removal across host session partitions', () => { // and rebased away every sibling worktree's tabs on the first renderer write for that host. it('keeps sibling tabs when a worktree is removed before its host partition was ever persisted', async () => { const store = await createStore() - store.setWorktreeMeta(STALE, { hostId: ENV_A }) + store.setWorktreeMeta(hostWorktreeTarget({ id: STALE, hostId: ENV_A }), { hostId: ENV_A }) - store.removeWorktreeMeta(STALE) + removeWorktreeMetaRow(store, STALE, undefined) store.setWorkspaceSession( { @@ -100,9 +101,9 @@ describe('worktree removal across host session partitions', () => { it('does not materialize an unwritten host partition when removing a worktree', async () => { const store = await createStore() - store.setWorktreeMeta(STALE, { hostId: ENV_A }) + store.setWorktreeMeta(hostWorktreeTarget({ id: STALE, hostId: ENV_A }), { hostId: ENV_A }) - store.removeWorktreeMeta(STALE) + removeWorktreeMetaRow(store, STALE, undefined) expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) }) @@ -118,7 +119,7 @@ describe('worktree removal across host session partitions', () => { // A persisted partition must still be fenced so main's copy wins over the renderer's stale tabs. it('still fences a host partition that was persisted before the removal', async () => { const store = await createStore() - store.setWorktreeMeta(STALE, { hostId: ENV_A }) + store.setWorktreeMeta(hostWorktreeTarget({ id: STALE, hostId: ENV_A }), { hostId: ENV_A }) store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), @@ -127,7 +128,7 @@ describe('worktree removal across host session partitions', () => { ENV_A ) - store.removeWorktreeMeta(STALE) + removeWorktreeMetaRow(store, STALE, undefined) expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[STALE]).toBeUndefined() expect(store.getWorkspaceSession(ENV_A).terminalTopologyRevisionByRepoId?.['repo-gone']).toBe(1) @@ -146,7 +147,7 @@ describe('worktree removal across host session partitions', () => { // local blob (its fallback whenever worktree ownership is unresolved) leaked forever. it('purges a runtime-owned worktree from the local blob as well as its host partition', async () => { const store = await createStore() - store.setWorktreeMeta(STALE, { hostId: ENV_A }) + store.setWorktreeMeta(hostWorktreeTarget({ id: STALE, hostId: ENV_A }), { hostId: ENV_A }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: { @@ -161,7 +162,7 @@ describe('worktree removal across host session partitions', () => { ENV_A ) - store.removeWorktreeMeta(STALE) + removeWorktreeMetaRow(store, STALE, undefined) expect(store.getWorkspaceSession().tabsByWorktree[STALE]).toBeUndefined() expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[STALE]).toBeUndefined() @@ -171,7 +172,7 @@ describe('worktree removal across host session partitions', () => { // repo-wide authority there and the renderer's next ownership-unresolved write lost its tabs. it('does not fence the local blob for a runtime removal it never held tabs for', async () => { const store = await createStore() - store.setWorktreeMeta(STALE, { hostId: ENV_A }) + store.setWorktreeMeta(hostWorktreeTarget({ id: STALE, hostId: ENV_A }), { hostId: ENV_A }) store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), @@ -180,7 +181,7 @@ describe('worktree removal across host session partitions', () => { ENV_A ) - store.removeWorktreeMeta(STALE) + removeWorktreeMetaRow(store, STALE, undefined) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), @@ -199,7 +200,7 @@ describe('worktree removal across host session partitions', () => { it('fences the owning partition that holds no tabs for the removed worktree', async () => { const store = await createStore() const otherRepoWorktree = 'repo-other::/workspace/live' - store.setWorktreeMeta(STALE, { hostId: ENV_A }) + store.setWorktreeMeta(hostWorktreeTarget({ id: STALE, hostId: ENV_A }), { hostId: ENV_A }) store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), @@ -212,7 +213,7 @@ describe('worktree removal across host session partitions', () => { ENV_A ) - store.removeWorktreeMeta(STALE) + removeWorktreeMetaRow(store, STALE, undefined) expect(store.getWorkspaceSession(ENV_A).terminalTopologyRevisionByRepoId?.['repo-gone']).toBe(1) @@ -230,13 +231,13 @@ describe('worktree removal across host session partitions', () => { // The widened local purge must not fence the local blob when only siblings live there. it('does not fence the local blob that holds only sibling worktrees of the removed runtime worktree', async () => { const store = await createStore() - store.setWorktreeMeta(STALE, { hostId: ENV_A }) + store.setWorktreeMeta(hostWorktreeTarget({ id: STALE, hostId: ENV_A }), { hostId: ENV_A }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: { [LIVE]: [makeTerminalTab({ id: 'live-tab', worktreeId: LIVE })] } }) - store.removeWorktreeMeta(STALE) + removeWorktreeMetaRow(store, STALE, undefined) expect( store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.['repo-gone'] diff --git a/src/main/wsl-move-incidental-writes.test.ts b/src/main/wsl-move-incidental-writes.test.ts index 58418f1caa8..2bad5efcbeb 100644 --- a/src/main/wsl-move-incidental-writes.test.ts +++ b/src/main/wsl-move-incidental-writes.test.ts @@ -23,6 +23,7 @@ vi.mock('./runtime/runtime-linear-command-dependencies', async (importOriginal) import { materializeWorktreePushTargetRemote } from './ipc/worktree-remote' import { RuntimeLinearTeamWriteCommands } from './runtime/runtime-linear-team-write-commands' +import { hostWorktreeTarget } from '../shared/worktree/host-qualified-identity' const MOVING_WT = 'repo-1::/ws/repo-1/wt' const STAYING_WT = 'repo-2::/ws/repo-2/wt' @@ -55,7 +56,11 @@ describe('push-target bookkeeping under a WSL move', () => { async function push(worktreeId: string, hostId?: ExecutionHostId) { const setWorktreeMeta = vi.fn() const meta = hostId ? { [worktreeId]: mergeWorktreeMetaForWrite(undefined, { hostId }) } : {} - const store = { getAllWorktreeMeta: () => meta, setWorktreeMeta } + // An unstamped row resolves its host from its repo's only registration. + const repos = [ + { id: 'repo-2', path: '/ws/repo-2', displayName: 'r', badgeColor: '#000', addedAt: 0 } + ] + const store = { getAllWorktreeMeta: () => meta, getRepos: () => repos, setWorktreeMeta } const result = await materializeWorktreePushTargetRemote( '/ws/repo', target, @@ -78,9 +83,10 @@ describe('push-target bookkeeping under a WSL move', () => { ['the same repo id on another host', MOVING_WT, 'ssh:target-1'] ])('records the push target for %s', async (_label, worktreeId, hostId) => { const { setWorktreeMeta } = await push(worktreeId, hostId) - expect(setWorktreeMeta).toHaveBeenCalledWith(worktreeId, { - pushTarget: { ...target, remoteCreated: true } - }) + expect(setWorktreeMeta).toHaveBeenCalledWith( + hostWorktreeTarget({ id: worktreeId, hostId: hostId ?? 'local' }), + { pushTarget: { ...target, remoteCreated: true } } + ) }) }) @@ -140,7 +146,7 @@ describe('Linear workspace backfill under a WSL move', () => { ['the same repo id on another host', MOVING_WT, 'ssh:target-1'] ])('persists the backfill for %s', async (_label, id, hostId) => { const { setWorktreeMeta } = await resolveCurrent(worktree(id, hostId)) - expect(setWorktreeMeta).toHaveBeenCalledWith(id, { + expect(setWorktreeMeta).toHaveBeenCalledWith(expect.objectContaining({ id, hostId }), { linkedLinearIssueWorkspaceId: 'ws-1', linkedLinearIssueOrganizationUrlKey: 'acme' }) diff --git a/src/main/wsl-move-scan-pruning.test.ts b/src/main/wsl-move-scan-pruning.test.ts index c1575185260..b87c2e8b1c4 100644 --- a/src/main/wsl-move-scan-pruning.test.ts +++ b/src/main/wsl-move-scan-pruning.test.ts @@ -16,6 +16,7 @@ import { resetWslMoveFenceForTests } from './persistence/moving-wsl-projects/wsl-move-scope-guard' import { pruneLineageForMissingRepoWorktrees } from './worktree-lineage-pruning' +import { hostWorktreeTargetForRepo } from './persistence/host-qualified-worktree-meta' import { pruneMetadataMissingFromAuthoritativeLocalScan } from './ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning' import { listFolderWorkspaces } from './ipc/worktrees/listing/folder-workspace-catalog' import { getFolderWorkspaceRootId } from './ipc/worktrees/folder-workspace-model' @@ -72,7 +73,10 @@ afterEach(async () => { describe('lineage pruning under a WSL move', () => { const pruneMissingChild = (target: Repo): WorktreeLineage | undefined => { const childId = `${target.id}::/ws/${target.id}/gone` - store.setWorktreeLineage(childId, lineage(childId, `${target.id}::${target.path}`)) + store.setWorktreeLineage( + hostWorktreeTargetForRepo(childId, target), + lineage(childId, `${target.id}::${target.path}`) + ) pruneLineageForMissingRepoWorktrees(store, target, [gitRow(target.path)]) return store.getWorktreeLineage(childId) } @@ -94,7 +98,7 @@ describe('authoritative metadata pruning under a WSL move', () => { const pruneMissingRow = async (repoId: string): Promise => { const target = repo(repoId) const goneId = `${repoId}::/ws/${repoId}/gone` - store.setWorktreeMeta(goneId, { displayName: 'gone' }) + store.setWorktreeMeta(hostWorktreeTargetForRepo(goneId, target), { displayName: 'gone' }) const result = await pruneMetadataMissingFromAuthoritativeLocalScan({ store, repo: target, diff --git a/tests/e2e/folder-upgrade-identity-persistence.unit.test.ts b/tests/e2e/folder-upgrade-identity-persistence.unit.test.ts index ea55aa7ff98..4e011f24f56 100644 --- a/tests/e2e/folder-upgrade-identity-persistence.unit.test.ts +++ b/tests/e2e/folder-upgrade-identity-persistence.unit.test.ts @@ -1,3 +1,4 @@ +import { hostWorktreeTarget } from '../../src/shared/worktree/host-qualified-identity' import { getDefaultWorkspaceSession } from '../../src/shared/constants' import type { AppState } from '../../src/renderer/src/store/types' import { getRemovedWorktreeIdsAfterAuthoritativeScan } from '../../src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership' @@ -28,8 +29,12 @@ it('retains the folder instance and metadata through upgrade, listing, and Store const owner = makeRepo({ id: 'folder', path: 'C:\\projects\\draft', kind: 'folder' }) store.addRepo(owner) const id = `folder::${owner.path}` - const before = store.setWorktreeMetaForHost(id, 'local', { comment: 'ongoing OMP work' }) - store.setWorktreeMetaForHost(id, 'ssh:builder', { comment: 'other host' }) + const before = store.setWorktreeMeta(hostWorktreeTarget({ id, hostId: 'local' }), { + comment: 'ongoing OMP work' + }) + store.setWorktreeMeta(hostWorktreeTarget({ id, hostId: 'ssh:builder' }), { + comment: 'other host' + }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), activeRepoId: owner.id,