From 121eb3bc84d6aa6aaa8e4bf4444b504bf6533f8b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:52:37 -0700 Subject: [PATCH] fix(runtime): resolve folder workspaces wherever a command can be given one (#35223) * fix(runtime): resolve workspaces, not only Git worktrees, wherever a folder id can arrive The Git-only selector resolver never matches project-group folder workspaces (no repo row), so worktree show/sleep, terminal list by workspace key, the active-terminal fallback, scrollback reads, Kitty file reads, Linear --current, browser commands, plugin context and the session tab fallbacks failed with selector_not_found for them. Route those callers through resolveWorkspaceSelector and rename the Git-only resolver to resolveGitWorktreeSelector so it reads as the repo-backed catalog only. Refs STA-12562 * test(runtime): follow the workspace resolver rename in host fakes Refs STA-12562 * test(runtime): keep partial browser host fakes and mail-routing stubs on the renamed resolver Refs STA-12562 * test(runtime): drop an accidental copy of the folder selector suite Refs STA-12562 * fix(runtime): keep folder workspaces off the skill worktree fence and the Linear meta backfill Skill installs fence a worktreeId by repo rows, so a folder id there must come through folderWorkspaceId, where its own host fence applies. Linear --current answers a folder's legacy link without writing a worktreeMeta row keyed by the folder id. Refs STA-12562 --- src/main/ipc/pty/pty-launch-parity.test.ts | 4 +- .../electron-serve-browser-process.test.ts | 4 +- ...romium-browser-process.integration.test.ts | 2 +- .../external-chromium-tab-registry.test.ts | 8 +- src/main/orcad/orcad-browser-provider.test.ts | 2 +- src/main/orcad/orcad-browser-startup.test.ts | 2 +- .../agent-detection-host-resolution.test.ts | 4 +- .../agent-terminal-launch-host.test.ts | 4 +- ...owser-tab-create-caller-navigation.test.ts | 4 +- ...rowser-tab-create-folder-workspace.test.ts | 2 +- .../browser-tab-create-publication.test.ts | 3 +- .../browser/browser-command-contracts.ts | 2 +- .../browser/browser-command-targets.ts | 6 +- src/main/runtime/browser/browser-tab-close.ts | 6 +- .../runtime-construction-services.ts | 2 +- .../runtime-file-command-state.ts | 3 - .../construction/runtime-host-services.ts | 2 +- .../construction/runtime-service-state.ts | 4 +- src/main/runtime/files/file-command-host.ts | 1 - .../integrations/linear/linear-commands.ts | 2 +- .../launch-agent-terminal-followup.test.ts | 4 +- src/main/runtime/linear-save-markdown.test.ts | 2 +- ...orca-runtime-browser-client-hosted.test.ts | 3 +- ...me-browser-ghost-session-row-close.test.ts | 5 +- .../orca-runtime-browser-headless.test.ts | 3 +- src/main/runtime/orca-runtime-browser.test.ts | 3 +- ...orca-runtime-files-artifact-facade.test.ts | 1 - .../orca-runtime-files-test-harness.ts | 1 - .../runtime/orca-runtime-files-watch.test.ts | 5 - .../runtime/orca-runtime-migration-catalog.ts | 2 +- .../orca-runtime-read-kitty-file.test.ts | 44 +++- .../runtime/orca-runtime-read-kitty-file.ts | 2 +- ...untime-terminal-create-idempotency.test.ts | 2 +- ...t-group-folder-workspace-selectors.test.ts | 194 ++++++++++++++++++ .../queued-terminal-graph-exit.test.ts | 6 +- ...e-created-worktree-terminal-target.test.ts | 4 +- ...runtime-file-target-execution-host.test.ts | 6 +- .../runtime-git-target-execution-host.test.ts | 6 +- .../runtime/runtime-linear-command-base.ts | 6 +- .../runtime-linear-comment-edit.test.ts | 2 +- .../runtime-linear-read-commands.test.ts | 2 +- .../runtime-linear-team-write-commands.ts | 10 +- .../runtime-skill-install-authority.test.ts | 10 + .../runtime-skill-install-authority.ts | 5 + .../session-graph/session-graph-authority.ts | 2 +- .../session-graph-reload-lifecycle.ts | 2 +- .../close/close-mobile-session-tab.ts | 2 +- ...form-mobile-session-pty-records-refresh.ts | 3 +- .../headless-pane-move-to-new-tab.ts | 2 +- .../session-tabs/headless-tab-properties.ts | 2 +- .../headless-terminal-tab-close.ts | 4 +- .../session-tab-prototype-dispatch.test.ts | 2 +- .../session-tabs-change-publication.ts | 2 +- .../session-tabs/session-tabs-inventory.ts | 2 +- .../structured-session-tab-restoration.ts | 2 +- ...-page-browser-commands-host-test-double.ts | 2 +- .../structured-worker-mail-routing.test.ts | 4 +- .../adopt-terminal-orphans-from-inventory.ts | 4 +- .../driver/terminal-driver-resources.ts | 4 +- .../terminal/launch/mobile-terminal-create.ts | 2 +- .../launch/terminal-create-deduplication.ts | 2 +- .../launch/terminal-workspace-scope.ts | 2 +- .../layout/terminal-resize-listeners.ts | 2 +- .../terminal-orchestration-authority.ts | 2 +- .../stop/exact-terminal-stop-liveness.test.ts | 6 +- .../wait/terminal-interactive-wait.ts | 2 +- .../managed-workspace-operations.ts | 4 +- .../workspaces/workspace-activation.ts | 2 +- .../workspace-file-session-targets.ts | 4 +- .../runtime/workspaces/workspace-metadata.ts | 4 +- .../workspace-query-prototype.test.ts | 2 +- .../workspaces/workspace-removal-target.ts | 2 +- .../runtime/workspaces/workspace-selectors.ts | 3 +- ...rktree-scan-admin-fingerprint-gate.test.ts | 20 +- src/main/wsl-move-incidental-writes.test.ts | 2 +- 75 files changed, 356 insertions(+), 139 deletions(-) create mode 100644 src/main/runtime/project-group-folder-workspace-selectors.test.ts diff --git a/src/main/ipc/pty/pty-launch-parity.test.ts b/src/main/ipc/pty/pty-launch-parity.test.ts index afba814ed0a..bdbb67e1ab3 100644 --- a/src/main/ipc/pty/pty-launch-parity.test.ts +++ b/src/main/ipc/pty/pty-launch-parity.test.ts @@ -273,9 +273,9 @@ describe('launch facts outside the tables', () => { const paneKey = makePaneKey(pane.tabId, pane.leafId) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: exposes the existing selector; empty-inventory Sleep needs only this local workspace identity. const selecting = lanes.runtime as unknown as { - resolveWorktreeSelector: () => Promise<{ id: string }> + resolveGitWorktreeSelector: () => Promise<{ id: string }> } - vi.spyOn(selecting, 'resolveWorktreeSelector').mockResolvedValue({ id: pane.worktreeId }) + vi.spyOn(selecting, 'resolveGitWorktreeSelector').mockResolvedValue({ id: pane.worktreeId }) const events: string[] = [] const releases: ReturnType void>>[] = [] const finishes: ReturnType void>>[] = [] diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts index 81b3aa2faaa..db70161fef9 100644 --- a/src/main/orcad/electron-serve-browser-process.test.ts +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -70,7 +70,7 @@ type SidecarRequest = { method: string; params: Record; authTok const host: RuntimeBrowserCommandHost = { getAgentBrowserBridge: () => null, // Distinct IDs prove registry fencing uses resolved workspaces, not caller selectors. - resolveWorktreeSelector: async (selector) => ({ id: `id-${selector}` }), + resolveWorkspaceSelector: async (selector) => ({ id: `id-${selector}` }), resolveBrowserWorkspace: async (selector) => ({ id: `id-${selector}` }), resolveBrowserNetworkExecutionHost: () => { throw new Error('No browser network execution host') @@ -448,7 +448,7 @@ describe('ElectronServeBrowserProcess command routing', () => { it('scopes a folder workspace through the browser-workspace resolver', async () => { const folderHost: RuntimeBrowserCommandHost = { ...host, - resolveWorktreeSelector: async () => { + resolveWorkspaceSelector: async () => { throw new Error('selector_not_found') }, resolveBrowserWorkspace: async () => ({ id: 'folder:docs' }) diff --git a/src/main/orcad/external-chromium-browser-process.integration.test.ts b/src/main/orcad/external-chromium-browser-process.integration.test.ts index 21860741b5a..a46fb420230 100644 --- a/src/main/orcad/external-chromium-browser-process.integration.test.ts +++ b/src/main/orcad/external-chromium-browser-process.integration.test.ts @@ -32,7 +32,7 @@ describe('ExternalChromiumBrowserProcess integration', () => { } const commands = provider.factory({ getAgentBrowserBridge: () => null, - resolveWorktreeSelector: async (selector) => ({ id: selector }), + resolveWorkspaceSelector: async (selector) => ({ id: selector }), resolveBrowserWorkspace: async (selector) => ({ id: selector }), resolveBrowserNetworkExecutionHost: () => { throw new Error('No browser network execution host') diff --git a/src/main/orcad/external-chromium-tab-registry.test.ts b/src/main/orcad/external-chromium-tab-registry.test.ts index fe632d032bd..3a8df84df87 100644 --- a/src/main/orcad/external-chromium-tab-registry.test.ts +++ b/src/main/orcad/external-chromium-tab-registry.test.ts @@ -18,14 +18,14 @@ function fakeSession(): ExternalChromiumBrowserSession { describe('external Chromium tab registry', () => { it('scopes a folder workspace through the browser-workspace resolver', async () => { - const resolveWorktreeSelector = vi.fn(async () => { + const resolveWorkspaceSelector = vi.fn(async () => { throw new Error('selector_not_found') }) const host: Pick< RuntimeBrowserCommandHost, - 'resolveWorktreeSelector' | 'resolveBrowserWorkspace' + 'resolveWorkspaceSelector' | 'resolveBrowserWorkspace' > = { - resolveWorktreeSelector, + resolveWorkspaceSelector, resolveBrowserWorkspace: async () => ({ id: 'folder:docs' }) } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these tab paths read only the two selector resolvers and optional notifiers. @@ -40,6 +40,6 @@ describe('external Chromium tab registry', () => { .parse(await registry.listTabs(commandHost, { worktree: 'id:folder:docs' })) expect(listed.tabs.map((tab) => tab.browserPageId)).toEqual([created.browserPageId]) - expect(resolveWorktreeSelector).not.toHaveBeenCalled() + expect(resolveWorkspaceSelector).not.toHaveBeenCalled() }) }) diff --git a/src/main/orcad/orcad-browser-provider.test.ts b/src/main/orcad/orcad-browser-provider.test.ts index 9dc6f4b08ea..461baca51b1 100644 --- a/src/main/orcad/orcad-browser-provider.test.ts +++ b/src/main/orcad/orcad-browser-provider.test.ts @@ -104,7 +104,7 @@ describe('ExternalChromiumBrowserProcess', () => { await processHandle.start() const commands = processHandle.createCommands({ getAgentBrowserBridge: () => null, - resolveWorktreeSelector: async (selector) => ({ id: selector }), + resolveWorkspaceSelector: async (selector) => ({ id: selector }), resolveBrowserWorkspace: async (selector) => ({ id: selector }), resolveBrowserNetworkExecutionHost: () => { throw new Error('No browser network execution host') diff --git a/src/main/orcad/orcad-browser-startup.test.ts b/src/main/orcad/orcad-browser-startup.test.ts index 3b7e5d4b9b8..005c347ce6e 100644 --- a/src/main/orcad/orcad-browser-startup.test.ts +++ b/src/main/orcad/orcad-browser-startup.test.ts @@ -115,7 +115,7 @@ function closeHost(pages: RuntimeBrowserPageRegistry): RuntimeBrowserCommandHost getRuntimeBrowserPageRegistry: () => pages, getBrowserHostLeaseRegistry: () => ({ getPlacement: () => undefined }), resolveBrowserWorkspace: async (selector: string) => ({ id: selector.replace(/^id:/, '') }), - resolveWorktreeSelector: async (selector: string) => ({ id: selector.replace(/^id:/, '') }), + resolveWorkspaceSelector: async (selector: string) => ({ id: selector.replace(/^id:/, '') }), retireRuntimeOwnedBrowserSessionTab: vi.fn(() => true) }) } diff --git a/src/main/runtime/agent-detection-host-resolution.test.ts b/src/main/runtime/agent-detection-host-resolution.test.ts index 27612af081f..be4bc735fad 100644 --- a/src/main/runtime/agent-detection-host-resolution.test.ts +++ b/src/main/runtime/agent-detection-host-resolution.test.ts @@ -34,9 +34,7 @@ function makeRuntime(repos: Repo[], projects: Project[] = []) { }) ) const selector = vi.fn<(selector: string) => Promise>() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: replaces the protected worktree lookup this method awaits; its signature is restated above. - ;(runtime as unknown as { resolveWorktreeSelector: typeof selector }).resolveWorktreeSelector = - selector + runtime['resolveGitWorktreeSelector'] = selector return { runtime, selector } } diff --git a/src/main/runtime/agent-terminal-launch-host.test.ts b/src/main/runtime/agent-terminal-launch-host.test.ts index e0817f49e3a..5309258ee61 100644 --- a/src/main/runtime/agent-terminal-launch-host.test.ts +++ b/src/main/runtime/agent-terminal-launch-host.test.ts @@ -13,7 +13,7 @@ import { OrcaRuntimeService } from './orca-runtime' const REMOTE_PATH = '/srv/app-feature' type RuntimeInternals = { - resolveWorktreeSelector: (selector: string) => Promise + resolveGitWorktreeSelector: (selector: string) => Promise buildStartupForAgent: (repo: unknown, agent: unknown, prompt: string) => unknown createTerminal: (selector: string, opts: unknown) => Promise } @@ -27,7 +27,7 @@ function makeRuntime(repos: readonly Record[], hostId?: string) } const runtime = new OrcaRuntimeService(store as never) const internals = runtime as unknown as RuntimeInternals - vi.spyOn(internals, 'resolveWorktreeSelector').mockResolvedValue({ + vi.spyOn(internals, 'resolveGitWorktreeSelector').mockResolvedValue({ id: 'repo-shared::/srv/app-feature', repoId: 'repo-shared', path: REMOTE_PATH, diff --git a/src/main/runtime/browser-tab-create-caller-navigation.test.ts b/src/main/runtime/browser-tab-create-caller-navigation.test.ts index d5a89b125f7..0d17ed13184 100644 --- a/src/main/runtime/browser-tab-create-caller-navigation.test.ts +++ b/src/main/runtime/browser-tab-create-caller-navigation.test.ts @@ -58,7 +58,7 @@ const storeBase = { type RuntimeInternals = { offscreenBrowserBackend: unknown agentBrowserBridge: unknown - resolveWorktreeSelector: (selector: string) => Promise<{ id: string }> + resolveGitWorktreeSelector: (selector: string) => Promise<{ id: string }> } function makeSession(): WorkspaceSessionState { @@ -95,7 +95,7 @@ function createPairedRuntime() { }) const internals = runtime as unknown as RuntimeInternals // Stub git selector resolution; this contract needs only the resolved workspace id. - internals.resolveWorktreeSelector = async () => ({ id: WT }) + internals.resolveGitWorktreeSelector = async () => ({ id: WT }) internals.offscreenBrowserBackend = { closeTab: vi.fn(), createTab: vi.fn(async (options: { browserPageId?: string }) => ({ diff --git a/src/main/runtime/browser-tab-create-folder-workspace.test.ts b/src/main/runtime/browser-tab-create-folder-workspace.test.ts index a7859fcddbb..6aeb427f3e4 100644 --- a/src/main/runtime/browser-tab-create-folder-workspace.test.ts +++ b/src/main/runtime/browser-tab-create-folder-workspace.test.ts @@ -25,7 +25,7 @@ const FOLDER = 'folder:folder-1' function createHost(overrides: Record): RuntimeBrowserCommandHost { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test host implements only the members browserTabCreate reads. return { - resolveWorktreeSelector: vi.fn(async () => { + resolveWorkspaceSelector: vi.fn(async () => { throw new Error('selector_not_found') }), resolveBrowserWorkspace: vi.fn(async () => ({ id: FOLDER })), diff --git a/src/main/runtime/browser-tab-create-publication.test.ts b/src/main/runtime/browser-tab-create-publication.test.ts index a7498fff4f7..404bf767066 100644 --- a/src/main/runtime/browser-tab-create-publication.test.ts +++ b/src/main/runtime/browser-tab-create-publication.test.ts @@ -44,8 +44,9 @@ function createCommandHost( overrides: Partial ): RuntimeBrowserCommandHost { const runtimeBrowserPages = new RuntimeBrowserPageRegistry() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial host fake; these tests reach only the methods it defines. return { - resolveWorktreeSelector: async (selector: string) => ({ id: selector.replace(/^id:/, '') }), + resolveWorkspaceSelector: async (selector: string) => ({ id: selector.replace(/^id:/, '') }), resolveBrowserWorkspace: async (selector: string) => ({ id: selector.replace(/^id:/, '') }), getRuntimeBrowserPageRegistry: () => runtimeBrowserPages, getAgentBrowserBridge: () => diff --git a/src/main/runtime/browser/browser-command-contracts.ts b/src/main/runtime/browser/browser-command-contracts.ts index 07b1c06c52b..086ebf01dcc 100644 --- a/src/main/runtime/browser/browser-command-contracts.ts +++ b/src/main/runtime/browser/browser-command-contracts.ts @@ -25,7 +25,7 @@ export type ResolvedBrowserPageWebContents = { export type RuntimeBrowserCommandHost = { getAgentBrowserBridge(): AgentBrowserBridge | null - resolveWorktreeSelector(selector: string): Promise<{ + resolveWorkspaceSelector(selector: string): Promise<{ id: string repoId?: string hostId?: ExecutionHostId diff --git a/src/main/runtime/browser/browser-command-targets.ts b/src/main/runtime/browser/browser-command-targets.ts index 3b3e331f2e9..de520ac3a67 100644 --- a/src/main/runtime/browser/browser-command-targets.ts +++ b/src/main/runtime/browser/browser-command-targets.ts @@ -18,7 +18,7 @@ export class RuntimeBrowserCommandTargets { private readonly host: Pick< RuntimeBrowserCommandHost, | 'getAgentBrowserBridge' - | 'resolveWorktreeSelector' + | 'resolveWorkspaceSelector' | 'getAuthoritativeWindow' | 'retireRuntimeOwnedBrowserSessionTab' | 'serveBrowserTabs' @@ -81,7 +81,7 @@ export class RuntimeBrowserCommandTargets { return undefined } - const worktreeId = (await this.host.resolveWorktreeSelector(selector)).id + const worktreeId = (await this.host.resolveWorkspaceSelector(selector)).id // Explicit selector failures must surface instead of widening scope; activation alone is best-effort. const bridge = this.host.getAgentBrowserBridge() if (bridge && !this.hasLiveRegisteredBrowserTab(bridge, worktreeId)) { @@ -104,7 +104,7 @@ export class RuntimeBrowserCommandTargets { } const worktreeId = params.worktree - ? (await this.host.resolveWorktreeSelector(params.worktree)).id + ? (await this.host.resolveWorkspaceSelector(params.worktree)).id : undefined const bridge = this.host.getAgentBrowserBridge() if (bridge && !this.hasLiveRegisteredBrowserPage(bridge, worktreeId, browserPageId)) { diff --git a/src/main/runtime/browser/browser-tab-close.ts b/src/main/runtime/browser/browser-tab-close.ts index b39e622d9e0..179ce2d117b 100644 --- a/src/main/runtime/browser/browser-tab-close.ts +++ b/src/main/runtime/browser/browser-tab-close.ts @@ -22,7 +22,7 @@ export class RuntimeBrowserTabClose { | 'retireRuntimeOwnedBrowserSessionTab' | 'notifyHeadlessBrowserSessionTabsChanged' | 'getAgentBrowserBridge' - | 'resolveWorktreeSelector' + | 'resolveWorkspaceSelector' | 'getAvailableAuthoritativeWindow' | 'getOffscreenBrowserBackend' | 'getAuthoritativeWindow' @@ -52,7 +52,7 @@ export class RuntimeBrowserTabClose { namedPageId && params.worktree && this.targets.retireGhostBrowserSessionRow( - (await this.host.resolveWorktreeSelector(params.worktree)).id, + (await this.host.resolveWorkspaceSelector(params.worktree)).id, namedPageId ) ) { @@ -62,7 +62,7 @@ export class RuntimeBrowserTabClose { } const worktreeId = explicitPage ? params.worktree - ? (await this.host.resolveWorktreeSelector(params.worktree)).id + ? (await this.host.resolveWorkspaceSelector(params.worktree)).id : undefined : await this.targets.resolveBrowserWorktreeId(params.worktree) diff --git a/src/main/runtime/construction/runtime-construction-services.ts b/src/main/runtime/construction/runtime-construction-services.ts index 773cedfeaab..446a42e834b 100644 --- a/src/main/runtime/construction/runtime-construction-services.ts +++ b/src/main/runtime/construction/runtime-construction-services.ts @@ -267,7 +267,7 @@ export abstract class RuntimeConstructionServices { protected abstract getTrackedDisplayTitleForPty(ptyId: string): string | null protected abstract getLeavesForPty(ptyId: string): RuntimeLeafRecord[] abstract notifyMessageArrived(handle: string, messageType?: string): void - protected abstract resolveWorktreeSelector(selector: string): Promise + protected abstract resolveGitWorktreeSelector(selector: string): Promise protected abstract resolveWorkspaceSelector( selector: string, options?: { requireUsableFolderPath?: boolean } diff --git a/src/main/runtime/construction/runtime-file-command-state.ts b/src/main/runtime/construction/runtime-file-command-state.ts index bf837cc13d0..2420b38e649 100644 --- a/src/main/runtime/construction/runtime-file-command-state.ts +++ b/src/main/runtime/construction/runtime-file-command-state.ts @@ -85,14 +85,11 @@ export abstract class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreserv abstract requireStore(): RuntimeStore - protected abstract resolveWorktreeSelector(selector: string): Promise - protected abstract resolveRepoSelector(selector: RuntimeRepoSelector): Promise protected readonly fileCommands = new RuntimeFileCommands({ getRuntimeId: () => this.runtimeId, requireStore: () => this.requireStore(), - resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), resolveRuntimeFileTarget: async (selector) => { // Scratch folders are file-only; the general resolver also places chats. const scratchFolder = await resolveChatScratchSelectorFolder(selector) diff --git a/src/main/runtime/construction/runtime-host-services.ts b/src/main/runtime/construction/runtime-host-services.ts index d5d3463902f..076a75c392f 100644 --- a/src/main/runtime/construction/runtime-host-services.ts +++ b/src/main/runtime/construction/runtime-host-services.ts @@ -222,7 +222,7 @@ export abstract class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeW const scope = await this.resolveTerminalWorkspaceLaunchScope(`id:${candidate.worktreeId}`) const resolved = scope.folderWorkspace ? this.folderWorkspaceToResolvedWorktree(scope.folderWorkspace) - : await this.resolveWorktreeSelector(`id:${scope.id}`) + : await this.resolveGitWorktreeSelector(`id:${scope.id}`) return { scope, resolved } }, refreshInventory: (worktrees, connectionId) => diff --git a/src/main/runtime/construction/runtime-service-state.ts b/src/main/runtime/construction/runtime-service-state.ts index 8d057b5aad3..93a443a0030 100644 --- a/src/main/runtime/construction/runtime-service-state.ts +++ b/src/main/runtime/construction/runtime-service-state.ts @@ -107,7 +107,7 @@ export abstract class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWith protected readonly worktreeLineage = new RuntimeWorktreeLineageController({ getStore: () => this.store, getDb: () => this.getOrchestrationDbIfAvailable(), - resolveWorktree: (selector) => this.resolveWorktreeSelector(selector), + resolveWorktree: (selector) => this.resolveGitWorktreeSelector(selector), listResolvedWorktrees: () => this.listResolvedWorktrees(), showTerminal: (handle) => this.showTerminal(handle) }) @@ -133,7 +133,7 @@ export abstract class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWith getExplicitWorktreeId: (selector) => this.getExplicitSessionWorktreeIdSelector(selector), getResolvedCache: () => this.resolvedWorktrees.peek(), buildWorktreeFromId: (worktreeId) => this.buildResolvedWorktreeFromId(worktreeId), - resolveWorktree: (selector) => this.resolveWorktreeSelector(selector), + resolveWorktree: (selector) => this.resolveWorkspaceSelector(selector), listKnownWorktrees: (worktreeId, target) => this.listKnownResolvedWorktreesForExplicitTarget(worktreeId, target), getWorktreeMap: () => this.getResolvedWorktreeMap(), diff --git a/src/main/runtime/files/file-command-host.ts b/src/main/runtime/files/file-command-host.ts index b797e439702..207b09e379f 100644 --- a/src/main/runtime/files/file-command-host.ts +++ b/src/main/runtime/files/file-command-host.ts @@ -7,7 +7,6 @@ import type { RuntimeFileOpenPosition } from '../../../shared/runtime-file-contr export type RuntimeFileCommandHost = { getRuntimeId(): string requireStore(): RuntimeStore - resolveWorktreeSelector(selector: string): Promise resolveRuntimeFileTarget(selector: string): Promise resolveKnownWorkspaceFileTarget?( absolutePath: string, diff --git a/src/main/runtime/integrations/linear/linear-commands.ts b/src/main/runtime/integrations/linear/linear-commands.ts index d33b8c56e62..9a8de44f8a2 100644 --- a/src/main/runtime/integrations/linear/linear-commands.ts +++ b/src/main/runtime/integrations/linear/linear-commands.ts @@ -12,7 +12,7 @@ export abstract class OrcaRuntimeWithLinearCommands extends OrcaRuntimeWithFileC readonly linearCommands = new RuntimeLinearCommands({ runtimeAvailable: () => this.store !== null, showTerminal: (handle) => this.showTerminal(handle), - resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), + resolveWorkspaceSelector: (selector) => this.resolveWorkspaceSelector(selector), listResolvedWorktrees: () => this.listResolvedWorktrees(), setWorktreeMeta: (worktree, meta) => { const target = worktreeRowTarget(this.store!, worktree) diff --git a/src/main/runtime/launch-agent-terminal-followup.test.ts b/src/main/runtime/launch-agent-terminal-followup.test.ts index d0f05a436a9..7829438f293 100644 --- a/src/main/runtime/launch-agent-terminal-followup.test.ts +++ b/src/main/runtime/launch-agent-terminal-followup.test.ts @@ -16,7 +16,7 @@ function makeRuntime() { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads only these protected members; each is assigned or stubbed before launchAgentTerminal runs. const internals = runtime as unknown as { store: Record - resolveWorktreeSelector: (selector: string) => Promise + resolveGitWorktreeSelector: (selector: string) => Promise createTerminal: (selector: string, opts: unknown) => Promise sendStartupFollowupWhenReady: (handle: string, followup: unknown) => void } @@ -26,7 +26,7 @@ function makeRuntime() { getRepos: () => [repo], getRepo: (id: string) => (id === repo.id ? repo : undefined) } - vi.spyOn(internals, 'resolveWorktreeSelector').mockResolvedValue({ + vi.spyOn(internals, 'resolveGitWorktreeSelector').mockResolvedValue({ id: 'repo-1::/home/me/app-feature', repoId: 'repo-1', path: '/home/me/app-feature' diff --git a/src/main/runtime/linear-save-markdown.test.ts b/src/main/runtime/linear-save-markdown.test.ts index 1b2ac2bb094..ecc0f3bbc57 100644 --- a/src/main/runtime/linear-save-markdown.test.ts +++ b/src/main/runtime/linear-save-markdown.test.ts @@ -108,7 +108,7 @@ function commands() { showTerminal: async () => { throw new Error('No terminal in HTTP proof') }, - resolveWorktreeSelector: async () => { + resolveWorkspaceSelector: async () => { throw new Error('No workspace resolution in HTTP proof') }, listResolvedWorktrees: async () => [], diff --git a/src/main/runtime/orca-runtime-browser-client-hosted.test.ts b/src/main/runtime/orca-runtime-browser-client-hosted.test.ts index 07de61b3d33..c81941aa463 100644 --- a/src/main/runtime/orca-runtime-browser-client-hosted.test.ts +++ b/src/main/runtime/orca-runtime-browser-client-hosted.test.ts @@ -72,8 +72,9 @@ function createHost(overrides: Partial = {}): Runtime ] })) } as unknown as AgentBrowserBridge) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial host fake; these tests reach only the methods it defines. return { - resolveWorktreeSelector: async (selector) => ({ id: selector.replace(/^id:/, '') }), + resolveWorkspaceSelector: async (selector) => ({ id: selector.replace(/^id:/, '') }), resolveBrowserWorkspace: async (selector) => ({ id: selector.replace(/^id:/, '') }), getRuntimeBrowserPageRegistry: () => runtimeBrowserPages, getAuthoritativeWindow: vi.fn(), diff --git a/src/main/runtime/orca-runtime-browser-ghost-session-row-close.test.ts b/src/main/runtime/orca-runtime-browser-ghost-session-row-close.test.ts index 82c7c5b4acc..91ae93d8939 100644 --- a/src/main/runtime/orca-runtime-browser-ghost-session-row-close.test.ts +++ b/src/main/runtime/orca-runtime-browser-ghost-session-row-close.test.ts @@ -131,7 +131,7 @@ describe('browserTabClose on a host with no browser engine', () => { createHost({ retireRuntimeOwnedBrowserSessionTab, // Folder workspaces bypass git selectors; resolve them through browser workspace routing. - resolveWorktreeSelector: async () => { + resolveWorkspaceSelector: async () => { throw new Error('selector_not_found') } }) @@ -192,8 +192,9 @@ function createHost(overrides: Partial = {}): Runtime getActiveWebContentsId: vi.fn(() => 100), tabList: vi.fn(() => ({ tabs: [] })) } as unknown as AgentBrowserBridge) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial host fake; these tests reach only the methods it defines. return { - resolveWorktreeSelector: async (selector: string) => ({ id: selector.replace(/^id:/, '') }), + resolveWorkspaceSelector: async (selector: string) => ({ id: selector.replace(/^id:/, '') }), resolveBrowserWorkspace: async (selector: string) => ({ id: selector.replace(/^id:/, '') }), getRuntimeBrowserPageRegistry: () => runtimeBrowserPages, getBrowserHostLeaseRegistry: () => ({ getPlacement: () => undefined }), diff --git a/src/main/runtime/orca-runtime-browser-headless.test.ts b/src/main/runtime/orca-runtime-browser-headless.test.ts index 15a68f6bed6..cfab8a55f92 100644 --- a/src/main/runtime/orca-runtime-browser-headless.test.ts +++ b/src/main/runtime/orca-runtime-browser-headless.test.ts @@ -67,8 +67,9 @@ function createHost(overrides: Partial = {}): Runtime getActivePageId: vi.fn(() => 'page-1'), tabList: vi.fn(() => ({ tabs: [] })) } as unknown as AgentBrowserBridge) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial host fake; these tests reach only the methods it defines. return { - resolveWorktreeSelector: async (selector) => ({ id: selector.replace(/^id:/, '') }), + resolveWorkspaceSelector: async (selector) => ({ id: selector.replace(/^id:/, '') }), resolveBrowserWorkspace: async (selector) => ({ id: selector.replace(/^id:/, '') }), getRuntimeBrowserPageRegistry: () => runtimeBrowserPages, getAuthoritativeWindow: vi.fn(), diff --git a/src/main/runtime/orca-runtime-browser.test.ts b/src/main/runtime/orca-runtime-browser.test.ts index ba17d2b03ee..81798cdf081 100644 --- a/src/main/runtime/orca-runtime-browser.test.ts +++ b/src/main/runtime/orca-runtime-browser.test.ts @@ -96,8 +96,9 @@ function createHost(overrides: Partial = {}): Runtime ] })) } as unknown as AgentBrowserBridge) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial host fake; these tests reach only the methods it defines. return { - resolveWorktreeSelector: async (selector) => ({ id: selector.replace(/^id:/, '') }), + resolveWorkspaceSelector: async (selector) => ({ id: selector.replace(/^id:/, '') }), resolveBrowserWorkspace: async (selector) => ({ id: selector.replace(/^id:/, '') }), getRuntimeBrowserPageRegistry: () => runtimeBrowserPages, getAuthoritativeWindow: vi.fn(), diff --git a/src/main/runtime/orca-runtime-files-artifact-facade.test.ts b/src/main/runtime/orca-runtime-files-artifact-facade.test.ts index e62acb0f873..639db62e3d2 100644 --- a/src/main/runtime/orca-runtime-files-artifact-facade.test.ts +++ b/src/main/runtime/orca-runtime-files-artifact-facade.test.ts @@ -15,7 +15,6 @@ const unexpectedHostRead = (): never => { const host = { getRuntimeId: () => 'artifact-facade', requireStore: unexpectedHostRead, - resolveWorktreeSelector: async () => unexpectedHostRead(), resolveRuntimeFileTarget: async () => unexpectedHostRead(), resolveRuntimeGitTarget: async () => unexpectedHostRead(), openFile: unexpectedHostRead, diff --git a/src/main/runtime/orca-runtime-files-test-harness.ts b/src/main/runtime/orca-runtime-files-test-harness.ts index 05d136d0ca6..dfc0292d815 100644 --- a/src/main/runtime/orca-runtime-files-test-harness.ts +++ b/src/main/runtime/orca-runtime-files-test-harness.ts @@ -62,7 +62,6 @@ export function createRuntimeFileCommands(options?: { const commands = new RuntimeFileCommands({ getRuntimeId: () => 'runtime-1', requireStore: () => store, - resolveWorktreeSelector: vi.fn(async () => worktree), resolveRuntimeFileTarget: options?.resolveRuntimeFileTarget ?? vi.fn(async () => ({ diff --git a/src/main/runtime/orca-runtime-files-watch.test.ts b/src/main/runtime/orca-runtime-files-watch.test.ts index d8526229c31..b969bde0995 100644 --- a/src/main/runtime/orca-runtime-files-watch.test.ts +++ b/src/main/runtime/orca-runtime-files-watch.test.ts @@ -73,11 +73,6 @@ function createRuntimeFileCommands(rootPath: string) { const commands = new RuntimeFileCommands({ getRuntimeId: () => 'runtime-1', requireStore: () => store, - resolveWorktreeSelector: vi.fn(async () => ({ - id: 'wt-1', - repoId: 'repo-1', - path: rootPath - })), resolveRuntimeFileTarget: vi.fn(async () => ({ worktree: { id: 'wt-1', diff --git a/src/main/runtime/orca-runtime-migration-catalog.ts b/src/main/runtime/orca-runtime-migration-catalog.ts index b077b8e0ca7..684cbdeb2a4 100644 --- a/src/main/runtime/orca-runtime-migration-catalog.ts +++ b/src/main/runtime/orca-runtime-migration-catalog.ts @@ -187,7 +187,7 @@ export abstract class OrcaRuntimeWithMigrationCatalog extends OrcaRuntimeWithRes } return readHostTerminalScrollback( { - resolveWorktreeId: async (selector) => (await this.resolveWorktreeSelector(selector)).id, + resolveWorktreeId: async (selector) => (await this.resolveWorkspaceSelector(selector)).id, getWorkspaceSession: (worktreeId) => this.workspaceSessions.get(worktreeId), statSnapshotSize: (ref) => statSnapshotSize.call(store, ref), readSnapshot: (ref) => readSnapshot.call(store, ref) diff --git a/src/main/runtime/orca-runtime-read-kitty-file.test.ts b/src/main/runtime/orca-runtime-read-kitty-file.test.ts index 05d4315cb23..de65df46404 100644 --- a/src/main/runtime/orca-runtime-read-kitty-file.test.ts +++ b/src/main/runtime/orca-runtime-read-kitty-file.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession } from '../../shared/constants' import { OrcaRuntimeService } from './orca-runtime' +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' const { readFile } = vi.hoisted(() => ({ readFile: vi.fn() })) vi.mock('./terminal-kitty-file-read', () => ({ readTerminalKittyFile: readFile })) @@ -9,8 +10,38 @@ const PTY = 'kitty-file-pty' const LEAF = '11111111-1111-4111-8111-111111111111' const WORKSPACE = 'folder:kitty-files' +// A project-group folder workspace: no repo row backs it, so a Git-only selector never finds it. +function folderWorkspaceStore() { + const folder = { + id: 'kitty-files', + projectGroupId: 'group-1', + name: 'kitty', + folderPath: '/workspace', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture supplies only the store reads workspace resolution makes. + return withDurableRuntimeStore({ + getRepos: () => [], + getRepo: () => undefined, + getFolderWorkspaces: () => [folder], + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getWorkspaceSession: () => getDefaultWorkspaceSession(), + setWorkspaceSession: () => {}, + flushOrThrow: () => {} + }) as never +} + function fixture(ptyId = PTY, connectionId: string | null = null, worktreeId = WORKSPACE) { - const runtime = new OrcaRuntimeService(null) + const runtime = new OrcaRuntimeService(folderWorkspaceStore()) runtime.attachWindow(1) const handle = runtime.preAllocateHandleForPty(ptyId) runtime.registerPty(ptyId, worktreeId, connectionId, { @@ -22,7 +53,6 @@ function fixture(ptyId = PTY, connectionId: string | null = null, worktreeId = W tabs: [{ tabId: 'file-tab', worktreeId, title: '', activeLeafId: LEAF, layout: null }], leaves: [{ tabId: 'file-tab', worktreeId, leafId: LEAF, paneRuntimeId: 1, ptyId }] }) - runtime['resolveWorktreeSelector'] = vi.fn().mockResolvedValue({ path: '/workspace' }) return { runtime, handle } } @@ -39,7 +69,6 @@ describe('Kitty file execution-host ownership', () => { expect(await runtime.readKittyFile(handle, request, controller.signal)).toEqual({ base64: 'aW1hZ2U=' }) - expect(runtime['resolveWorktreeSelector']).toHaveBeenCalledWith(`id:${WORKSPACE}`) expect(readFile).toHaveBeenCalledWith( request, { workspacePath: '/workspace', wslDistro: 'Ubuntu' }, @@ -51,7 +80,7 @@ describe('Kitty file execution-host ownership', () => { 'reads floating-terminal temporary files in namespace %s', async (distro) => { const { runtime, handle } = fixture(PTY, null, FLOATING_TERMINAL_WORKTREE_ID) - runtime['resolveWorktreeSelector'] = vi + runtime['resolveGitWorktreeSelector'] = vi .fn() .mockRejectedValue(new Error('selector_not_found')) if (distro) { @@ -60,7 +89,7 @@ describe('Kitty file execution-host ownership', () => { expect(await runtime.readKittyFile(handle, { path: '/tmp/image' })).toEqual({ base64: 'aW1hZ2U=' }) - expect(runtime['resolveWorktreeSelector']).not.toHaveBeenCalled() + expect(runtime['resolveGitWorktreeSelector']).not.toHaveBeenCalled() expect(readFile).toHaveBeenCalledWith( { path: '/tmp/image' }, { workspacePath: null, wslDistro: distro }, @@ -70,8 +99,7 @@ describe('Kitty file execution-host ownership', () => { ) it('does not treat an unresolved project as a floating terminal', async () => { - const { runtime, handle } = fixture() - runtime['resolveWorktreeSelector'] = vi.fn().mockRejectedValue(new Error('selector_not_found')) + const { runtime, handle } = fixture(PTY, null, 'folder:deleted-folder') await expect(runtime.readKittyFile(handle, { path: '/tmp/image' })).rejects.toThrow( 'selector_not_found' ) diff --git a/src/main/runtime/orca-runtime-read-kitty-file.ts b/src/main/runtime/orca-runtime-read-kitty-file.ts index 2559cecfdf9..c66d9c868c8 100644 --- a/src/main/runtime/orca-runtime-read-kitty-file.ts +++ b/src/main/runtime/orca-runtime-read-kitty-file.ts @@ -29,7 +29,7 @@ export abstract class OrcaRuntimeWithReadKittyFile extends RuntimeWorkspaceSelec } const workspacePath = isFloatingWorkspaceId(pty.worktreeId) ? null - : (await this.resolveWorktreeSelector(`id:${pty.worktreeId}`)).path + : (await this.resolveWorkspaceSelector(`id:${pty.worktreeId}`)).path const result = await readTerminalKittyFile( request, { diff --git a/src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts b/src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts index fe01d86865c..6040dbcd0e8 100644 --- a/src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts +++ b/src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts @@ -590,7 +590,7 @@ describe('launch runtime dispatch and allocation phases', () => { Object.defineProperty(runtime, 'store', { value: { getRepos: () => [repo], getRepo: () => repo } }) - Object.defineProperty(runtime, 'resolveWorktreeSelector', { + Object.defineProperty(runtime, 'resolveGitWorktreeSelector', { value: async () => ({ id: 'repo::workspace', repoId: 'repo', path: '/workspace' }) }) const events: string[] = [] diff --git a/src/main/runtime/project-group-folder-workspace-selectors.test.ts b/src/main/runtime/project-group-folder-workspace-selectors.test.ts new file mode 100644 index 00000000000..19afac301c1 --- /dev/null +++ b/src/main/runtime/project-group-folder-workspace-selectors.test.ts @@ -0,0 +1,194 @@ +import { tmpdir } from 'node:os' +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { folderWorkspaceKey } from '../../shared/workspace-scope' +import { OrcaRuntimeService } from './orca-runtime' +import { setRuntimeBrowserCommandsFactory } from './runtime-browser-commands-factory' + +// STA-12562: project-group folder workspaces have no repo row, so a Git-only selector never finds them. +const FOLDER_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const WORKSPACE_ID = folderWorkspaceKey(FOLDER_ID) +// Browser workspaces require the folder on disk. +const FOLDER_PATH = tmpdir() + +const connectedLinearWorkspaces = vi.hoisted(() => { + const current: { id: string; organizationUrlKey: string }[] = [] + return { current } +}) +vi.mock('../linear/issue-context/client', async (importOriginal) => ({ + ...(await importOriginal()), + getConnectedWorkspaces: () => connectedLinearWorkspaces.current +})) + +function createRuntime( + activeWorktreeId: string | null = null, + linkedTask: Record | null = null, + setWorktreeMeta: (...args: unknown[]) => unknown = () => ({}) +) { + const folder = { + id: FOLDER_ID, + projectGroupId: 'group-1', + name: 'home', + folderPath: FOLDER_PATH, + linkedTask, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture supplies only the store reads these commands make. + const store = withDurableRuntimeStore({ + getRepos: () => [], + getRepo: () => undefined, + getProjectGroups: () => [ + { id: 'group-1', name: 'Home', parentPath: FOLDER_PATH, connectionId: null } + ], + getFolderWorkspaces: () => [folder], + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta, + getWorkspaceSession: () => ({ ...getDefaultWorkspaceSession(), activeWorktreeId }), + setWorkspaceSession: () => {}, + readTerminalScrollbackSnapshotAsync: async () => null, + statTerminalScrollbackSnapshotSize: async () => null, + flushOrThrow: () => {} + }) as never + const runtime = new OrcaRuntimeService(store) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake implements only the controller calls these commands make. + runtime.setPtyController({ + write: () => true, + kill: () => true, + stopAndWait: async () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + } as never) + return runtime +} + +describe('workspace commands on a project-group folder workspace', () => { + it('shows it (orca worktree show)', async () => { + const shown = await createRuntime().showManagedWorktree(`id:${WORKSPACE_ID}`) + + expect(shown.id).toBe(WORKSPACE_ID) + expect(shown.path).toBe(FOLDER_PATH) + }) + + it('sleeps it (worktree.sleep)', async () => { + const result = await createRuntime().sleepManagedWorktree(`id:${WORKSPACE_ID}`) + + expect(result.worktreeId).toBe(WORKSPACE_ID) + }) + + it('lists its terminals by its bare workspace key', async () => { + const listed = await createRuntime().listTerminals(WORKSPACE_ID) + + expect(listed.terminals).toEqual([]) + }) + + it('resolves its active terminal instead of rejecting the workspace', async () => { + await expect(createRuntime().resolveActiveTerminal(`id:${WORKSPACE_ID}`)).rejects.toThrow( + 'no_active_terminal' + ) + }) + + it('reads its stored scrollback instead of rejecting the workspace', async () => { + await expect( + createRuntime().readTerminalScrollbackForClient({ + worktree: `id:${WORKSPACE_ID}`, + ref: `v1-${'0'.repeat(32)}` + }) + ).rejects.toThrow('terminal_scrollback_not_found') + }) + + it('gives plugins its context while it is the active workspace', async () => { + const context = await createRuntime(WORKSPACE_ID).resolveActiveWorktreeContext() + + expect(context).toMatchObject({ worktreeId: WORKSPACE_ID, path: FOLDER_PATH }) + }) +}) + +function runtimeWithFolderTerminal( + linkedTask: Record, + setWorktreeMeta?: (...args: unknown[]) => unknown +) { + const runtime = createRuntime(null, linkedTask, setWorktreeMeta) + const leafId = '22222222-2222-4222-8222-222222222222' + runtime.attachWindow(1) + const handle = runtime.preAllocateHandleForPty('folder-pty') + runtime.registerPty('folder-pty', WORKSPACE_ID, null, { tabId: 'tab-1', leafId }) + runtime.syncWindowGraph(1, { + tabs: [ + { tabId: 'tab-1', worktreeId: WORKSPACE_ID, title: '', activeLeafId: leafId, layout: null } + ], + leaves: [ + { tabId: 'tab-1', worktreeId: WORKSPACE_ID, leafId, paneRuntimeId: 1, ptyId: 'folder-pty' } + ] + }) + return { runtime, handle } +} + +const LINKED_LINEAR_TASK = { + provider: 'linear', + type: 'issue', + number: 1, + title: 'Fix it', + url: 'https://linear.app/acme/issue/ENG-1', + linearIdentifier: 'ENG-1' +} + +describe('Linear --current from a project-group folder workspace terminal', () => { + it('reads the folder workspace’s linked Linear issue', async () => { + const { runtime, handle } = runtimeWithFolderTerminal({ + ...LINKED_LINEAR_TASK, + linearWorkspaceId: 'linear-workspace-1' + }) + + const link = await runtime.linearCommands.linearResolveCurrentIssue({ terminalHandle: handle }) + + expect(link.identifier).toBe('ENG-1') + }) + + it('answers a legacy link without writing worktree metadata for the folder', async () => { + connectedLinearWorkspaces.current = [{ id: 'linear-workspace-1', organizationUrlKey: 'acme' }] + const setWorktreeMeta = vi.fn() + const { runtime, handle } = runtimeWithFolderTerminal( + { ...LINKED_LINEAR_TASK, linearOrganizationUrlKey: 'acme' }, + setWorktreeMeta + ) + + const link = await runtime.linearCommands.linearResolveCurrentIssue({ terminalHandle: handle }) + + expect(link.workspaceId).toBe('linear-workspace-1') + expect(setWorktreeMeta).not.toHaveBeenCalled() + connectedLinearWorkspaces.current = [] + }) +}) + +describe('browser commands on a project-group folder workspace', () => { + beforeAll(async () => { + // Install browser RPCs as Electron does; plain Node hosts leave them unavailable. + const { RuntimeBrowserCommands } = await import('./browser/browser-commands') + setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host)) + return () => setRuntimeBrowserCommandsFactory(null) + }) + + it('lists its browser tabs through the agent browser bridge', async () => { + const runtime = createRuntime() + const tabList = vi.fn(() => ({ tabs: [] })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: listing reads only these bridge methods. + runtime.setAgentBrowserBridge({ + tabList, + getRegisteredTabs: () => new Map(), + getActivePageId: () => null + } as never) + + await expect(runtime.browserTabList({ worktree: `id:${WORKSPACE_ID}` })).resolves.toEqual({ + tabs: [] + }) + }) +}) diff --git a/src/main/runtime/queued-terminal-graph-exit.test.ts b/src/main/runtime/queued-terminal-graph-exit.test.ts index a8a7be5d92e..2220ef7950e 100644 --- a/src/main/runtime/queued-terminal-graph-exit.test.ts +++ b/src/main/runtime/queued-terminal-graph-exit.test.ts @@ -9,8 +9,8 @@ const FIRST = '10000000-0000-4000-8000-000000000003' const NEXT = '10000000-0000-4000-8000-000000000004' class ExitAuthorityRuntime extends OrcaRuntimeService { - override resolveWorktreeSelector(selector: string) { - return super.resolveWorktreeSelector(selector) + override resolveGitWorktreeSelector(selector: string) { + return super.resolveGitWorktreeSelector(selector) } override getResolvedWorktreeMap() { @@ -113,7 +113,7 @@ describe('host exit authority over queued renderer graphs', () => { childWorktreeIds: [], lineage: null } - const resolve = vi.spyOn(runtime, 'resolveWorktreeSelector').mockResolvedValue(worktree) + const resolve = vi.spyOn(runtime, 'resolveGitWorktreeSelector').mockResolvedValue(worktree) const map = vi .spyOn(runtime, 'getResolvedWorktreeMap') .mockResolvedValue(new Map([[WORKTREE, worktree]])) 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 cedcf452c89..add4e959fbf 100644 --- a/src/main/runtime/runtime-created-worktree-terminal-target.test.ts +++ b/src/main/runtime/runtime-created-worktree-terminal-target.test.ts @@ -39,7 +39,7 @@ class StartupRuntime extends OrcaRuntimeService { return this.repoLookup(selector) } - protected override resolveWorktreeSelector(selector: string): Promise { + protected override resolveGitWorktreeSelector(selector: string): Promise { return this.lookup(selector) } } @@ -285,7 +285,7 @@ describe('workspace activation and metadata reentry', () => { super(store) } - override async resolveWorktreeSelector(): Promise { + override async resolveGitWorktreeSelector(): Promise { return this.selectedWorktree } diff --git a/src/main/runtime/runtime-file-target-execution-host.test.ts b/src/main/runtime/runtime-file-target-execution-host.test.ts index 99d4a10c029..686e8934efa 100644 --- a/src/main/runtime/runtime-file-target-execution-host.test.ts +++ b/src/main/runtime/runtime-file-target-execution-host.test.ts @@ -25,10 +25,6 @@ import { OrcaRuntimeService } from './orca-runtime' const REMOTE_PATH = '/srv/app-feature' const WORKTREE_ID = 'repo-shared::/srv/app-feature' -type RuntimeInternals = { - resolveWorktreeSelector: (selector: string) => Promise -} - function makeRuntime(repos: readonly Record[], hostId?: string) { const store = { getSettings: () => ({ @@ -42,7 +38,7 @@ function makeRuntime(repos: readonly Record[], hostId?: string) getRepo: (id: string) => repos.find((repo) => repo.id === id) } const runtime = new OrcaRuntimeService(store as never) - vi.spyOn(runtime as unknown as RuntimeInternals, 'resolveWorktreeSelector').mockResolvedValue({ + runtime['resolveGitWorktreeSelector'] = vi.fn().mockResolvedValue({ id: WORKTREE_ID, repoId: 'repo-shared', path: REMOTE_PATH, diff --git a/src/main/runtime/runtime-git-target-execution-host.test.ts b/src/main/runtime/runtime-git-target-execution-host.test.ts index f04efbd6a4b..768dbb56ff6 100644 --- a/src/main/runtime/runtime-git-target-execution-host.test.ts +++ b/src/main/runtime/runtime-git-target-execution-host.test.ts @@ -24,10 +24,6 @@ import { OrcaRuntimeService } from './orca-runtime' const REMOTE_PATH = '/srv/app-feature' const WORKTREE_ID = 'repo-shared::/srv/app-feature' -type RuntimeInternals = { - resolveWorktreeSelector: (selector: string) => Promise -} - function makeRuntime(repos: readonly Record[], hostId?: string) { const store = { getSettings: () => ({ disabledTuiAgents: [], workspaceDir: '/tmp/workspaces' }), @@ -37,7 +33,7 @@ function makeRuntime(repos: readonly Record[], hostId?: string) getRepo: (id: string) => repos.find((repo) => repo.id === id) } const runtime = new OrcaRuntimeService(store as never) - vi.spyOn(runtime as unknown as RuntimeInternals, 'resolveWorktreeSelector').mockResolvedValue({ + runtime['resolveGitWorktreeSelector'] = vi.fn().mockResolvedValue({ id: WORKTREE_ID, repoId: 'repo-shared', path: REMOTE_PATH, diff --git a/src/main/runtime/runtime-linear-command-base.ts b/src/main/runtime/runtime-linear-command-base.ts index f2bce94f616..6a09b1b241d 100644 --- a/src/main/runtime/runtime-linear-command-base.ts +++ b/src/main/runtime/runtime-linear-command-base.ts @@ -6,7 +6,7 @@ import { RuntimeLinearBrowseCommands } from './runtime-linear-browse-commands' export type RuntimeLinearCommandPorts = { runtimeAvailable: () => boolean showTerminal: (handle: string) => Promise - resolveWorktreeSelector: (selector: string) => Promise + resolveWorkspaceSelector: (selector: string) => Promise listResolvedWorktrees: () => Promise setWorktreeMeta: ( worktree: Pick, @@ -32,8 +32,8 @@ export class RuntimeLinearCommandBase extends RuntimeLinearBrowseCommands { return this.ports.showTerminal(handle) } - protected resolveWorktreeSelector(selector: string): Promise { - return this.ports.resolveWorktreeSelector(selector) + protected resolveWorkspaceSelector(selector: string): Promise { + return this.ports.resolveWorkspaceSelector(selector) } protected listResolvedWorktrees(): Promise { diff --git a/src/main/runtime/runtime-linear-comment-edit.test.ts b/src/main/runtime/runtime-linear-comment-edit.test.ts index 367ff308f04..7d86e22c69d 100644 --- a/src/main/runtime/runtime-linear-comment-edit.test.ts +++ b/src/main/runtime/runtime-linear-comment-edit.test.ts @@ -89,7 +89,7 @@ function createCommands(emitClientEvent = vi.fn()): RuntimeLinearReadCommands { showTerminal: async () => { throw new Error('unused') }, - resolveWorktreeSelector: async () => { + resolveWorkspaceSelector: async () => { throw new Error('unused') }, listResolvedWorktrees: async () => [worktree], diff --git a/src/main/runtime/runtime-linear-read-commands.test.ts b/src/main/runtime/runtime-linear-read-commands.test.ts index b2c0d0db868..c64e5888e97 100644 --- a/src/main/runtime/runtime-linear-read-commands.test.ts +++ b/src/main/runtime/runtime-linear-read-commands.test.ts @@ -16,7 +16,7 @@ function createCommands(): RuntimeLinearReadCommands { showTerminal: async () => { throw new Error('unused') }, - resolveWorktreeSelector: async () => { + resolveWorkspaceSelector: async () => { throw new Error('unused') }, listResolvedWorktrees: async () => [], diff --git a/src/main/runtime/runtime-linear-team-write-commands.ts b/src/main/runtime/runtime-linear-team-write-commands.ts index 848a3d78912..d921b8c5a80 100644 --- a/src/main/runtime/runtime-linear-team-write-commands.ts +++ b/src/main/runtime/runtime-linear-team-write-commands.ts @@ -17,6 +17,7 @@ import type { import type { ResolvedWorktree } from './runtime-worktree-path-identity' import { RuntimeLinearDedupeCommands } from './runtime-linear-dedupe-commands' import { isWslMoveWorktreeBlocked } from '../persistence/moving-wsl-projects/wsl-move-background-backoff' +import { parseWorkspaceKey } from '../../shared/workspace-scope' export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands { public parseLinearAttachmentUrl(value: string): URL { @@ -232,7 +233,7 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands 'The provided Linear worktree context does not match the caller terminal.' ) } - worktree = await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`) + worktree = await this.resolveWorkspaceSelector(`id:${terminal.worktreeId}`) } catch (error) { if (error instanceof LinearAgentAccessError) { throw error @@ -270,8 +271,11 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands worktree.linkedLinearIssueOrganizationUrlKey ) if (backfill?.workspaceId) { - // The answer still serves this lookup; persisting it waits out a WSL move. - if (!isWslMoveWorktreeBlocked(worktree.id, worktree.hostId, worktree.path)) { + // The answer still serves this lookup; persisting it waits out a WSL move. Folder workspaces keep links on their own row, not in worktree metadata. + if ( + parseWorkspaceKey(worktree.id)?.type !== 'folder' && + !isWslMoveWorktreeBlocked(worktree.id, worktree.hostId, worktree.path) + ) { this.setWorktreeMeta(worktree, { linkedLinearIssueWorkspaceId: backfill.workspaceId, linkedLinearIssueOrganizationUrlKey: backfill.organizationUrlKey ?? null diff --git a/src/main/runtime/runtime-skill-install-authority.test.ts b/src/main/runtime/runtime-skill-install-authority.test.ts index 5a845a231ea..262a4a36f9a 100644 --- a/src/main/runtime/runtime-skill-install-authority.test.ts +++ b/src/main/runtime/runtime-skill-install-authority.test.ts @@ -40,4 +40,14 @@ describe('createSkillInstallAuthority', () => { await expect(authority.resolveWorktree(WORKTREE_ID)).resolves.toBeNull() }) + + it('never resolves a folder workspace as a worktree, where only repo rows fence the host', async () => { + const authority = createSkillInstallAuthority( + createHost({ + showManagedWorktree: async () => ({ id: 'folder:remote-folder', path: '/srv/remote' }) + }) + ) + + await expect(authority.resolveWorktree('folder:remote-folder')).resolves.toBeNull() + }) }) diff --git a/src/main/runtime/runtime-skill-install-authority.ts b/src/main/runtime/runtime-skill-install-authority.ts index 08437549578..f3e1bdb4235 100644 --- a/src/main/runtime/runtime-skill-install-authority.ts +++ b/src/main/runtime/runtime-skill-install-authority.ts @@ -10,6 +10,7 @@ import { type ExecutionHostId } from '../../shared/execution-host' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { parseWorkspaceKey } from '../../shared/workspace-scope' import type { SkillInstallDestinationAuthority } from '../skills/skill-install-destinations' import { resolveEnvironmentSkillProviderRoots, @@ -110,6 +111,10 @@ export function createSkillInstallAuthority( environmentId: host.getRuntimeId(), homeDirectory: homedir(), resolveWorktree: async (id) => { + // Folder workspaces arrive as folderWorkspaceId, whose own host fence applies; repo rows cannot fence them. + if (parseWorkspaceKey(id)?.type === 'folder') { + return null + } const repos = host .listRepos() .filter((candidate) => candidate.id === getRepoIdFromWorktreeId(id)) diff --git a/src/main/runtime/session-graph/session-graph-authority.ts b/src/main/runtime/session-graph/session-graph-authority.ts index 8b499098f95..75bff5217f6 100644 --- a/src/main/runtime/session-graph/session-graph-authority.ts +++ b/src/main/runtime/session-graph/session-graph-authority.ts @@ -166,7 +166,7 @@ export abstract class OrcaRuntimeWithTransitionGraphReloadToTerminalState extend : this.resolveFolderWorkspaceSelector(selector) return folderWorkspace ? this.folderWorkspaceToResolvedWorktree(folderWorkspace) - : this.resolveWorktreeSelector(selector) + : this.resolveGitWorktreeSelector(selector) } protected async resolveEmulatorWorkspaceId(selector: string): Promise { diff --git a/src/main/runtime/session-graph/session-graph-reload-lifecycle.ts b/src/main/runtime/session-graph/session-graph-reload-lifecycle.ts index dc828c1b1a5..52131fd3d8a 100644 --- a/src/main/runtime/session-graph/session-graph-reload-lifecycle.ts +++ b/src/main/runtime/session-graph/session-graph-reload-lifecycle.ts @@ -57,7 +57,7 @@ export abstract class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntime async hasTerminalsForWorktree(worktreeSelector: string): Promise { const graphEpoch = this.captureReadyGraphEpoch() - const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const worktree = await this.resolveWorkspaceSelector(worktreeSelector) this.assertStableReadyGraph(graphEpoch) for (const leaf of this.leaves.values()) { if (leaf.worktreeId === worktree.id && leaf.ptyId) { diff --git a/src/main/runtime/session-lifecycle/close/close-mobile-session-tab.ts b/src/main/runtime/session-lifecycle/close/close-mobile-session-tab.ts index 9dfa20ec603..d8b902b22ff 100644 --- a/src/main/runtime/session-lifecycle/close/close-mobile-session-tab.ts +++ b/src/main/runtime/session-lifecycle/close/close-mobile-session-tab.ts @@ -42,7 +42,7 @@ export abstract class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWi const graphEpoch = options.clientNavigationId ? this.captureReadyGraphEpoch() : null const explicitWorktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector) const worktreeId = - explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + explicitWorktreeId ?? (await this.resolveWorkspaceSelector(worktreeSelector)).id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) const observedPtyIds = await this.refreshMobileSessionPtyRecords() if (graphEpoch !== null) { diff --git a/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts b/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts index 194891c629b..a98a9783e49 100644 --- a/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts +++ b/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts @@ -130,7 +130,8 @@ export abstract class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh exten const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all') const targetsHost = navigationTargetsHost(navigation) const explicitWorktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector) - let worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + let worktreeId = + explicitWorktreeId ?? (await this.resolveWorkspaceSelector(worktreeSelector)).id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) await this.refreshMobileSessionPtyRecords(worktreeId) // Why: the renderer can publish the host's spelling while inventory refresh is pending. diff --git a/src/main/runtime/session-tabs/headless-pane-move-to-new-tab.ts b/src/main/runtime/session-tabs/headless-pane-move-to-new-tab.ts index 7f31bdd20fa..10076d2a96f 100644 --- a/src/main/runtime/session-tabs/headless-pane-move-to-new-tab.ts +++ b/src/main/runtime/session-tabs/headless-pane-move-to-new-tab.ts @@ -29,7 +29,7 @@ export abstract class OrcaRuntimeWithHeadlessPaneMoveToNewTab extends OrcaRuntim ): Promise { const explicitWorktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector) const worktreeId = - explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + explicitWorktreeId ?? (await this.resolveWorkspaceSelector(worktreeSelector)).id if (this.getAvailableAuthoritativeWindow()) { return { status: 'not_held' } } diff --git a/src/main/runtime/session-tabs/headless-tab-properties.ts b/src/main/runtime/session-tabs/headless-tab-properties.ts index 8f22616b296..e3e58dfccbd 100644 --- a/src/main/runtime/session-tabs/headless-tab-properties.ts +++ b/src/main/runtime/session-tabs/headless-tab-properties.ts @@ -40,7 +40,7 @@ export abstract class RuntimeHeadlessTabProperties extends OrcaRuntimeWithHeadle ): Promise<{ updated: true }> { const explicitWorktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector) const worktreeId = - explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + explicitWorktreeId ?? (await this.resolveWorkspaceSelector(worktreeSelector)).id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) // Attached renderers own and republish props; headless writes would be overwritten. const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) diff --git a/src/main/runtime/session-tabs/headless-terminal-tab-close.ts b/src/main/runtime/session-tabs/headless-terminal-tab-close.ts index 75035b9cbc8..8446eff1c66 100644 --- a/src/main/runtime/session-tabs/headless-terminal-tab-close.ts +++ b/src/main/runtime/session-tabs/headless-terminal-tab-close.ts @@ -152,7 +152,7 @@ export abstract class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends Orca ): Promise { const explicitWorktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector) const worktreeId = - explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + explicitWorktreeId ?? (await this.resolveWorkspaceSelector(worktreeSelector)).id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (!snapshot) { @@ -209,7 +209,7 @@ export abstract class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends Orca ): Promise<{ updated: true }> { const explicitWorktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector) const worktreeId = - explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + explicitWorktreeId ?? (await this.resolveWorkspaceSelector(worktreeSelector)).id // Resolve legacy raw ids to the host tab id, matching tab-prop persistence. const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const hostTabId = snapshot diff --git a/src/main/runtime/session-tabs/session-tab-prototype-dispatch.test.ts b/src/main/runtime/session-tabs/session-tab-prototype-dispatch.test.ts index 735fd22eb86..9cb135ea33e 100644 --- a/src/main/runtime/session-tabs/session-tab-prototype-dispatch.test.ts +++ b/src/main/runtime/session-tabs/session-tab-prototype-dispatch.test.ts @@ -249,7 +249,7 @@ class SessionRuntime extends OrcaRuntimeService { restoreRequests = 0 restoreResult = Promise.resolve() - override async resolveWorktreeSelector(): Promise { + override async resolveGitWorktreeSelector(): Promise { const git = { path: 'workspace', head: '', diff --git a/src/main/runtime/session-tabs/session-tabs-change-publication.ts b/src/main/runtime/session-tabs/session-tabs-change-publication.ts index b243d7bff91..207244dd5da 100644 --- a/src/main/runtime/session-tabs/session-tabs-change-publication.ts +++ b/src/main/runtime/session-tabs/session-tabs-change-publication.ts @@ -168,7 +168,7 @@ export abstract class OrcaRuntimeWithScheduleMobileSessionTabsChanged extends Or ): Promise { const worktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector) ?? - (await this.resolveWorktreeSelector(worktreeSelector)).id + (await this.resolveWorkspaceSelector(worktreeSelector)).id const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const tab = snapshot?.tabs.find( (candidate): candidate is RuntimeMobileSessionMarkdownTab => diff --git a/src/main/runtime/session-tabs/session-tabs-inventory.ts b/src/main/runtime/session-tabs/session-tabs-inventory.ts index 545f0188aa4..8d242e7c0de 100644 --- a/src/main/runtime/session-tabs/session-tabs-inventory.ts +++ b/src/main/runtime/session-tabs/session-tabs-inventory.ts @@ -195,7 +195,7 @@ export abstract class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees e this.restoreLivePairedRendererSessionOwnedMobileTerminals(resolvedId) return this.getMobileSessionTabsForWorktree(resolvedId, clientNavigationId, options) } - const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const worktree = await this.resolveWorkspaceSelector(worktreeSelector) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, { allowAttachedWindow: true, onlyRuntimeOwnedTerminals: true diff --git a/src/main/runtime/session-tabs/structured-session-tab-restoration.ts b/src/main/runtime/session-tabs/structured-session-tab-restoration.ts index fe27452ef4c..a33e95c92e7 100644 --- a/src/main/runtime/session-tabs/structured-session-tab-restoration.ts +++ b/src/main/runtime/session-tabs/structured-session-tab-restoration.ts @@ -267,7 +267,7 @@ export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce exten return { repo, repoPath: repo.path } } - const worktree = await this.resolveWorktreeSelector(args.worktreeSelector) + const worktree = await this.resolveGitWorktreeSelector(args.worktreeSelector) if (worktree.repoId !== repo.id) { throw new Error('Access denied: worktree does not belong to repository') } diff --git a/src/main/runtime/single-page-browser-commands-host-test-double.ts b/src/main/runtime/single-page-browser-commands-host-test-double.ts index 59325cda959..5ec44b0390c 100644 --- a/src/main/runtime/single-page-browser-commands-host-test-double.ts +++ b/src/main/runtime/single-page-browser-commands-host-test-double.ts @@ -35,7 +35,7 @@ export function createSinglePageBrowserCommandsHost( } as unknown as AgentBrowserBridge // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: page commands read only these host members. return { - resolveWorktreeSelector: async () => ({ id: 'wt-1' }), + resolveWorkspaceSelector: async () => ({ id: 'wt-1' }), getAgentBrowserBridge: () => bridge, getRuntimeBrowserPageRegistry: () => runtimeBrowserPages, getAvailableAuthoritativeWindow: vi.fn(() => window), diff --git a/src/main/runtime/structured-worker-mail-routing.test.ts b/src/main/runtime/structured-worker-mail-routing.test.ts index 5484f3b7b7e..ca61ab092cc 100644 --- a/src/main/runtime/structured-worker-mail-routing.test.ts +++ b/src/main/runtime/structured-worker-mail-routing.test.ts @@ -120,7 +120,7 @@ describe('implicit sender resolution refuses to guess', () => { return { graphStatus: 'ready', assertGraphReady: () => {}, - resolveWorktreeSelector: async () => ({ id: 'wt_1' }), + resolveWorkspaceSelector: async () => ({ id: 'wt_1' }), tabs: new Map(), leaves: new Map( leafIds.map((leafId) => [leafId, { tabId: 'tab_1', leafId, worktreeId: 'wt_1' }]) @@ -148,7 +148,7 @@ describe('implicit sender resolution refuses to guess', () => { // Keep a focused terminal in the starting graph so only the pre-ready identity guard can refuse this request. const preReady = { graphStatus: 'starting', - resolveWorktreeSelector: async () => ({ id: 'wt_1' }), + resolveWorkspaceSelector: async () => ({ id: 'wt_1' }), getMobileSessionTabsForWorktree: () => ({ tabs: [{ type: 'terminal', isActive: true, status: 'ready', terminal: 'term_focused' }] }), diff --git a/src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts b/src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts index e8726338da4..74eb0f78857 100644 --- a/src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts +++ b/src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts @@ -116,7 +116,7 @@ export abstract class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends O ): Promise { if (this.graphStatus !== 'ready') { const targetWorktreeId = worktreeSelector - ? (await this.resolveWorktreeSelector(worktreeSelector)).id + ? (await this.resolveWorkspaceSelector(worktreeSelector)).id : null const snapshots = targetWorktreeId ? [this.getMobileSessionTabsForWorktree(targetWorktreeId)] @@ -150,7 +150,7 @@ export abstract class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends O this.assertGraphReady() const targetWorktreeId = worktreeSelector - ? (await this.resolveWorktreeSelector(worktreeSelector)).id + ? (await this.resolveWorkspaceSelector(worktreeSelector)).id : null // Prefer last-focused panes only outside identity claims. diff --git a/src/main/runtime/terminal/driver/terminal-driver-resources.ts b/src/main/runtime/terminal/driver/terminal-driver-resources.ts index b73cf4650fc..e4ec7374429 100644 --- a/src/main/runtime/terminal/driver/terminal-driver-resources.ts +++ b/src/main/runtime/terminal/driver/terminal-driver-resources.ts @@ -163,8 +163,8 @@ export abstract class OrcaRuntimeWithTerminalDrivers extends OrcaRuntimeWithFitO }, browserHost: { getAgentBrowserBridge: () => this.agentBrowserBridge, - resolveWorktreeSelector: (selector) => - resolveRemoteCliBrowserScopeSelector(selector) ?? this.resolveWorktreeSelector(selector), + resolveWorkspaceSelector: (selector) => + resolveRemoteCliBrowserScopeSelector(selector) ?? this.resolveWorkspaceSelector(selector), resolveBrowserWorkspace: (selector) => resolveRemoteCliBrowserScopeSelector(selector) ?? this.resolveBrowserWorkspace(selector), getBrowserHostLeaseRegistry: () => getBrowserHostLeaseRegistry(this), diff --git a/src/main/runtime/terminal/launch/mobile-terminal-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-create.ts index d47e68c8c11..952353dbc8b 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-create.ts @@ -132,7 +132,7 @@ export abstract class OrcaRuntimeWithCreateMobileSessionTerminal extends OrcaRun if (select) { const worktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector) ?? - (await this.resolveWorktreeSelector(worktreeSelector)).id + (await this.resolveWorkspaceSelector(worktreeSelector)).id this.applyMobileSessionTabNavigation( this.getMobileSessionTabsForWorktree(worktreeId), result.tab.id, diff --git a/src/main/runtime/terminal/launch/terminal-create-deduplication.ts b/src/main/runtime/terminal/launch/terminal-create-deduplication.ts index 36f6acc4107..53a84789966 100644 --- a/src/main/runtime/terminal/launch/terminal-create-deduplication.ts +++ b/src/main/runtime/terminal/launch/terminal-create-deduplication.ts @@ -158,7 +158,7 @@ export abstract class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRun suppressStartupUpdatePrompt?: boolean } ): Promise { - const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const worktree = await this.resolveGitWorktreeSelector(worktreeSelector) // Repo ids are host-blind; resolve against the worktree owner before quoting its launch (#11163). const resolution = resolveWorktreeLaunchHost(this.store?.getRepos() ?? [], worktree) if (resolution.kind === 'ambiguous') { diff --git a/src/main/runtime/terminal/launch/terminal-workspace-scope.ts b/src/main/runtime/terminal/launch/terminal-workspace-scope.ts index e1df765a7ea..bc5505ef747 100644 --- a/src/main/runtime/terminal/launch/terminal-workspace-scope.ts +++ b/src/main/runtime/terminal/launch/terminal-workspace-scope.ts @@ -156,7 +156,7 @@ export abstract class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktr const worktreeSelector = parsed?.type === 'worktree' ? `id:${parsed.worktreeId}` : selector const worktree = resolveCreatedWorktreeTerminalTarget(this.store, selector, createdWorktree) ?? - (await this.resolveWorktreeSelector(worktreeSelector)) + (await this.resolveGitWorktreeSelector(worktreeSelector)) // Repo ids are host-blind; resolve the worktree owner or refuse as unresolved rather than spawn locally with a remote cwd (#11163). const resolution = resolveWorktreeLaunchHost(this.store?.getRepos() ?? [], worktree) if (resolution.kind === 'ambiguous') { diff --git a/src/main/runtime/terminal/layout/terminal-resize-listeners.ts b/src/main/runtime/terminal/layout/terminal-resize-listeners.ts index d680df3ca93..b47a5263bf2 100644 --- a/src/main/runtime/terminal/layout/terminal-resize-listeners.ts +++ b/src/main/runtime/terminal/layout/terminal-resize-listeners.ts @@ -224,7 +224,7 @@ export abstract class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRunti return this.runWorktreeTerminalMutation(workspace.id, async () => { const resolvedWorkspace = workspace.folderWorkspace ? this.folderWorkspaceToResolvedWorktree(workspace.folderWorkspace) - : await this.resolveWorktreeSelector(`id:${workspace.id}`) + : await this.resolveGitWorktreeSelector(`id:${workspace.id}`) const inventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( [resolvedWorkspace], workspace.id, diff --git a/src/main/runtime/terminal/output/terminal-orchestration-authority.ts b/src/main/runtime/terminal/output/terminal-orchestration-authority.ts index 72407433b5c..f45b48df321 100644 --- a/src/main/runtime/terminal/output/terminal-orchestration-authority.ts +++ b/src/main/runtime/terminal/output/terminal-orchestration-authority.ts @@ -204,7 +204,7 @@ export abstract class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends O { store: this.store, resolveRepo: (selector) => this.resolveRepoSelector(selector), - resolveWorktree: (selector) => this.resolveWorktreeSelector(selector) + resolveWorktree: (selector) => this.resolveGitWorktreeSelector(selector) }, worktreeId, repoId diff --git a/src/main/runtime/terminal/stop/exact-terminal-stop-liveness.test.ts b/src/main/runtime/terminal/stop/exact-terminal-stop-liveness.test.ts index 551d5908b1e..6e53df55d5c 100644 --- a/src/main/runtime/terminal/stop/exact-terminal-stop-liveness.test.ts +++ b/src/main/runtime/terminal/stop/exact-terminal-stop-liveness.test.ts @@ -8,8 +8,8 @@ const LEAF = '10000000-0000-4000-8000-000000000002' const INCARNATION = '10000000-0000-4000-8000-000000000003' class ExactStopRuntime extends OrcaRuntimeService { - override resolveWorktreeSelector(selector: string) { - return super.resolveWorktreeSelector(selector) + override resolveGitWorktreeSelector(selector: string) { + return super.resolveGitWorktreeSelector(selector) } override getResolvedWorktreeMap() { @@ -59,7 +59,7 @@ describe('terminal.stopExact post-stop verification', () => { it('verifies a confirmed stop even when an overlapping inventory voids its first check', async () => { const runtime = new ExactStopRuntime() const worktree = folderWorktree() - vi.spyOn(runtime, 'resolveWorktreeSelector').mockResolvedValue(worktree) + vi.spyOn(runtime, 'resolveGitWorktreeSelector').mockResolvedValue(worktree) vi.spyOn(runtime, 'getResolvedWorktreeMap').mockResolvedValue(new Map([[WORKTREE, worktree]])) let stopped = false let overlapped: Promise | null = null diff --git a/src/main/runtime/terminal/wait/terminal-interactive-wait.ts b/src/main/runtime/terminal/wait/terminal-interactive-wait.ts index e81d5fe5b94..32da7cac8f8 100644 --- a/src/main/runtime/terminal/wait/terminal-interactive-wait.ts +++ b/src/main/runtime/terminal/wait/terminal-interactive-wait.ts @@ -157,7 +157,7 @@ export abstract class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRunt return null } try { - const resolved = await this.resolveWorktreeSelector(`id:${worktreeId}`) + const resolved = await this.resolveWorkspaceSelector(`id:${worktreeId}`) return { worktreeId: resolved.id, path: resolved.git.path, diff --git a/src/main/runtime/workspaces/managed-workspace-operations.ts b/src/main/runtime/workspaces/managed-workspace-operations.ts index 73355ae1589..f3a97bda7da 100644 --- a/src/main/runtime/workspaces/managed-workspace-operations.ts +++ b/src/main/runtime/workspaces/managed-workspace-operations.ts @@ -146,7 +146,7 @@ export abstract class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWit } async showManagedWorktree(worktreeSelector: string) { - const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const worktree = await this.resolveWorkspaceSelector(worktreeSelector) return { ...worktree, linkedItems: getWorkspaceAttachments(worktree) } } @@ -250,7 +250,7 @@ export abstract class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWit worktreeSelector: string, opts: { replyOnAcceptance?: boolean; markChatsAsleep?: boolean } = {} ): Promise<{ worktreeId: string } & Partial> { - const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const worktree = await this.resolveWorkspaceSelector(worktreeSelector) return this.workspaceSleep.sleepOwnWorktree(worktree.id, opts) } diff --git a/src/main/runtime/workspaces/workspace-activation.ts b/src/main/runtime/workspaces/workspace-activation.ts index 0a50d6e6a85..98214c6be84 100644 --- a/src/main/runtime/workspaces/workspace-activation.ts +++ b/src/main/runtime/workspaces/workspace-activation.ts @@ -74,7 +74,7 @@ export abstract class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntime sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' }> { this.assertGraphReady() - const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const worktree = await this.resolveGitWorktreeSelector(worktreeSelector) const repo = this.store?.getRepo(worktree.repoId) if (!repo) { throw new Error('repo_not_found') diff --git a/src/main/runtime/workspaces/workspace-file-session-targets.ts b/src/main/runtime/workspaces/workspace-file-session-targets.ts index f400a1ed9fc..e6d72504844 100644 --- a/src/main/runtime/workspaces/workspace-file-session-targets.ts +++ b/src/main/runtime/workspaces/workspace-file-session-targets.ts @@ -258,7 +258,7 @@ export abstract class OrcaRuntimeWithPersistHeadlessTerminalTitle extends Runtim localGitOptions?: { wslDistro?: string } }> { const store = this.requireStore() - const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const worktree = await this.resolveGitWorktreeSelector(worktreeSelector) const routing = resolveWorktreeHostRouting(store.getRepos(), worktree) if (routing.kind === 'ambiguous') { throw new Error('worktree_execution_host_unresolved') @@ -298,7 +298,7 @@ export abstract class OrcaRuntimeWithPersistHeadlessTerminalTitle extends Runtim } const store = this.requireStore() - const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const worktree = await this.resolveGitWorktreeSelector(worktreeSelector) const routing = resolveWorktreeHostRouting(store.getRepos(), worktree) if (routing.kind === 'ambiguous') { throw new Error('worktree_execution_host_unresolved') diff --git a/src/main/runtime/workspaces/workspace-metadata.ts b/src/main/runtime/workspaces/workspace-metadata.ts index 7a12574504e..76a52a396d5 100644 --- a/src/main/runtime/workspaces/workspace-metadata.ts +++ b/src/main/runtime/workspaces/workspace-metadata.ts @@ -102,7 +102,7 @@ export abstract class OrcaRuntimeWithCreateManagedRemoteWorktree extends OrcaRun return probeRuntimeWorktreeDrift({ selector: worktreeSelector, store: this.store ? this.requireStore() : null, - resolveWorktree: (selector) => this.resolveWorktreeSelector(selector), + resolveWorktree: (selector) => this.resolveGitWorktreeSelector(selector), resolveRemoteTrackingBase: (repoPath, base, options) => this.resolveRemoteTrackingBase(repoPath, base, options), fetchRemote: (repoPath, remote, options) => @@ -128,7 +128,7 @@ export abstract class OrcaRuntimeWithCreateManagedRemoteWorktree extends OrcaRun updates, store: this.store, ports: { - resolveWorktree: (selector) => this.resolveWorktreeSelector(selector), + resolveWorktree: (selector) => this.resolveGitWorktreeSelector(selector), validateParent: (worktree, parent) => this.worktreeLineage.validateParent(worktree, parent), invalidateResolved: () => this.invalidateResolvedWorktreeCache(), invalidateScan: (repoId) => this.invalidateWorktreeScanCacheForRepo(repoId), diff --git a/src/main/runtime/workspaces/workspace-query-prototype.test.ts b/src/main/runtime/workspaces/workspace-query-prototype.test.ts index 3a41f8cb35d..0ebd21752c2 100644 --- a/src/main/runtime/workspaces/workspace-query-prototype.test.ts +++ b/src/main/runtime/workspaces/workspace-query-prototype.test.ts @@ -96,7 +96,7 @@ describe('workspace query prototype dispatch', () => { return resolved } run() { - return this.resolveWorktreeSelector(`id:${TEST_WORKTREE_ID}`) + return this.resolveGitWorktreeSelector(`id:${TEST_WORKTREE_ID}`) } } const runtime = new Probe(store) diff --git a/src/main/runtime/workspaces/workspace-removal-target.ts b/src/main/runtime/workspaces/workspace-removal-target.ts index 3f5187174ac..d866d9551ff 100644 --- a/src/main/runtime/workspaces/workspace-removal-target.ts +++ b/src/main/runtime/workspaces/workspace-removal-target.ts @@ -64,7 +64,7 @@ export abstract class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRu return resolveRuntimeWorktreeRemovalTarget({ selector: worktreeSelector, store: this.store, - resolveWorktree: (selector) => this.resolveWorktreeSelector(selector), + resolveWorktree: (selector) => this.resolveGitWorktreeSelector(selector), resolveExplicitWorktreeIdScoped: (worktreeId, hostId) => this.resolveExplicitWorktreeIdScoped(worktreeId, hostId), ...(requiredHostId ? { requiredHostId } : {}) diff --git a/src/main/runtime/workspaces/workspace-selectors.ts b/src/main/runtime/workspaces/workspace-selectors.ts index 2d4cc8225fe..912da331a6c 100644 --- a/src/main/runtime/workspaces/workspace-selectors.ts +++ b/src/main/runtime/workspaces/workspace-selectors.ts @@ -42,7 +42,8 @@ export abstract class RuntimeWorkspaceSelectors extends OrcaRuntimeWithResolveBr ...args: Parameters ): ReturnType - protected async resolveWorktreeSelector(selector: string): Promise { + /** The repo-backed catalog only: project-group folder workspaces never match. Resolve a workspace with resolveWorkspaceSelector. */ + protected async resolveGitWorktreeSelector(selector: string): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(selector) // Only id selectors name a repo; scoping other selectors would hide fleet-wide ambiguity. if (explicitWorktreeId && !this.hasFreshResolvedWorktreeCache()) { 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 bab567f58dc..c87d5988782 100644 --- a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts +++ b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts @@ -605,9 +605,7 @@ describe('scoped explicit worktree-id resolution', () => { it('scans only the owning repo for an id: selector on a cold cache', async () => { const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never) const resolve = (selector: string): Promise<{ id: string }> => - ( - runtime as unknown as { resolveWorktreeSelector: (s: string) => Promise<{ id: string }> } - ).resolveWorktreeSelector(selector) + runtime['resolveGitWorktreeSelector'](selector) const resolved = await resolve(`id:${MAIN_WORKTREE_ID}`) @@ -617,9 +615,7 @@ describe('scoped explicit worktree-id resolution', () => { it('resolves an exact canonical identity without relying on the mutable locator', async () => { const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never) const resolve = (selector: string): Promise<{ id: string }> => - ( - runtime as unknown as { resolveWorktreeSelector: (s: string) => Promise<{ id: string }> } - ).resolveWorktreeSelector(selector) + runtime['resolveGitWorktreeSelector'](selector) const identityKey = canonicalWorktreeIdentity({ worktreeId: MAIN_WORKTREE_ID, executionHostId: 'local', @@ -634,9 +630,7 @@ describe('scoped explicit worktree-id resolution', () => { it('still finds worktrees in other repos through the fleet path', async () => { const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never) const resolve = (selector: string): Promise<{ id: string }> => - ( - runtime as unknown as { resolveWorktreeSelector: (s: string) => Promise<{ id: string }> } - ).resolveWorktreeSelector(selector) + runtime['resolveGitWorktreeSelector'](selector) const otherId = `${REPO_ID}-3::${REPO_PATH}-3` const resolved = await resolve(`id:${otherId}`) @@ -648,9 +642,7 @@ describe('scoped explicit worktree-id resolution', () => { it('keeps cross-repo selectors on the fleet path so ambiguity still throws', async () => { const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never) const resolve = (selector: string): Promise => - ( - runtime as unknown as { resolveWorktreeSelector: (s: string) => Promise } - ).resolveWorktreeSelector(selector) + runtime['resolveGitWorktreeSelector'](selector) await expect(resolve('branch:main')).rejects.toThrow('selector_ambiguous') expect(new Set(scannedRepoPaths()).size).toBe(10) @@ -659,9 +651,7 @@ describe('scoped explicit worktree-id resolution', () => { it('falls back to the fleet path when the id names no registered repo', async () => { const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never) const resolve = (selector: string): Promise => - ( - runtime as unknown as { resolveWorktreeSelector: (s: string) => Promise } - ).resolveWorktreeSelector(selector) + runtime['resolveGitWorktreeSelector'](selector) await expect(resolve('id:missing-repo::/nowhere')).rejects.toThrow('selector_not_found') expect(new Set(scannedRepoPaths()).size).toBe(10) diff --git a/src/main/wsl-move-incidental-writes.test.ts b/src/main/wsl-move-incidental-writes.test.ts index f39c3ef2896..92ddd91d5ae 100644 --- a/src/main/wsl-move-incidental-writes.test.ts +++ b/src/main/wsl-move-incidental-writes.test.ts @@ -134,7 +134,7 @@ describe('Linear workspace backfill under a WSL move', () => { showTerminal: async () => { throw new Error('unused') }, - resolveWorktreeSelector: async () => row, + resolveWorkspaceSelector: async () => row, listResolvedWorktrees: async () => [row], setWorktreeMeta, emitClientEvent: () => {}