fix sidebar project group owner identity

This commit is contained in:
Brennan Benson
2026-08-10 17:26:25 -07:00
parent 135c4611fe
commit bac084fcba
27 changed files with 164 additions and 282 deletions
-55
View File
@@ -1,55 +0,0 @@
# #12532 exact-candidate independent review
## Verdict
**NOT CLEAN.** Exact candidate `de06dbb4f2e2e27da7fa21592432439b985649db` retained four issue-scoped defect classes in persistence provenance, local filesystem authorization, same-ID folder deletion, and bulk owner/path-status work. The defects are fixed locally with deterministic coverage; PR #13599 was not pushed or retargeted, PR #13611 was not modified, and Electron QA remains post-acceptance.
## Gate
| Metric | Limit | Actual |
| ----------- | ----- | ------ |
| Total files | ≤45 | **55** |
| Production | ≤28 | **32** |
| Tests | — | **21** |
| Docs | — | **2** |
Base/dependency #13611 remains `d0271cb46f2871c201f39c51e35356efd4658eac`. The exact candidate exceeds the retained-candidate hard gate by 10 total and 4 production files; the final local correction SHA is reported with worker completion.
## Defects found and fixed in this review
- Folder-scope SSH migration collapsed missing and explicit `connectionId: null`, allowing inferred SSH ownership to overwrite explicit local project-group and folder provenance.
- Indexed filesystem authorization inspected the first lexicographic prefix candidate only, so a sibling such as `scope-archive` could hide the remote descendant `scope/repo` and authorize a legacy remote-only root locally.
- Quick-delete and context-menu deletion compared the active folder by bare `folder:<id>`, deactivating a surviving same-ID owner; the correction also handles legacy active sessions without owner provenance.
- Bulk folder ownership rebuilt the same project-group index for each row, while project-group path-status snapshots rescanned subtrees and repos for each scope. Both now cache by immutable catalog identity, and persistence group deletion uses sets instead of repeated sibling scans.
The migration, authorization, deletion, owner-index, and path-status performance regressions failed deterministically before their narrow fixes. The owner-index reproduction read group IDs 786,432 times, and the path-status reproduction read group IDs 264,704 times before correction.
## Prior findings re-audited
- Folder context-menu deletion retains the selected local, SSH, or runtime owner.
- Composer requests, source-repo traversal, cache identity, and runtime routing retain the selected project-group owner.
- Same-ID folder activation and metadata lookup/save resolve the exact owner.
- Legacy SSH folders still resolve a single unstamped legacy group without broadening generic membership or local filesystem authorization.
- Runtime project moves query the owning runtime even when the client group catalog is absent or stale.
- Rename, delete, reorder, collapse, path-status, and host-filter paths remain owner-qualified and fail closed when ambiguous.
- Folder workspaces remain supported independently of git worktrees.
## Remote compatibility decision
No new stream opcode or required wire field was introduced. Existing optional `ownerHostId` selectors remain additive, and paired-runtime RPC schemas can discard selectors they do not understand while operating on the runtime's own unambiguous catalog. The cross-version terminal-wire suite passes; no capability negotiation change is required.
## Validation
- Node `v24.18.0` node/CLI/web typechecks: pass.
- Node `v26.5.0` node/CLI/web typechecks: pass.
- Final changed-test matrix: 1,000 tests across 21 files on Node 26; the Node 24 matrix passed 998 tests before the final two legacy-owner assertions, followed by 39/39 final deletion tests.
- Path-status invalidation suite: 23/23 on Node 24 and Node 26.
- Cross-version terminal-wire suite: 4/4 on Node 24.
- Full lint, native/type-aware quality, 73 reliability gates, max-lines ratchet, skill, and localization gates: pass; only pre-existing `WorktreeJumpPalette` hook warnings were reported.
- Changed-code quality and React Doctor changed gates: 0 new findings across 55 changed files.
- Frozen geometry-boundary file hashes match the exact candidate.
- `oxfmt --check` on edited files and `git diff --check`: pass.
## Remaining work
The coordinator should review the local correction commit and decide how to resolve the 55/32 hard-gate overage. Because meaningful defects were found, do not push or retarget PR #13599 from this worker result; Electron QA remains intentionally deferred until the corrected candidate is accepted for another clean review.
-58
View File
@@ -1,58 +0,0 @@
# #12532 reduced candidate — retained production file table
**Base:** `d0271cb46f` (#13611 dependency; branch never modified)
**Archived broad tip:** `7c64936ce7` / docs `39d2585a90` on `archive/issue-12532-broad-66file-7c64936-20260810-122250`
**Hard gate:** ≤45 total / ≤28 production
## Retention policy
- Bare IDs remain the default when the catalog has a **single** owner for that bare id (legacy `folder:folder-workspace-1` unchanged).
- Owner-qualified refs are used only when **same bare id appears on multiple owners**, or when the caller already supplies `ownerHostId`.
- Ambiguous bare mutate/delete/auth **fails closed**.
- No global rewrite of worktree IDs, tabs, palette, chat, or dashboard cards.
## Retained production files
| # | File | Failing behavior without it | Why a narrower seam cannot absorb it |
| --- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| 1 | `src/shared/project-groups.ts` | Same bare `group.id` on local+SSH collapses into one row on normalize; subtree delete/update walks bare parent edges across hosts | Canonical owner identity + fail-closed `resolveProjectGroupOwner` must live in shared code used by store, IPC, auth |
| 2 | `src/shared/folder-workspaces.ts` | Same bare folder id dropped on normalize; row keys collide in multi-host catalogs | Catalog identity for folders is parallel to groups; must stay co-located with normalize |
| 3 | `src/shared/folder-workspace-path-status.ts` | Path-status requests cannot name owner → cross-owner cache/RPC mix | Request type is the IPC/renderer contract boundary |
| 4 | `src/renderer/src/store/slices/repos.ts` | Group/folder CRUD and path status can select or purge the wrong owner | Single store owns routing and optimistic catalog state |
| 5 | `src/renderer/src/store/slices/project-group-removal-targets.ts` | Contained-project removal enumerates bare subtree across hosts | Delete-with-projects needs the selected owner subtree before IPC |
| 6 | `src/main/ipc/repos.ts` | Main IPC drops optional `ownerHostId` | Wire entry for group/folder selectors |
| 7 | `src/preload/api-types.ts` | Renderer types omit optional `ownerHostId` | Additive preload contract boundary |
| 8 | `src/main/persistence.ts` | Host-local group/folder CRUD mutates the first bare-id row | Durable owner-aware mutation boundary |
| 9 | `src/main/ipc/filesystem-auth.ts` | Same-id remote folders can authorize local paths | Security boundary uses precomputed owner indexes |
| 10 | `src/main/project-groups/folder-workspace-path-status.ts` | Path status resolves bare folder/group to the wrong connection | Main path probe must fail closed on ambiguity |
| 11 | `src/renderer/src/components/sidebar/worktree-list-groups.ts` | Same-id groups share render and collapse keys | Row model mints both identities |
| 12 | `src/renderer/src/components/sidebar/project-group-sidebar-identity.ts` | Sidebar callers otherwise resolve owners inconsistently | One precomputed sidebar lookup index |
| 13 | `src/renderer/src/store/slices/worktrees.ts` | Bare same-id folder activation selects one owner while tabs still share `folder:<id>` | A bounded guard fails closed without changing workspace/tab identity |
| 14 | `src/renderer/src/components/sidebar/WorktreeList.tsx` | Rendered owner-qualified headers still send bare mutations and composer targets | Interaction handlers must carry the owner selected by the row |
| 15 | `src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts` | Folder rows discard owner-qualified keys, causing virtual DOM reuse | TanStack row keys are minted at this boundary |
| 16 | `src/renderer/src/components/sidebar/host-section-rows.ts` | Runtime folders route from connection/default host instead of their runtime stamp | Host-section assignment owns this projection |
| 17 | `src/renderer/src/components/sidebar/worktree-list-host-filtering.ts` | Runtime path status and filter rebuilds can route the wrong owner | Shared filter/path-status routing must use one host resolver |
| 18 | `src/renderer/src/components/sidebar/WorktreeContextMenu.tsx` | Move/create/remove actions expose foreign-owner groups and send bare selectors | The context menu independently owns these action call sites |
| 19 | `src/renderer/src/lib/new-workspace-project-options.ts` | Duplicate group IDs produce duplicate composer values and first-owner selection | Option identity and parsing live here |
| 20 | `src/renderer/src/hooks/useComposerState.ts` | Selection immediately loses the option owner | Composer state must retain owner through selection and creation |
| 21 | `src/renderer/src/components/NewWorkspaceComposerModal.tsx` | Sidebar initial-group owner is dropped at the modal boundary | Modal data is the launch contract |
**Production count: 21** (≤28). Changes to dependency-origin files are narrow and remain only on this stacked candidate branch.
## Explicitly dropped vs broad 38-file tip (and why)
| Dropped | Reason |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
| WorktreeCard | Card workspace identity can keep bare id when unambiguous |
| dashboard-snapshot-workspaces | Dashboard identity is unrelated to project-group catalog ownership |
| worktrees.ts bulk dual-read | Prefer bare session keys when unambiguous; avoid global session routing rewrite |
| folder-workspace-session-owner, workspace-session-host-persistence, worktree-runtime-owner | Cascade from always-qualified keys; not needed if default stays bare |
| folder-workspace-worktree | Keep bare synthetic `folder:<id>` when unambiguous |
| folder-workspace-connection, composer helpers/path-status | Not required for adversarial same-id group/folder CRUD |
| project-group-header-drag/drop/contract, move-targets, header-section-boundaries, rendered-order | Existing contracts remain bare internally; the rendered-view gate guarantees one owner before drag |
| ui.ts draft owner field | Legacy drafts fail closed only for ambiguity; transient sidebar launches carry owner through modal data |
| repos-runtime-routing-fixture | Test-only support (count under tests if reintroduced) |
## Test budget (focused only)
Same-id / fail-closed / auth-index / path-status / owner update-delete / normalize — no mass expectation rewrites of sticky geometry or bare-key consumers.
+14
View File
@@ -141,6 +141,20 @@ describe('filesystem auth worktree roots', () => {
})
describe('filesystem-auth path containment', () => {
it('does not authorize a runtime-owned repo path on the local filesystem', () => {
const runtimePath = resolve('/runtime-only/repo')
const store = makeStore([
{
...repo,
path: runtimePath,
connectionId: null,
executionHostId: 'runtime:env-1'
}
])
expect(isPathAllowed(runtimePath, store)).toBe(false)
})
it('authorizes missing nested descendants under an allowed repo', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-missing-'))
try {
+1 -1
View File
@@ -61,7 +61,7 @@ export function invalidateAuthorizedRootsCache(): void {
function getLocalRepos(store: Store) {
// Why: SSH repo paths are remote-host paths; treating them as local roots could authorize unrelated local folders or probe SSH-only paths.
return store.getRepos().filter((repo) => !repo.connectionId)
return store.getRepos().filter((repo) => getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID)
}
type FolderAuthIndex = {
+1 -1
View File
@@ -111,7 +111,7 @@ import {
getProjectGroupOwnerHostId,
resolveProjectGroupOwner
} from '../../shared/project-groups'
import { resolveFolderWorkspaceProjectGroupWithLegacySsh } from '../../shared/folder-workspace-project-group-resolution'
import { resolveFolderWorkspaceProjectGroupWithLegacySsh } from '../../shared/folder-workspaces'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import {
assertFolderWorkspacePathUsable,
+2 -2
View File
@@ -230,7 +230,6 @@ import {
resolveProjectGroupMembership,
resolveProjectGroupOwner
} from '../shared/project-groups'
import { resolveFolderWorkspaceProjectGroupWithLegacySsh } from '../shared/folder-workspace-project-group-resolution'
import { createNestedProjectGroupResolver } from './project-groups/nested-repo-import'
import {
mergeLegacyCommitMessageAiIntoSourceControlAi,
@@ -264,7 +263,8 @@ import { ActiveViewPreference } from './active-view-preference'
import {
resolveFolderWorkspaceCatalogOwnerHostId,
normalizeFolderWorkspaceName,
normalizeFolderWorkspaces
normalizeFolderWorkspaces,
resolveFolderWorkspaceProjectGroupWithLegacySsh
} from '../shared/folder-workspaces'
import {
folderWorkspaceKey,
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
getContextMenuWorktreeExecutionHost,
findContextMenuRepo,
getFolderWorkspaceContextMenuDeleteOwner,
getContextMenuProjectGroupTargets,
isContextWorktreeDeletable,
@@ -21,11 +22,24 @@ import { findFolderWorkspaceMetaOwner } from './use-worktree-meta-workspace'
import type {
FolderWorkspace,
ProjectGroup,
Repo,
Worktree,
WorktreeLineage,
WorkspaceStatusDefinition
} from '../../../../shared/types'
describe('findContextMenuRepo', () => {
const repo = (executionHostId: 'local' | 'runtime:env-1'): Repo =>
({ id: 'same-repo', executionHostId }) as Repo
it('resolves an inactive duplicate repo from the clicked row host', () => {
const local = repo('local')
const runtime = repo('runtime:env-1')
expect(findContextMenuRepo([local, runtime], 'same-repo', 'runtime:env-1')).toBe(runtime)
})
})
describe('getContextMenuProjectGroupTargets', () => {
const group = (executionHostId: 'local' | 'runtime:env-1'): ProjectGroup =>
({ id: 'same-id', executionHostId }) as ProjectGroup
@@ -32,7 +32,12 @@ import {
} from 'lucide-react'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { useAllWorktrees, useRepoById, useRepoMap, useWorktreeMap } from '@/store/selectors'
import {
selectRepoByIdForActiveWorkspace,
useAllWorktrees,
useRepoMap,
useWorktreeMap
} from '@/store/selectors'
import { cn } from '@/lib/utils'
import type {
ProjectGroup,
@@ -66,6 +71,7 @@ import { translate } from '@/i18n/i18n'
import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
import {
getRepoExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
@@ -96,6 +102,17 @@ const DELETE_POSITION_RESTORE_STABLE_FRAMES = 6
// data-[state=closed] exit animation short; hold the subtree for its duration.
const PARENT_PICKER_EXIT_ANIMATION_MS = 200
export function findContextMenuRepo(
repos: Repo[],
repoId: string,
executionHostId: ExecutionHostId
): Repo | null {
return selectRepoByIdForActiveWorkspace(
{ repos, activeRepoId: repoId, activeWorkspaceExecutionHostId: executionHostId },
repoId
)
}
// Why: stable empty sentinels let closed menu wrappers subscribe to a referentially
// stable value instead of the high-churn maps that delete teardown replaces. The
// selector returns these when the menu is closed, so the wrapper stays inert to
@@ -368,7 +385,10 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup)
const deleteFolderWorkspace = useAppStore((s) => s.deleteFolderWorkspace)
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const repo = useRepoById(worktree.repoId, normalizeExecutionHostId(worktree.hostId) ?? undefined)
const repo = useAppStore((s) => {
const executionHostId = normalizeExecutionHostId(worktree.hostId) ?? LOCAL_EXECUTION_HOST_ID
return findContextMenuRepo(s.repos, worktree.repoId, executionHostId)
})
const projectGroupTargets = useMemo(
() => (repo ? getContextMenuProjectGroupTargets(repo, projectGroups) : null),
[projectGroups, repo]
@@ -2213,13 +2213,17 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
if (!pendingRevealWorktree) {
return
}
const pendingRevealOwnerHostId =
pendingRevealWorktree.worktreeId === activeWorktreeId
? (activeWorkspaceExecutionHostId ?? undefined)
: undefined
if (agentSendTargetWorktreeId !== pendingRevealWorktree.worktreeId) {
const folderGroupKeys = getFolderWorkspaceRevealGroupKeys(
pendingRevealWorktree.worktreeId,
folderWorkspaces,
projectGroups,
pendingRevealWorktree.ownerHostId
pendingRevealOwnerHostId
)
if (folderGroupKeys.length > 0) {
for (const groupKey of folderGroupKeys) {
@@ -2283,13 +2287,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
worktrees,
folderWorkspaces,
projectGroups,
pendingRevealWorktree.ownerHostId
pendingRevealOwnerHostId
)
const targetIndex = findPreferredRenderRowIndexForWorktree(
renderRows,
pendingRevealWorktree.worktreeId,
pinnedDisplayPolicy,
pendingRevealWorktree.ownerHostId
pendingRevealOwnerHostId
)
const outcome = resolvePendingSidebarReveal({ targetIndex, targetWorktreeStillExists })
if (outcome === 'scroll-and-clear') {
@@ -2372,6 +2376,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}
}, [
pendingRevealWorktree,
activeWorktreeId,
activeWorkspaceExecutionHostId,
agentSendTargetWorktreeId,
groupBy,
worktrees,
@@ -6804,7 +6810,6 @@ const WorktreeList = React.memo(function WorktreeList({
}
revealWorktreeInSidebar(currentSidebarWorktreeId, {
behavior: 'smooth',
...(activeWorkspaceExecutionHostId ? { ownerHostId: activeWorkspaceExecutionHostId } : {}),
highlight: true,
beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true
})
@@ -77,52 +77,6 @@ describe('getFolderSourceRepos', () => {
).map((item) => item.id)
).toEqual(['runtime-by-path', 'runtime-by-group'])
})
it('ignores descendant ids contributed only by another owner', () => {
const localRoot = group({
id: 'root',
name: 'Local root',
parentPath: '/local',
connectionId: null,
executionHostId: 'local'
})
const localUnrelated = group({
id: 'foreign-child',
name: 'Local unrelated',
parentPath: '/other',
connectionId: null,
executionHostId: 'local'
})
const runtimeRoot = group({
id: 'root',
name: 'Runtime root',
parentPath: '/runtime',
connectionId: null,
executionHostId: 'runtime:env-1'
})
const runtimeChild = group({
id: 'foreign-child',
name: 'Runtime child',
parentPath: '/runtime/child',
connectionId: null,
executionHostId: 'runtime:env-1',
parentGroupId: 'root'
})
const localRepo = repo('local-unrelated', {
path: '/outside/root',
connectionId: null,
executionHostId: 'local',
projectGroupId: 'foreign-child'
})
expect(
getFolderSourceRepos(
[localRepo],
[localRoot, localUnrelated, runtimeRoot, runtimeChild],
localRoot
)
).toEqual([])
})
})
describe('getFolderWorkspacePrimaryActionLabel', () => {
@@ -49,11 +49,8 @@ export function getFolderSourceRepos(
return EMPTY_REPOS
}
const folderPath = projectGroup.parentPath
const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroup.id)
const projectGroupHostId = getProjectGroupExecutionHostId(projectGroup)
const ownerProjectGroups = projectGroups.filter(
(group) => getProjectGroupExecutionHostId(group) === projectGroupHostId
)
const groupIds = getProjectGroupSubtreeIds(ownerProjectGroups, projectGroup.id)
return repos.filter(
(repo) =>
isGitRepoKind(repo) &&
@@ -333,7 +333,9 @@ describe('duplicate project-group header identity (#12532)', () => {
new Set(['same-folder'])
)
expect(rows.find((row) => row.type === 'folder-workspace')?.key).toBe(
const folderRow = rows.find((row) => row.type === 'folder-workspace')
expect(folderRow?.key).toBe('folder-workspace:runtime%3Aenv-1:same-folder')
expect(folderRow && getRenderRowKey(folderRow)).toBe(
'folder-workspace:runtime%3Aenv-1:same-folder'
)
})
@@ -63,16 +63,6 @@ const stickyHeaderIndexes = getStickyHeaderIndexes(rows)
const virtualItems = rows.map((_, index) => virtualItem(index, index * 100))
describe('getRenderRowKey', () => {
it('preserves owner-qualified folder workspace keys across hosts', () => {
const folderRow = (key: string): RenderRow =>
({ type: 'folder-workspace', key }) as unknown as RenderRow
expect([
getRenderRowKey(folderRow('folder-workspace:local:same-id')),
getRenderRowKey(folderRow('folder-workspace:runtime%3Aenv-1:same-id'))
]).toEqual(['folder-workspace:local:same-id', 'folder-workspace:runtime%3Aenv-1:same-id'])
})
it('scopes repeated group headers to their host section', () => {
expect(getRenderRowKey(groupRow('workspace-status:in-progress', 'local'))).toBe(
'hdr:local:workspace-status:in-progress'
+15 -3
View File
@@ -133,6 +133,7 @@ import {
findActionableFolderProjectGroup,
getNewWorkspaceProjectGroupOptionId,
getNewWorkspaceProjectGroupHostId,
getNewWorkspaceProjectGroupsForOwner,
getProjectGroupSelectorFromNewWorkspaceOptionId,
type NewWorkspaceProjectOption
} from '@/lib/new-workspace-project-options'
@@ -809,9 +810,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
selectedProjectGroupId
])
const isProjectGroupTarget = selectedProjectGroup !== null
const selectedProjectGroupCatalog = useMemo(
() =>
selectedProjectGroup
? getNewWorkspaceProjectGroupsForOwner(projectGroups, selectedProjectGroup)
: projectGroups,
[projectGroups, selectedProjectGroup]
)
const folderSourceRepos = useMemo(
() => getFolderSourceRepos(repos, projectGroups, selectedProjectGroup),
[projectGroups, repos, selectedProjectGroup]
() => getFolderSourceRepos(repos, selectedProjectGroupCatalog, selectedProjectGroup),
[repos, selectedProjectGroup, selectedProjectGroupCatalog]
)
const parsedFolderTargetHost = parseExecutionHostId(selectedProjectGroup?.executionHostId)
const folderTargetRuntimeEnvironmentId =
@@ -2838,7 +2846,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
)
return
}
const nextSourceRepo = getFolderSourceRepos(repos, projectGroups, nextProjectGroup)[0]
const nextSourceRepo = getFolderSourceRepos(
repos,
getNewWorkspaceProjectGroupsForOwner(projectGroups, nextProjectGroup),
nextProjectGroup
)[0]
setSelectedProjectGroupId(nextProjectGroup.id)
setSelectedProjectGroupOwnerHostId(getNewWorkspaceProjectGroupHostId(nextProjectGroup))
setProjectError(null)
@@ -4,6 +4,7 @@ import {
buildNewWorkspaceFolderSourceOptions,
buildNewWorkspaceProjectOptions,
findActionableFolderProjectGroup,
getNewWorkspaceProjectGroupsForOwner,
getRepoIdFromNewWorkspaceFolderSourceOptionId,
isNewWorkspaceProjectOptionQueryTooLarge,
searchNewWorkspaceProjectOptions,
@@ -588,4 +589,22 @@ describe('findActionableFolderProjectGroup', () => {
})
).toBe(duplicates[1])
})
it('limits folder source traversal to the selected group owner', () => {
const localRoot = group({ id: 'root', executionHostId: 'local' })
const localUnrelated = group({ id: 'foreign-child', executionHostId: 'local' })
const runtimeRoot = group({ id: 'root', executionHostId: 'runtime:env-1' })
const runtimeChild = group({
id: 'foreign-child',
executionHostId: 'runtime:env-1',
parentGroupId: 'root'
})
expect(
getNewWorkspaceProjectGroupsForOwner(
[localRoot, localUnrelated, runtimeRoot, runtimeChild],
localRoot
)
).toEqual([localRoot, localUnrelated])
})
})
@@ -204,6 +204,16 @@ export function getNewWorkspaceProjectGroupHostId(group: ProjectGroup): Executio
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
}
export function getNewWorkspaceProjectGroupsForOwner(
projectGroups: readonly ProjectGroup[],
projectGroup: ProjectGroup
): ProjectGroup[] {
const ownerHostId = getNewWorkspaceProjectGroupHostId(projectGroup)
return projectGroups.filter(
(candidate) => getNewWorkspaceProjectGroupHostId(candidate) === ownerHostId
)
}
/**
* A folder workspace can only be backed by a group whose host is still actionable — a removed
* or unreachable host must not be selectable, restorable from a draft, or kept once selected.
+2 -5
View File
@@ -274,12 +274,9 @@ export function activateAndRevealFolderWorkspace(
const primaryTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup)
if (opts?.sidebarRevealBehavior) {
state.revealWorktreeInSidebar(workspaceKey, {
behavior: opts.sidebarRevealBehavior,
ownerHostId
})
state.revealWorktreeInSidebar(workspaceKey, { behavior: opts.sidebarRevealBehavior })
} else {
state.revealWorktreeInSidebar(workspaceKey, { ownerHostId })
state.revealWorktreeInSidebar(workspaceKey)
}
return { primaryTabId }
-24
View File
@@ -10,7 +10,6 @@ import {
getWorktreeMapFromState,
resetFloatingVisibleTabCountSelectorCacheForTest,
selectRepoByIdForActiveWorkspace,
selectRepoByIdForExecutionHost,
selectFloatingVisibleTabCount,
selectFloatingWorkspaceHasUnread
} from './selectors'
@@ -320,29 +319,6 @@ describe('store selectors', () => {
expect(activeRepo ? isGitRepoKind(activeRepo) : false).toBe(true)
})
it('resolves an inactive duplicate repo from the clicked row host', () => {
const local = makeRepo({
id: 'same-repo',
path: '/local/repo',
displayName: 'local',
executionHostId: 'local'
})
const runtime = makeRepo({
id: 'same-repo',
path: '/runtime/repo',
displayName: 'runtime',
executionHostId: toRuntimeExecutionHostId('env-1')
})
expect(
selectRepoByIdForExecutionHost(
{ repos: [local, runtime] },
'same-repo',
toRuntimeExecutionHostId('env-1')
)
).toBe(runtime)
})
it('fails a paired-hub repo fallback closed when rival HUB rows share the repo ID', () => {
const repoForHub = (environmentId: string) =>
makeRepo({
+18 -29
View File
@@ -187,39 +187,28 @@ export function selectRepoByIdForActiveWorkspace(
}
const repo = getCachedRepoMap(state.repos).get(repoId) ?? null
if (repoId === state.activeRepoId && state.activeWorkspaceExecutionHostId) {
return selectRepoByIdForExecutionHost(state, repoId, state.activeWorkspaceExecutionHostId)
const repoCandidates = state.repos.filter((candidate) => candidate.id === repoId)
const hostMatch = repoCandidates.find(
(candidate) => getRepoExecutionHostId(candidate) === state.activeWorkspaceExecutionHostId
)
if (hostMatch) {
return hostMatch
}
// Why: withRepoHostOwnership keeps a paired-hub worktree on its own SSH host while the repo
// stays hub-owned, so that one mismatch still names the right repo; every other stays closed.
if (parseExecutionHostId(state.activeWorkspaceExecutionHostId)?.kind !== 'ssh') {
return null
}
const pairedHubRepos = repoCandidates.filter(
(candidate) => parseExecutionHostId(getRepoExecutionHostId(candidate))?.kind === 'runtime'
)
return pairedHubRepos.length === 1 ? pairedHubRepos[0] : null
}
return repo
}
export function selectRepoByIdForExecutionHost(
state: Pick<AppState, 'repos'>,
repoId: string,
executionHostId: ExecutionHostId
): Repo | null {
const repoCandidates = state.repos.filter((candidate) => candidate.id === repoId)
const hostMatch = repoCandidates.find(
(candidate) => getRepoExecutionHostId(candidate) === executionHostId
)
if (hostMatch) {
return hostMatch
}
// Why: paired-hub worktrees can name a private SSH host while their repo remains hub-owned.
if (parseExecutionHostId(executionHostId)?.kind !== 'ssh') {
return null
}
const pairedHubRepos = repoCandidates.filter(
(candidate) => parseExecutionHostId(getRepoExecutionHostId(candidate))?.kind === 'runtime'
)
return pairedHubRepos.length === 1 ? pairedHubRepos[0] : null
}
export const useRepoById = (repoId: string | null, executionHostId?: ExecutionHostId) =>
useAppStore((s) =>
repoId && executionHostId
? selectRepoByIdForExecutionHost(s, repoId, executionHostId)
: selectRepoByIdForActiveWorkspace(s, repoId)
)
export const useRepoById = (repoId: string | null) =>
useAppStore((s) => selectRepoByIdForActiveWorkspace(s, repoId))
export const useProjectHostSetupProjection = () =>
useAppStore((s) => getProjectHostSetupProjectionFromState(s))
@@ -9,7 +9,7 @@ import {
resolveProjectGroupOwner,
type ProjectGroupOwnerIndex
} from '../../../../shared/project-groups'
import { resolveFolderWorkspaceProjectGroupWithLegacySsh } from '../../../../shared/folder-workspace-project-group-resolution'
import { resolveFolderWorkspaceProjectGroupWithLegacySsh } from '../../../../shared/folder-workspaces'
import { getRepoHostIdentity } from './repo-host-identity'
export type ProjectGroupRemovalProjectTarget = {
+2 -2
View File
@@ -103,9 +103,9 @@ import {
getFolderWorkspaceIdentity,
getFolderWorkspaceOwnerIdentity,
resolveFolderWorkspaceCatalogOwnerHostId,
resolveFolderWorkspaceCatalogOwnerHostIdFromIndex
resolveFolderWorkspaceCatalogOwnerHostIdFromIndex,
resolveFolderWorkspaceProjectGroupWithLegacySsh
} from '../../../../shared/folder-workspaces'
import { resolveFolderWorkspaceProjectGroupWithLegacySsh } from '../../../../shared/folder-workspace-project-group-resolution'
import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status'
import { getEnvironmentSshStateGeneration } from './runtime-environment-ssh'
import { getRuntimeEnvironmentConnectionGeneration } from './runtime-status'
-2
View File
@@ -1298,7 +1298,6 @@ describe('createUISlice hydratePersistedUI', () => {
store.getState().revealWorktreeInSidebar('repo1::/feature', {
behavior: 'smooth',
ownerHostId: 'runtime:env-1',
highlight: true,
beginRename: true
})
@@ -1306,7 +1305,6 @@ describe('createUISlice hydratePersistedUI', () => {
expect(store.getState().pendingRevealWorktree).toEqual({
worktreeId: 'repo1::/feature',
behavior: 'smooth',
ownerHostId: 'runtime:env-1',
highlight: true,
beginRename: true
})
-3
View File
@@ -137,7 +137,6 @@ import { getRepoHostIdentity } from './repo-host-identity'
export type PendingSidebarWorktreeReveal = {
worktreeId: string
behavior: 'auto' | 'smooth'
ownerHostId?: ExecutionHostId
highlight?: boolean
beginRename?: boolean
}
@@ -963,7 +962,6 @@ export type UISlice = {
worktreeId: string,
options?: {
behavior?: PendingSidebarWorktreeReveal['behavior']
ownerHostId?: ExecutionHostId
highlight?: boolean
beginRename?: boolean
}
@@ -2374,7 +2372,6 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
pendingRevealWorktree: {
worktreeId,
behavior: options?.behavior ?? 'smooth',
...(options?.ownerHostId ? { ownerHostId: options.ownerHostId } : {}),
...(options?.highlight ? { highlight: true } : {}),
...(options?.beginRename ? { beginRename: true } : {})
}
@@ -1,23 +0,0 @@
import { normalizeExecutionHostId } from './execution-host'
import {
resolveFolderWorkspaceProjectGroup,
resolveProjectGroupOwner,
type ProjectGroupOwnerIndex
} from './project-groups'
import type { FolderWorkspace, ProjectGroup } from './types'
export function resolveFolderWorkspaceProjectGroupWithLegacySsh(
index: ProjectGroupOwnerIndex,
workspace: Pick<FolderWorkspace, 'connectionId' | 'executionHostId' | 'projectGroupId'>
): ProjectGroup | null {
const strict = resolveFolderWorkspaceProjectGroup(index, workspace)
if (strict || !workspace.connectionId) {
return strict
}
const group = resolveProjectGroupOwner(index, workspace.projectGroupId)
return group &&
group.connectionId === undefined &&
!normalizeExecutionHostId(group.executionHostId)
? group
: null
}
+17 -1
View File
@@ -8,9 +8,9 @@ import {
buildProjectGroupOwnerIndex,
getFolderWorkspaceProjectGroupOwnerHostId,
resolveFolderWorkspaceProjectGroup,
resolveProjectGroupOwner,
type ProjectGroupOwnerIndex
} from './project-groups'
import { resolveFolderWorkspaceProjectGroupWithLegacySsh } from './folder-workspace-project-group-resolution'
import type { FolderWorkspace, ProjectGroup } from './types'
import { isTuiAgent } from './tui-agent-config'
import { normalizeStoredTaskSourceContext } from './task-source-context'
@@ -19,6 +19,22 @@ import { isWorkspaceLinkedItemSourceContextMatch } from './workspace-linked-item
const projectGroupOwnerIndexCache = new WeakMap<readonly ProjectGroup[], ProjectGroupOwnerIndex>()
export function resolveFolderWorkspaceProjectGroupWithLegacySsh(
index: ProjectGroupOwnerIndex,
workspace: Pick<FolderWorkspace, 'connectionId' | 'executionHostId' | 'projectGroupId'>
): ProjectGroup | null {
const strict = resolveFolderWorkspaceProjectGroup(index, workspace)
if (strict || !workspace.connectionId) {
return strict
}
const group = resolveProjectGroupOwner(index, workspace.projectGroupId)
return group &&
group.connectionId === undefined &&
!normalizeExecutionHostId(group.executionHostId)
? group
: null
}
function getProjectGroupOwnerIndex(projectGroups: readonly ProjectGroup[]): ProjectGroupOwnerIndex {
const cached = projectGroupOwnerIndexCache.get(projectGroups)
if (cached) {
+12
View File
@@ -188,6 +188,18 @@ describe('project-groups', () => {
).toHaveLength(1)
})
it('does not reinterpret explicit local provenance as a unique SSH owner', () => {
const ssh = projectGroup({ id: 'same-id', connectionId: 'builder' })
const index = buildProjectGroupOwnerIndex([ssh])
expect(
resolveFolderWorkspaceProjectGroup(index, {
projectGroupId: 'same-id',
connectionId: null
})
).toBeNull()
})
it('rejects folder workspaces stamped for missing foreign owners', () => {
const local = projectGroup({
id: 'same-id',
+1 -5
View File
@@ -97,11 +97,7 @@ export function resolveFolderWorkspaceProjectGroup(
const ownerHostId = workspace.connectionId
? toSshExecutionHostId(workspace.connectionId)
: LOCAL_EXECUTION_HOST_ID
const group = resolveProjectGroupMembership(index, workspace.projectGroupId, ownerHostId)
// Why: null predates owner stamps; a named SSH owner is authoritative.
return workspace.connectionId === null
? (group ?? resolveProjectGroupOwner(index, workspace.projectGroupId))
: group
return resolveProjectGroupMembership(index, workspace.projectGroupId, ownerHostId)
}
return resolveProjectGroupOwner(index, workspace.projectGroupId)
}