diff --git a/src/renderer/src/lib/worktree-operation-route.ts b/src/renderer/src/lib/worktree-operation-route.ts index fadd72841..75608bc0c 100644 --- a/src/renderer/src/lib/worktree-operation-route.ts +++ b/src/renderer/src/lib/worktree-operation-route.ts @@ -7,7 +7,11 @@ import { import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' import { addRoute, resolveExactWorktreeRoute, routeForOwner } from './worktree-owner-route' -import { resolveIndexedWorktreeOwner } from './worktree-runtime-owner-index' +import { + findIndexedDetectedWorktrees, + hasIndexedDetectedWorktree, + resolveIndexedWorktreeOwner +} from './worktree-runtime-owner-index' import { findFolderWorkspaceOwner, getExecutionHostIdForFolderWorkspace, @@ -51,11 +55,7 @@ function ownerRecordsOnHost( executionHostId: ExecutionHostId ): WorktreeOperationOwnerRecord[] { const owners: WorktreeOperationOwnerRecord[] = [] - const catalogs = [ - ...Object.values(state.worktreesByRepo ?? {}), - ...Object.values(state.detectedWorktreesByRepo ?? {}).map((result) => result.worktrees) - ] - for (const worktrees of catalogs) { + for (const worktrees of Object.values(state.worktreesByRepo ?? {})) { for (const worktree of worktrees) { if ( worktree.id === worktreeId && @@ -65,6 +65,11 @@ function ownerRecordsOnHost( } } } + for (const worktree of findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, worktreeId)) { + if (parseExecutionHostId(worktree.hostId)?.id === executionHostId) { + owners.push(worktree) + } + } return owners } @@ -135,12 +140,8 @@ export function resolveWorktreeOperationRouteResult( } const hasKnownWorktree = - Object.values(state.worktreesByRepo ?? {}).some((worktrees) => - worktrees.some((worktree) => worktree.id === worktreeId) - ) || - Object.values(state.detectedWorktreesByRepo ?? {}).some((result) => - result.worktrees.some((worktree) => worktree.id === worktreeId) - ) + resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId).kind !== 'missing' || + hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId) const repoId = getRepoIdFromWorktreeId(worktreeId) const hasKnownRepo = state.repos?.some((repo) => repo.id === repoId) === true if (!hasKnownWorktree && !hasKnownRepo) { @@ -216,18 +217,14 @@ export function resolveExplicitWorktreeOperationRouteResult( addRoute(exactRoutes, resolution.route) } } - for (const result of Object.values(state.detectedWorktreesByRepo ?? {})) { - for (const worktree of result.worktrees) { - if (worktree.id === worktreeId) { - exactRepoIds.add(worktree.repoId) - const resolution = resolveExactWorktreeRoute(state, worktree) - if (resolution.kind === 'ambiguous') { - return resolution - } - if (resolution.kind === 'resolved') { - addRoute(exactRoutes, resolution.route) - } - } + for (const worktree of findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, worktreeId)) { + exactRepoIds.add(worktree.repoId) + const resolution = resolveExactWorktreeRoute(state, worktree) + if (resolution.kind === 'ambiguous') { + return resolution + } + if (resolution.kind === 'resolved') { + addRoute(exactRoutes, resolution.route) } } if (exactRoutes.size > 0) { diff --git a/src/renderer/src/lib/worktree-runtime-owner-index.detected-perf.test.ts b/src/renderer/src/lib/worktree-runtime-owner-index.detected-perf.test.ts new file mode 100644 index 000000000..7b574cd27 --- /dev/null +++ b/src/renderer/src/lib/worktree-runtime-owner-index.detected-perf.test.ts @@ -0,0 +1,200 @@ +import { writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { hasIndexedDetectedWorktree } from './worktree-runtime-owner-index' +import { getExplicitRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state' + +const REPO_COUNT = 20 +const WORKTREES_PER_REPO = 100 +const LOOKUPS_PER_SWEEP = 200 +const SWEEPS = 40 + +type OwnerRecord = { + id: string + repoId: string + hostId?: ExecutionHostId + runtimeOwnerEnvironmentId?: string +} +type DetectedByRepo = Record + +// The pre-index expression, kept as the "before" leg of the benchmark. +function walkHasDetected(detectedWorktreesByRepo: DetectedByRepo | undefined, id: string): boolean { + return Object.values(detectedWorktreesByRepo ?? {}).some((result) => + result.worktrees.some((worktree) => worktree.id === id) + ) +} + +function buildDetectedCatalog(generation: number, counter?: { reads: number }): DetectedByRepo { + const catalog: DetectedByRepo = {} + for (let repoIndex = 0; repoIndex < REPO_COUNT; repoIndex += 1) { + const repoId = `repo-${repoIndex}` + const worktrees: OwnerRecord[] = [] + for (let index = 0; index < WORKTREES_PER_REPO; index += 1) { + const id = `${repoId}::detected-${index}-gen-${generation}` + const record: OwnerRecord = { id, repoId, hostId: 'ssh:target-a' } + if (counter) { + Object.defineProperty(record, 'id', { + get: () => { + counter.reads += 1 + return id + }, + enumerable: true + }) + } + worktrees.push(record) + } + catalog[repoId] = { worktrees } + } + return catalog +} + +const publishedRepos = Array.from({ length: REPO_COUNT }, (_unused, index) => ({ + id: `repo-${index}`, + connectionId: `target-${index}` +})) +const publishedWorktreesByRepo: Record = Object.fromEntries( + publishedRepos.map((repo) => [ + repo.id, + Array.from({ length: WORKTREES_PER_REPO }, (_unused, index) => ({ + id: `${repo.id}::worktree-${index}`, + repoId: repo.id, + hostId: 'ssh:target-a' as const + })) + ]) +) + +// Only the detected catalog changes identity: that is what isolates the collection under test. +function buildOwnerState(detectedWorktreesByRepo: DetectedByRepo): WorktreeRuntimeOwnerState { + return { + repos: publishedRepos, + worktreesByRepo: publishedWorktreesByRepo, + detectedWorktreesByRepo, + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + runtimeEnvironments: [] + } +} + +// Probe ids are published but never detected — the SSH-pane case, and the walk's worst case. +const PROBE_IDS = Array.from( + { length: LOOKUPS_PER_SWEEP }, + (_unused, index) => `repo-${index % REPO_COUNT}::worktree-${index % WORKTREES_PER_REPO}` +) + +const WARMUP_SWEEPS = 5 +const warmCatalog = buildDetectedCatalog(-1) + +// Each fresh leg needs its own never-indexed catalogs; a shared pool would leave the index warm +// for whichever leg runs second and silently erase the rebuild it is supposed to measure. +let catalogGeneration = 0 +function freshCatalogPool(): DetectedByRepo[] { + return Array.from({ length: SWEEPS + WARMUP_SWEEPS }, () => + buildDetectedCatalog((catalogGeneration += 1)) + ) +} + +function measureSweeps(run: (sweep: number) => void): number { + for (let warmup = 0; warmup < WARMUP_SWEEPS; warmup += 1) { + run(warmup) + } + const started = performance.now() + for (let sweep = 0; sweep < SWEEPS; sweep += 1) { + run(WARMUP_SWEEPS + sweep) + } + return (performance.now() - started) / SWEEPS +} + +describe('detected worktree index performance', () => { + it('answers repeated owner lookups without rescanning the detected catalog', () => { + const counter = { reads: 0 } + const catalog = buildDetectedCatalog(0, counter) + const state = buildOwnerState(catalog) + + getExplicitRuntimeEnvironmentIdForWorktree(state, 'repo-0::warm-the-index') + counter.reads = 0 + for (const probeId of PROBE_IDS) { + getExplicitRuntimeEnvironmentIdForWorktree(state, probeId) + } + const indexedReads = counter.reads + + counter.reads = 0 + for (const probeId of PROBE_IDS) { + walkHasDetected(catalog, probeId) + } + const walkedReads = counter.reads + + expect(indexedReads).toBe(0) + expect(walkedReads).toBeGreaterThan(100_000) + }) + + it('records lookup-path timing without making wall clock a CI gate', () => { + const walkPool = freshCatalogPool() + const walkMs = measureSweeps((sweep) => { + const catalog = walkPool[sweep]! + for (const probeId of PROBE_IDS) { + walkHasDetected(catalog, probeId) + } + }) + const walkWarmMs = measureSweeps(() => { + for (const probeId of PROBE_IDS) { + walkHasDetected(warmCatalog, probeId) + } + }) + // Unrelated store writes leave the detected catalog identical, so the index stays warm. + const warmMs = measureSweeps(() => { + for (const probeId of PROBE_IDS) { + hasIndexedDetectedWorktree(warmCatalog, probeId) + } + }) + // A landed worktree scan republishes the catalog: one rebuild amortized over the sweep. + const freshPool = freshCatalogPool() + const freshMs = measureSweeps((sweep) => { + const catalog = freshPool[sweep]! + for (const probeId of PROBE_IDS) { + hasIndexedDetectedWorktree(catalog, probeId) + } + }) + + const statePool = freshCatalogPool().map(buildOwnerState) + const warmState = buildOwnerState(warmCatalog) + const explicitOwnerFreshMs = measureSweeps((sweep) => { + const state = statePool[sweep]! + for (const probeId of PROBE_IDS) { + getExplicitRuntimeEnvironmentIdForWorktree(state, probeId) + } + }) + const explicitOwnerWarmMs = measureSweeps(() => { + for (const probeId of PROBE_IDS) { + getExplicitRuntimeEnvironmentIdForWorktree(warmState, probeId) + } + }) + + const round = (value: number): number => Number(value.toFixed(4)) + const report = { + entries: REPO_COUNT * WORKTREES_PER_REPO, + lookupsPerSweep: LOOKUPS_PER_SWEEP, + sweeps: SWEEPS, + lookupPath: { + walkFreshMsPerSweep: round(walkMs), + walkWarmMsPerSweep: round(walkWarmMs), + indexedWarmMsPerSweep: round(warmMs), + indexedFreshMsPerSweep: round(freshMs), + warmSpeedup: round(walkWarmMs / warmMs), + freshSpeedup: round(walkMs / freshMs) + }, + getExplicitRuntimeEnvironmentIdForWorktree: { + warmMsPerSweep: round(explicitOwnerWarmMs), + freshMsPerSweep: round(explicitOwnerFreshMs) + } + } + writeFileSync( + join(tmpdir(), 'orca-detected-worktree-index-bench.json'), + `${JSON.stringify(report, null, 2)}\n` + ) + + // Why: shared-runner timing is noisy; the read-count test above is the deterministic CI gate. + }) +}) diff --git a/src/renderer/src/lib/worktree-runtime-owner-index.detected.test.ts b/src/renderer/src/lib/worktree-runtime-owner-index.detected.test.ts new file mode 100644 index 000000000..2191a083e --- /dev/null +++ b/src/renderer/src/lib/worktree-runtime-owner-index.detected.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { + findIndexedDetectedWorktrees, + hasIndexedDetectedWorktree, + resolveIndexedWorktreeOwner +} from './worktree-runtime-owner-index' + +type OwnerRecord = { + id: string + repoId: string + hostId?: ExecutionHostId + runtimeOwnerEnvironmentId?: string +} +type DetectedByRepo = Record + +// The pre-index expressions this module replaced, kept verbatim as parity oracles. +function walkHasDetected(detectedWorktreesByRepo: DetectedByRepo | undefined, id: string): boolean { + return Object.values(detectedWorktreesByRepo ?? {}).some((result) => + result.worktrees.some((worktree) => worktree.id === id) + ) +} + +function walkDetectedMatches( + detectedWorktreesByRepo: DetectedByRepo | undefined, + id: string +): OwnerRecord[] { + const matches: OwnerRecord[] = [] + for (const result of Object.values(detectedWorktreesByRepo ?? {})) { + for (const worktree of result.worktrees) { + if (worktree.id === id) { + matches.push(worktree) + } + } + } + return matches +} + +function walkHasKnown( + worktreesByRepo: Record | undefined, + id: string +): boolean { + return Object.values(worktreesByRepo ?? {}).some((worktrees) => + worktrees.some((worktree) => worktree.id === id) + ) +} + +// Deterministic LCG so a parity failure reproduces from the printed case index. +function makeRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state * 1664525 + 1013904223) >>> 0 + return state / 0x100000000 + } +} + +const HOST_IDS: (ExecutionHostId | undefined)[] = [ + undefined, + 'local', + 'ssh:target-a', + 'ssh:target-b', + 'runtime:hub-a' +] + +function buildCase(random: () => number): { + detectedWorktreesByRepo: DetectedByRepo | undefined + worktreesByRepo: Record + probeIds: string[] +} { + const shape = random() + if (shape < 0.05) { + return { detectedWorktreesByRepo: undefined, worktreesByRepo: {}, probeIds: ['repo-0::absent'] } + } + if (shape < 0.1) { + return { detectedWorktreesByRepo: {}, worktreesByRepo: {}, probeIds: ['repo-0::absent'] } + } + const repoCount = 1 + Math.floor(random() * 6) + const detectedWorktreesByRepo: DetectedByRepo = {} + const worktreesByRepo: Record = {} + const knownIds: string[] = [] + for (let repoIndex = 0; repoIndex < repoCount; repoIndex += 1) { + const repoId = `repo-${repoIndex}` + // Empty arrays are a real store shape: a scan that found nothing still publishes its bucket. + const worktreeCount = random() < 0.25 ? 0 : 1 + Math.floor(random() * 5) + const detected: OwnerRecord[] = [] + const published: OwnerRecord[] = [] + for (let index = 0; index < worktreeCount; index += 1) { + // Duplicate ids across repos are how rival publications collide, so allow a shared pool. + const id = random() < 0.3 ? `shared::worktree-${index}` : `${repoId}::worktree-${index}` + const record: OwnerRecord = { + id, + repoId, + hostId: HOST_IDS[Math.floor(random() * HOST_IDS.length)], + ...(random() < 0.3 ? { runtimeOwnerEnvironmentId: `hub-${Math.floor(random() * 3)}` } : {}) + } + knownIds.push(id) + if (random() < 0.8) { + detected.push(record) + } + if (random() < 0.5) { + published.push(record) + } + } + detectedWorktreesByRepo[repoId] = { worktrees: detected } + worktreesByRepo[repoId] = published + } + const probeIds = [ + knownIds[0] ?? 'repo-0::absent', + knownIds.at(-1) ?? 'repo-0::absent', + knownIds[Math.floor(random() * Math.max(knownIds.length, 1))] ?? 'repo-0::absent', + 'shared::worktree-0', + 'never-published::worktree' + ] + return { detectedWorktreesByRepo, worktreesByRepo, probeIds } +} + +describe('detected worktree index', () => { + it('matches the pre-index catalog walk across randomized store shapes', () => { + const random = makeRandom(0x5eed) + for (let caseIndex = 0; caseIndex < 240; caseIndex += 1) { + const { detectedWorktreesByRepo, worktreesByRepo, probeIds } = buildCase(random) + for (const probeId of probeIds) { + expect( + { + case: caseIndex, + probeId, + has: hasIndexedDetectedWorktree(detectedWorktreesByRepo, probeId) + }, + `case ${caseIndex} / ${probeId}` + ).toEqual({ + case: caseIndex, + probeId, + has: walkHasDetected(detectedWorktreesByRepo, probeId) + }) + expect( + findIndexedDetectedWorktrees(detectedWorktreesByRepo, probeId), + `case ${caseIndex} / ${probeId}` + ).toEqual(walkDetectedMatches(detectedWorktreesByRepo, probeId)) + expect( + resolveIndexedWorktreeOwner(worktreesByRepo, probeId).kind !== 'missing', + `case ${caseIndex} / ${probeId}` + ).toBe(walkHasKnown(worktreesByRepo, probeId)) + } + } + }) + + it('returns rival publications in catalog order with identity preserved', () => { + const first = { id: 'shared', repoId: 'repo-a', hostId: 'ssh:target-a' as const } + const second = { id: 'shared', repoId: 'repo-b', hostId: 'runtime:hub-a' as const } + const detectedWorktreesByRepo = { + 'repo-a': { worktrees: [first] }, + 'repo-b': { worktrees: [second] } + } + + const matches = findIndexedDetectedWorktrees(detectedWorktreesByRepo, 'shared') + expect(matches).toHaveLength(2) + expect(matches[0]).toBe(first) + expect(matches[1]).toBe(second) + }) + + it('treats undefined, empty, and empty-bucket catalogs as unpublished', () => { + expect(hasIndexedDetectedWorktree(undefined, 'repo::wt')).toBe(false) + expect(findIndexedDetectedWorktrees(undefined, 'repo::wt')).toEqual([]) + expect(hasIndexedDetectedWorktree({}, 'repo::wt')).toBe(false) + expect(hasIndexedDetectedWorktree({ repo: { worktrees: [] } }, 'repo::wt')).toBe(false) + }) + + it('re-indexes a new catalog identity instead of serving stale hits', () => { + const worktree = { id: 'repo::wt', repoId: 'repo' } + const before = { repo: { worktrees: [worktree] } } + expect(hasIndexedDetectedWorktree(before, 'repo::wt')).toBe(true) + + const afterRemoval = { repo: { worktrees: [] } } + expect(hasIndexedDetectedWorktree(afterRemoval, 'repo::wt')).toBe(false) + + const afterReadd = { repo: { worktrees: [worktree] } } + expect(findIndexedDetectedWorktrees(afterReadd, 'repo::wt')).toEqual([worktree]) + // The prior identity keeps its own cached answer; nothing bleeds between snapshots. + expect(hasIndexedDetectedWorktree(afterRemoval, 'repo::wt')).toBe(false) + expect(hasIndexedDetectedWorktree(before, 'repo::wt')).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/worktree-runtime-owner-index.ts b/src/renderer/src/lib/worktree-runtime-owner-index.ts index 25b1af478..8fc642cfd 100644 --- a/src/renderer/src/lib/worktree-runtime-owner-index.ts +++ b/src/renderer/src/lib/worktree-runtime-owner-index.ts @@ -8,6 +8,7 @@ import { } from '../../../shared/execution-host' type WorktreeOwnerRecord = Pick +type DetectedWorktreeListing = { worktrees: readonly WorktreeOwnerRecord[] } type RepoOwnerRecord = Pick type FolderWorkspaceOwnerRecord = Pick< FolderWorkspace, @@ -33,6 +34,12 @@ const projectGroupOwnerIndexCache = new WeakMap< readonly ProjectGroupOwnerRecord[], ReadonlyMap >() +const detectedWorktreeIndexCache = new WeakMap< + Record, + ReadonlyMap +>() + +const NO_DETECTED_WORKTREES: readonly WorktreeOwnerRecord[] = [] type IndexedFolderWorkspaceOwnerResolution = | { kind: 'resolved'; owner: FolderWorkspaceOwnerRecord } @@ -210,6 +217,43 @@ export function resolveIndexedWorktreeOwner( return index.get(worktreeId) ?? { kind: 'missing' } } +/** + * Every detected publication of `worktreeId`, in catalog order. Rival repos may publish the same + * id, so callers that fail closed on conflicts need all matches rather than one resolved owner. + */ +export function findIndexedDetectedWorktrees( + detectedWorktreesByRepo: Record | undefined, + worktreeId: string +): readonly WorktreeOwnerRecord[] { + if (!detectedWorktreesByRepo) { + return NO_DETECTED_WORKTREES + } + let index = detectedWorktreeIndexCache.get(detectedWorktreesByRepo) + if (!index) { + const next = new Map() + for (const listing of Object.values(detectedWorktreesByRepo)) { + for (const worktree of listing.worktrees) { + const matches = next.get(worktree.id) + if (matches) { + matches.push(worktree) + } else { + next.set(worktree.id, [worktree]) + } + } + } + index = next + detectedWorktreeIndexCache.set(detectedWorktreesByRepo, index) + } + return index.get(worktreeId) ?? NO_DETECTED_WORKTREES +} + +export function hasIndexedDetectedWorktree( + detectedWorktreesByRepo: Record | undefined, + worktreeId: string +): boolean { + return findIndexedDetectedWorktrees(detectedWorktreesByRepo, worktreeId).length > 0 +} + export function findIndexedRepoOwner( repos: readonly RepoOwnerRecord[] | undefined, repoId: string diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index e322e834d..999ba347c 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -7,6 +7,7 @@ import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' import { findIndexedRepoOwner as findRepoRecord, findIndexedWorktreeOwner as findWorktreeRecord, + hasIndexedDetectedWorktree, resolveIndexedRepoOwner, resolveIndexedWorktreeOwner } from './worktree-runtime-owner-index' @@ -79,9 +80,7 @@ export function getRuntimeEnvironmentIdForWorktree( const owner = indexedOwner.owner const projectedRuntimeOwner = getProjectedRuntimeOwnerEnvironmentId(owner) const parsedHost = parseExecutionHostId(owner.hostId) - const hasDetectedOwner = Object.values(state.detectedWorktreesByRepo ?? {}).some((result) => - result.worktrees.some((worktree) => worktree.id === worktreeId) - ) + const hasDetectedOwner = hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId) if (!hasDetectedOwner && (projectedRuntimeOwner || parsedHost)) { return ( projectedRuntimeOwner || (parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null) @@ -125,9 +124,7 @@ export function getExplicitRuntimeEnvironmentIdForWorktree( workspaceScope.folderWorkspaceId ) } - const hasDetectedOwner = Object.values(state.detectedWorktreesByRepo ?? {}).some((result) => - result.worktrees.some((worktree) => worktree.id === worktreeId) - ) + const hasDetectedOwner = hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId) if (hasDetectedOwner) { // Why: detected-only rows are selectable before the primary catalog lands; use the same // ambiguity-aware explicit provenance as filesystem and terminal operations. @@ -177,9 +174,7 @@ export function getExecutionHostIdForWorktree( if (workspaceScope?.type === 'folder') { return getExecutionHostIdForFolderWorkspace(state, workspaceScope.folderWorkspaceId) } - const hasDetectedOwner = Object.values(state.detectedWorktreesByRepo ?? {}).some((result) => - result.worktrees.some((worktree) => worktree.id === worktreeId) - ) + const hasDetectedOwner = hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId) if (hasDetectedOwner) { const resolution = resolveExplicitWorktreeOperationRouteResult(state, worktreeId) if (resolution.kind === 'resolved') {