mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
perf(renderer): keep worktree lineage map identity on no-op refresh
* perf(renderer): keep worktree lineage map identity on no-op refresh A host-scoped lineage refresh always spread IPC clones into new maps, so WorktreeList Object.is subscriptions missed every time even when capture, taskId, and coordinatorHandle were unchanged. Reuse structurally equal records, return the previous maps when nothing moved, and skip set() when both maps are still current so a no-op refresh is actually a no-op. Co-authored-by: Orca <help@stably.ai> * type(store): mark worktree lineage maps readonly Widen reuseEqualRecordMap and the renderer lineage store fields to Readonly<Record<...>> so a reused no-op refresh map cannot be mutated in place. Co-authored-by: Orca <help@stably.ai> * type(store): build lineage overlays on mutable records mergeExactHostLineage was writing into AppState map types after they became Readonly. Use local mutable Records, then reuseEqualRecordMap. Co-authored-by: Orca <help@stably.ai> * fix(renderer): keep direct-ssh host hydration under the max-lines cap The lineage overlay rewrite pushed the file to 305 counted lines against the 300 cap, failing oxlint. Fold the duplicated per-map filter/overlay into one scoped helper so both maps share it. Co-authored-by: Orca <help@stably.ai> * perf(renderer): drop dead reuseEqualRecordMap key loop A key missing from next always shows up as either a key-count mismatch or a failed previous lookup in the first pass, so the second pass over the previous keys could never flip identical. Cover the identity contract with focused unit tests, including the same-count key swap that the removed loop appeared to guard. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HostLineageSnapshot } from '../../../shared/host-lineage-contract'
|
||||
import type { HostRepoCatalogSnapshot } from '../../../shared/host-repo-catalog-contract'
|
||||
import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types'
|
||||
import type { Repo } from '../../../shared/types'
|
||||
import type { Repo, WorktreeLineage, WorkspaceLineage } from '../../../shared/types'
|
||||
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import type { AppState } from '../store/types'
|
||||
import { createDirectSshHostHydration } from './direct-ssh-host-hydration'
|
||||
@@ -40,6 +40,34 @@ function state(overrides: Record<string, unknown> = {}): AppState {
|
||||
} as unknown as AppState
|
||||
}
|
||||
|
||||
function productionLineage(worktreeId: string, parentWorktreeId: string): WorktreeLineage {
|
||||
return {
|
||||
worktreeId,
|
||||
worktreeInstanceId: `${worktreeId}-instance`,
|
||||
parentWorktreeId,
|
||||
parentWorktreeInstanceId: `${parentWorktreeId}-instance`,
|
||||
origin: 'orchestration',
|
||||
capture: { source: 'orchestration-context', confidence: 'explicit' },
|
||||
taskId: 'task-1',
|
||||
coordinatorHandle: 'coord-1',
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function productionWorkspaceLineage(childId: string, parentId: string): WorkspaceLineage {
|
||||
return {
|
||||
childWorkspaceKey: worktreeWorkspaceKey(childId),
|
||||
childInstanceId: `${childId}-instance`,
|
||||
parentWorkspaceKey: worktreeWorkspaceKey(parentId),
|
||||
parentInstanceId: `${parentId}-instance`,
|
||||
origin: 'orchestration',
|
||||
capture: { source: 'orchestration-context', confidence: 'explicit' },
|
||||
taskId: 'task-1',
|
||||
coordinatorHandle: 'coord-1',
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function hostSnapshot(
|
||||
owner: DirectSshAuthority,
|
||||
repos: ReturnType<typeof repo>[]
|
||||
@@ -341,6 +369,79 @@ describe('createDirectSshHostHydration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps lineage map identity for a cloned no-op host snapshot', async () => {
|
||||
const owner = authority()
|
||||
const hostLineage = productionLineage('a::/work', 'a::/parent')
|
||||
const foreignLineage = productionLineage('b::/work', 'b::/parent')
|
||||
const hostWorkspace = productionWorkspaceLineage('a::/work', 'a::/parent')
|
||||
const foreignWorkspace = productionWorkspaceLineage('b::/work', 'b::/parent')
|
||||
const store = createStore<AppState>(() =>
|
||||
state({
|
||||
repos: [repo('a', 'target-a'), repo('b', 'target-b')],
|
||||
worktreesByRepo: {
|
||||
a: [{ id: 'a::/work', repoId: 'a', hostId: 'ssh:target-a' }],
|
||||
b: [{ id: 'b::/work', repoId: 'b', hostId: 'ssh:target-b' }]
|
||||
},
|
||||
worktreeLineageById: {
|
||||
'a::/work': hostLineage,
|
||||
'b::/work': foreignLineage
|
||||
},
|
||||
workspaceLineageByChildKey: {
|
||||
[worktreeWorkspaceKey('a::/work')]: hostWorkspace,
|
||||
[worktreeWorkspaceKey('b::/work')]: foreignWorkspace
|
||||
}
|
||||
})
|
||||
)
|
||||
const snapshot: HostLineageSnapshot = {
|
||||
authoritative: true,
|
||||
authority: {
|
||||
kind: 'direct-ssh',
|
||||
executionHostId: 'ssh:target-a',
|
||||
...owner
|
||||
},
|
||||
worktreeLineageById: {
|
||||
'a::/work': hostLineage
|
||||
},
|
||||
workspaceLineageByChildKey: {
|
||||
[worktreeWorkspaceKey('a::/work')]: hostWorkspace
|
||||
}
|
||||
}
|
||||
let publications = 0
|
||||
store.subscribe(() => {
|
||||
publications += 1
|
||||
})
|
||||
const hydration = createDirectSshHostHydration({
|
||||
store,
|
||||
listRepos: vi.fn(),
|
||||
listLineage: vi.fn(async () => structuredClone(snapshot)),
|
||||
isCurrentAuthority: () => true
|
||||
})
|
||||
const beforeLineage = store.getState().worktreeLineageById
|
||||
const beforeWorkspace = store.getState().workspaceLineageByChildKey
|
||||
|
||||
await expect(
|
||||
hydration.readHostScopedLineage({
|
||||
...owner,
|
||||
catalogRevision: 0,
|
||||
repoRefs: [{ repoId: 'a', executionHostId: 'ssh:target-a' }],
|
||||
authorityRequirement: 'required',
|
||||
reason: 'reconnect'
|
||||
})
|
||||
).resolves.toBe('complete')
|
||||
|
||||
expect(store.getState().worktreeLineageById).toBe(beforeLineage)
|
||||
expect(store.getState().workspaceLineageByChildKey).toBe(beforeWorkspace)
|
||||
expect(store.getState().worktreeLineageById['a::/work']).toBe(hostLineage)
|
||||
expect(store.getState().worktreeLineageById['b::/work']).toBe(foreignLineage)
|
||||
expect(store.getState().workspaceLineageByChildKey[worktreeWorkspaceKey('a::/work')]).toBe(
|
||||
hostWorkspace
|
||||
)
|
||||
expect(store.getState().workspaceLineageByChildKey[worktreeWorkspaceKey('b::/work')]).toBe(
|
||||
foreignWorkspace
|
||||
)
|
||||
expect(publications).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects lineage captured before a newer same-authority catalog revision', async () => {
|
||||
const owner = authority()
|
||||
const store = createStore<AppState>(() =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { HostRepoCatalogSnapshot } from '../../../shared/host-repo-catalog-
|
||||
import type { DirectSshAuthority } from '../../../shared/ssh-types'
|
||||
import { isWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import type { AppState } from '../store/types'
|
||||
import { reuseEqualRecordMap } from '../store/slices/repo-identity-reconcile'
|
||||
import type {
|
||||
DirectSshLineageOutcome,
|
||||
DirectSshPreparationInput,
|
||||
@@ -71,6 +72,20 @@ function mergeExactHostCatalog(state: AppState, snapshot: HostRepoCatalogSnapsho
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the host owns every in-scope key, so drop the stale in-scope rows, overlay the snapshot's,
|
||||
// and leave out-of-scope hosts alone. Reusing equal rows keeps a cloned no-op read from republishing.
|
||||
function overlayHostScopedLineage<T>(
|
||||
previous: Readonly<Record<string, T>>,
|
||||
incoming: Readonly<Record<string, T>>,
|
||||
isHostScoped: (key: string) => boolean
|
||||
): Readonly<Record<string, T>> {
|
||||
const next: Record<string, T> = Object.fromEntries([
|
||||
...Object.entries(previous).filter(([key]) => !isHostScoped(key)),
|
||||
...Object.entries(incoming).filter(([key]) => isHostScoped(key))
|
||||
])
|
||||
return reuseEqualRecordMap(previous, next)
|
||||
}
|
||||
|
||||
function mergeExactHostLineage(
|
||||
state: AppState,
|
||||
snapshot: Extract<HostLineageSnapshot, { authoritative: true }>,
|
||||
@@ -78,38 +93,23 @@ function mergeExactHostLineage(
|
||||
catalogRevision: number
|
||||
): AppState {
|
||||
const scope = directSshHostHydrationScope(state, authority, catalogRevision)
|
||||
const worktreeLineageById = Object.fromEntries(
|
||||
Object.entries(state.worktreeLineageById).filter(
|
||||
([worktreeId]) => !scope.gitWorktreeIds.has(worktreeId)
|
||||
)
|
||||
const worktreeLineageById = overlayHostScopedLineage(
|
||||
state.worktreeLineageById,
|
||||
snapshot.worktreeLineageById,
|
||||
(worktreeId) => scope.gitWorktreeIds.has(worktreeId)
|
||||
)
|
||||
const workspaceLineageByChildKey = Object.fromEntries(
|
||||
Object.entries(state.workspaceLineageByChildKey).filter(
|
||||
([childKey]) => !isWorkspaceKey(childKey) || !scope.lineageWorkspaceKeys.has(childKey)
|
||||
)
|
||||
const workspaceLineageByChildKey = overlayHostScopedLineage(
|
||||
state.workspaceLineageByChildKey,
|
||||
snapshot.workspaceLineageByChildKey,
|
||||
(childKey) => isWorkspaceKey(childKey) && scope.lineageWorkspaceKeys.has(childKey)
|
||||
)
|
||||
const incomingWorktreeLineage = Object.fromEntries(
|
||||
Object.entries(snapshot.worktreeLineageById).filter(([worktreeId]) =>
|
||||
scope.gitWorktreeIds.has(worktreeId)
|
||||
)
|
||||
)
|
||||
const incomingWorkspaceLineage = Object.fromEntries(
|
||||
Object.entries(snapshot.workspaceLineageByChildKey).filter(
|
||||
([workspaceKey]) =>
|
||||
isWorkspaceKey(workspaceKey) && scope.lineageWorkspaceKeys.has(workspaceKey)
|
||||
)
|
||||
)
|
||||
return {
|
||||
...state,
|
||||
worktreeLineageById: {
|
||||
...worktreeLineageById,
|
||||
...incomingWorktreeLineage
|
||||
},
|
||||
workspaceLineageByChildKey: {
|
||||
...workspaceLineageByChildKey,
|
||||
...incomingWorkspaceLineage
|
||||
}
|
||||
if (
|
||||
worktreeLineageById === state.worktreeLineageById &&
|
||||
workspaceLineageByChildKey === state.workspaceLineageByChildKey
|
||||
) {
|
||||
return state
|
||||
}
|
||||
return { ...state, worktreeLineageById, workspaceLineageByChildKey }
|
||||
}
|
||||
|
||||
export function createDirectSshHostHydration(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import { reconcileFetchedRepos } from './repo-identity-reconcile'
|
||||
import { reconcileFetchedRepos, reuseEqualRecordMap } from './repo-identity-reconcile'
|
||||
|
||||
function makeRepo(id: string, overrides: Partial<Repo> = {}): Repo {
|
||||
return { id, path: `/${id}`, displayName: id, badgeColor: '#000', addedAt: 1, ...overrides }
|
||||
@@ -123,3 +123,54 @@ describe('reconcileFetchedRepos', () => {
|
||||
expect(source).not.toMatch(/previous\.find|next\.find|findIndex/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reuseEqualRecordMap', () => {
|
||||
const row = (value: string): { value: string; nested: { tags: string[] } } => ({
|
||||
value,
|
||||
nested: { tags: [value, 'shared'] }
|
||||
})
|
||||
|
||||
it('returns the previous map when every key holds an equal value', () => {
|
||||
const previous = { a: row('a'), b: row('b') }
|
||||
expect(reuseEqualRecordMap(previous, { a: row('a'), b: row('b') })).toBe(previous)
|
||||
})
|
||||
|
||||
it('reuses the previous entry for structurally equal nested values', () => {
|
||||
const previous = { a: row('a'), b: row('b') }
|
||||
const result = reuseEqualRecordMap(previous, { a: row('a'), b: row('changed') })
|
||||
expect(result).not.toBe(previous)
|
||||
expect(result.a).toBe(previous.a)
|
||||
expect(result.b).toEqual(row('changed'))
|
||||
})
|
||||
|
||||
it('returns a new map when a value changed', () => {
|
||||
const previous = { a: row('a') }
|
||||
const result = reuseEqualRecordMap(previous, { a: row('changed') })
|
||||
expect(result).not.toBe(previous)
|
||||
expect(result.a).toEqual(row('changed'))
|
||||
})
|
||||
|
||||
it('returns a new map when a key is added', () => {
|
||||
const previous = { a: row('a') }
|
||||
const result = reuseEqualRecordMap(previous, { a: row('a'), b: row('b') })
|
||||
expect(result).not.toBe(previous)
|
||||
expect(result.a).toBe(previous.a)
|
||||
expect(Object.keys(result)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('returns a new map when a key is removed', () => {
|
||||
const previous = { a: row('a'), b: row('b') }
|
||||
const result = reuseEqualRecordMap(previous, { a: row('a') })
|
||||
expect(result).not.toBe(previous)
|
||||
expect(Object.keys(result)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('returns a new map when a key is swapped for a same-count replacement', () => {
|
||||
// Why: equal key counts alone must not read as unchanged — the replacement key misses the
|
||||
// previous lookup, which is why no second pass over the previous keys is needed.
|
||||
const previous = { a: row('a'), b: row('b') }
|
||||
const result = reuseEqualRecordMap(previous, { a: row('a'), c: row('b') })
|
||||
expect(result).not.toBe(previous)
|
||||
expect(Object.keys(result)).toEqual(['a', 'c'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,3 +79,28 @@ export function reconcileFetchedRepos(
|
||||
): readonly Repo[] {
|
||||
return reconcileCatalogRows(previous, next, getRepoHostIdentity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuses equal record values from `previous` — and the whole map when nothing
|
||||
* changed — so a cloned no-op refresh leaves Object.is subscribers untouched.
|
||||
*/
|
||||
export function reuseEqualRecordMap<T>(
|
||||
previous: Readonly<Record<string, T>>,
|
||||
next: Readonly<Record<string, T>>
|
||||
): Readonly<Record<string, T>> {
|
||||
const nextKeys = Object.keys(next)
|
||||
// Why: a matching key count plus every `next` key resolving to an equal `previous` entry below
|
||||
// means the key sets match, so a removed key always lands as either a count or a lookup miss.
|
||||
let identical = nextKeys.length === Object.keys(previous).length
|
||||
const reconciled: Record<string, T> = {}
|
||||
for (const key of nextKeys) {
|
||||
const existing = Object.hasOwn(previous, key) ? previous[key] : undefined
|
||||
if (existing !== undefined && areValuesEqual(existing, next[key])) {
|
||||
reconciled[key] = existing
|
||||
continue
|
||||
}
|
||||
identical = false
|
||||
reconciled[key] = next[key]
|
||||
}
|
||||
return identical ? previous : reconciled
|
||||
}
|
||||
|
||||
@@ -89,8 +89,8 @@ export type ActiveWorktreeStateTransition = (state: AppState) => {
|
||||
export type WorktreeSlice = {
|
||||
worktreesByRepo: Record<string, Worktree[]>
|
||||
detectedWorktreesByRepo: Record<string, DetectedWorktreeListResult>
|
||||
worktreeLineageById: Record<string, WorktreeLineage>
|
||||
workspaceLineageByChildKey: Record<WorkspaceKey, WorkspaceLineage>
|
||||
worktreeLineageById: Readonly<Record<string, WorktreeLineage>>
|
||||
workspaceLineageByChildKey: Readonly<Record<WorkspaceKey, WorkspaceLineage>>
|
||||
activeWorktreeId: string | null
|
||||
activeWorkspaceKey: WorkspaceKey | null
|
||||
activeWorkspaceExecutionHostId: ExecutionHostId | null
|
||||
|
||||
@@ -3187,6 +3187,71 @@ describe('fetchWorktrees', () => {
|
||||
expect(store.getState().sortEpoch).toBe(7)
|
||||
})
|
||||
|
||||
it('keeps remote lineage map identity when a cloned payload is unchanged', async () => {
|
||||
const store = createTestStore()
|
||||
const worktree = makeWorktree({
|
||||
id: 'repo1::/remote/wt1',
|
||||
repoId: 'repo1',
|
||||
path: '/remote/wt1',
|
||||
branch: 'refs/heads/remote',
|
||||
hostId: 'runtime:env-1'
|
||||
})
|
||||
const lineage = makeLineage({
|
||||
worktreeId: worktree.id,
|
||||
parentWorktreeId: 'repo1::/remote/parent',
|
||||
capture: { source: 'orchestration-context', confidence: 'explicit' },
|
||||
taskId: 'task-42',
|
||||
coordinatorHandle: 'coord-1'
|
||||
})
|
||||
const workspaceLineage = makeWorkspaceLineage({
|
||||
childWorkspaceKey: worktreeWorkspaceKey(worktree.id),
|
||||
parentWorkspaceKey: worktreeWorkspaceKey(lineage.parentWorktreeId),
|
||||
childInstanceId: lineage.worktreeInstanceId,
|
||||
parentInstanceId: lineage.parentWorktreeInstanceId,
|
||||
origin: lineage.origin,
|
||||
capture: lineage.capture,
|
||||
taskId: lineage.taskId,
|
||||
coordinatorHandle: lineage.coordinatorHandle,
|
||||
createdAt: lineage.createdAt
|
||||
})
|
||||
const detected = makeDetectedResult('repo1', [worktree])
|
||||
store.setState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
|
||||
worktreesByRepo: { repo1: [worktree] },
|
||||
detectedWorktreesByRepo: { repo1: detected },
|
||||
worktreeLineageById: { [lineage.worktreeId]: lineage },
|
||||
workspaceLineageByChildKey: {
|
||||
[workspaceLineage.childWorkspaceKey]: workspaceLineage
|
||||
},
|
||||
sortEpoch: 7
|
||||
} as Partial<AppState>)
|
||||
runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => {
|
||||
const result =
|
||||
method === 'worktree.lineageList'
|
||||
? structuredClone({
|
||||
lineage: { [lineage.worktreeId]: lineage },
|
||||
workspaceLineage: { [workspaceLineage.childWorkspaceKey]: workspaceLineage }
|
||||
})
|
||||
: structuredClone(detected)
|
||||
return Promise.resolve({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result,
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
})
|
||||
const before = store.getState()
|
||||
|
||||
await store.getState().fetchWorktrees('repo1')
|
||||
|
||||
expect(store.getState().worktreeLineageById).toBe(before.worktreeLineageById)
|
||||
expect(store.getState().workspaceLineageByChildKey).toBe(before.workspaceLineageByChildKey)
|
||||
expect(store.getState().worktreeLineageById[lineage.worktreeId]).toBe(lineage)
|
||||
expect(store.getState().workspaceLineageByChildKey[workspaceLineage.childWorkspaceKey]).toBe(
|
||||
workspaceLineage
|
||||
)
|
||||
})
|
||||
|
||||
it('defers remote lineage when a caller owns the final host refresh', async () => {
|
||||
const store = createTestStore()
|
||||
const worktree = makeWorktree({
|
||||
@@ -3319,6 +3384,135 @@ describe('worktree lineage state', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps lineage map and entry identity across a cloned no-op refresh', async () => {
|
||||
const lineage = makeLineage({
|
||||
capture: { source: 'orchestration-context', confidence: 'explicit' },
|
||||
taskId: 'task-42',
|
||||
coordinatorHandle: 'coord-1'
|
||||
})
|
||||
const workspaceLineage = makeWorkspaceLineage({
|
||||
childWorkspaceKey: worktreeWorkspaceKey(lineage.worktreeId),
|
||||
parentWorkspaceKey: worktreeWorkspaceKey(lineage.parentWorktreeId),
|
||||
childInstanceId: lineage.worktreeInstanceId,
|
||||
parentInstanceId: lineage.parentWorktreeInstanceId,
|
||||
origin: lineage.origin,
|
||||
capture: lineage.capture,
|
||||
taskId: lineage.taskId,
|
||||
coordinatorHandle: lineage.coordinatorHandle,
|
||||
createdAt: lineage.createdAt
|
||||
})
|
||||
const store = createLocalLineageTestStore(lineage)
|
||||
const payload = {
|
||||
lineage: { [lineage.worktreeId]: lineage },
|
||||
workspaceLineage: { [workspaceLineage.childWorkspaceKey]: workspaceLineage }
|
||||
}
|
||||
mockApi.worktrees.listLineage.mockImplementation(async () => structuredClone(payload))
|
||||
|
||||
await store.getState().fetchWorktreeLineage()
|
||||
const afterFirst = store.getState()
|
||||
const lineageById = afterFirst.worktreeLineageById
|
||||
const workspaceByKey = afterFirst.workspaceLineageByChildKey
|
||||
const lineageEntry = lineageById[lineage.worktreeId]
|
||||
const workspaceEntry = workspaceByKey[workspaceLineage.childWorkspaceKey]
|
||||
expect(lineageEntry).toEqual(lineage)
|
||||
expect(lineageEntry).not.toBe(lineage)
|
||||
const subscriber = vi.fn()
|
||||
const unsubscribe = store.subscribe(subscriber)
|
||||
|
||||
await store.getState().fetchWorktreeLineage()
|
||||
unsubscribe()
|
||||
|
||||
expect(store.getState().worktreeLineageById).toBe(lineageById)
|
||||
expect(store.getState().workspaceLineageByChildKey).toBe(workspaceByKey)
|
||||
expect(store.getState().worktreeLineageById[lineage.worktreeId]).toBe(lineageEntry)
|
||||
expect(store.getState().workspaceLineageByChildKey[workspaceLineage.childWorkspaceKey]).toBe(
|
||||
workspaceEntry
|
||||
)
|
||||
expect(subscriber).not.toHaveBeenCalled()
|
||||
expect(mockApi.worktrees.listLineage).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('preserves other-host lineage row identity across a cloned same-host refresh', async () => {
|
||||
const store = createTestStore()
|
||||
const localWorktree = makeWorktree({
|
||||
id: 'repo1::/local/child',
|
||||
repoId: 'repo1',
|
||||
hostId: LOCAL_EXECUTION_HOST_ID
|
||||
})
|
||||
const sshWorktree = makeWorktree({
|
||||
id: 'repo2::/ssh/child',
|
||||
repoId: 'repo2',
|
||||
hostId: 'ssh:ssh-1'
|
||||
})
|
||||
const localLineage = makeLineage({
|
||||
worktreeId: localWorktree.id,
|
||||
parentWorktreeId: 'repo1::/local/parent',
|
||||
capture: { source: 'orchestration-context', confidence: 'explicit' },
|
||||
taskId: 'task-local',
|
||||
coordinatorHandle: 'coord-local'
|
||||
})
|
||||
const sshLineage = makeLineage({
|
||||
worktreeId: sshWorktree.id,
|
||||
parentWorktreeId: 'repo2::/ssh/parent',
|
||||
capture: { source: 'cwd-context', confidence: 'inferred' },
|
||||
taskId: 'task-ssh',
|
||||
coordinatorHandle: 'coord-ssh'
|
||||
})
|
||||
const localWorkspace = makeWorkspaceLineage({
|
||||
childWorkspaceKey: worktreeWorkspaceKey(localWorktree.id),
|
||||
parentWorkspaceKey: worktreeWorkspaceKey(localLineage.parentWorktreeId),
|
||||
childInstanceId: localLineage.worktreeInstanceId,
|
||||
parentInstanceId: localLineage.parentWorktreeInstanceId,
|
||||
origin: localLineage.origin,
|
||||
capture: localLineage.capture,
|
||||
taskId: localLineage.taskId,
|
||||
coordinatorHandle: localLineage.coordinatorHandle
|
||||
})
|
||||
const sshWorkspace = makeWorkspaceLineage({
|
||||
childWorkspaceKey: worktreeWorkspaceKey(sshWorktree.id),
|
||||
parentWorkspaceKey: worktreeWorkspaceKey(sshLineage.parentWorktreeId),
|
||||
childInstanceId: sshLineage.worktreeInstanceId,
|
||||
parentInstanceId: sshLineage.parentWorktreeInstanceId,
|
||||
origin: sshLineage.origin,
|
||||
capture: sshLineage.capture,
|
||||
taskId: sshLineage.taskId,
|
||||
coordinatorHandle: sshLineage.coordinatorHandle
|
||||
})
|
||||
store.setState({
|
||||
worktreesByRepo: {
|
||||
repo1: [localWorktree],
|
||||
repo2: [sshWorktree]
|
||||
},
|
||||
worktreeLineageById: {
|
||||
[localWorktree.id]: localLineage,
|
||||
[sshWorktree.id]: sshLineage
|
||||
},
|
||||
workspaceLineageByChildKey: {
|
||||
[localWorkspace.childWorkspaceKey]: localWorkspace,
|
||||
[sshWorkspace.childWorkspaceKey]: sshWorkspace
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
const payload = {
|
||||
lineage: { [localWorktree.id]: localLineage },
|
||||
workspaceLineage: { [localWorkspace.childWorkspaceKey]: localWorkspace }
|
||||
}
|
||||
mockApi.worktrees.listLineage.mockImplementation(async () => structuredClone(payload))
|
||||
const before = store.getState()
|
||||
|
||||
await store.getState().fetchWorktreeLineage()
|
||||
|
||||
expect(store.getState().worktreeLineageById).toBe(before.worktreeLineageById)
|
||||
expect(store.getState().workspaceLineageByChildKey).toBe(before.workspaceLineageByChildKey)
|
||||
expect(store.getState().worktreeLineageById[localWorktree.id]).toBe(localLineage)
|
||||
expect(store.getState().worktreeLineageById[sshWorktree.id]).toBe(sshLineage)
|
||||
expect(store.getState().workspaceLineageByChildKey[localWorkspace.childWorkspaceKey]).toBe(
|
||||
localWorkspace
|
||||
)
|
||||
expect(store.getState().workspaceLineageByChildKey[sshWorkspace.childWorkspaceKey]).toBe(
|
||||
sshWorkspace
|
||||
)
|
||||
})
|
||||
|
||||
it('clears workspace lineage on successful old-shape lineage refresh', async () => {
|
||||
const store = createTestStore()
|
||||
const lineage = makeLineage()
|
||||
|
||||
@@ -133,6 +133,7 @@ import type {
|
||||
import type { DirectSshAuthority } from '../../../../shared/ssh-types'
|
||||
import { findIndexedWorktreeOwnerForHost } from '@/lib/worktree-runtime-owner-index'
|
||||
import { catalogRowsEqual, reuseEqualCatalogRows } from './worktree-catalog-reconciliation'
|
||||
import { reuseEqualRecordMap } from './repo-identity-reconcile'
|
||||
export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers'
|
||||
|
||||
// Why: old runtime servers only have `worktree.list`; preserve the large-list UI hydration parity used before `worktree.detectedList` existed.
|
||||
@@ -1390,8 +1391,8 @@ async function listWorktreeLineageForRuntime(
|
||||
settings: AppState['settings'],
|
||||
options: BackgroundRuntimeRefreshOptions = {}
|
||||
): Promise<{
|
||||
worktreeLineageById: Record<string, WorktreeLineage>
|
||||
workspaceLineageByChildKey: Record<string, WorkspaceLineage>
|
||||
worktreeLineageById: Readonly<Record<string, WorktreeLineage>>
|
||||
workspaceLineageByChildKey: Readonly<Record<string, WorkspaceLineage>>
|
||||
}> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
type LineageListResponse = {
|
||||
@@ -1570,21 +1571,38 @@ function applyWorktreeLineageUpdate(
|
||||
})
|
||||
}
|
||||
|
||||
function applyHostLineageRefresh(
|
||||
set: Parameters<StateCreator<AppState>>[0],
|
||||
hostId: ExecutionHostId,
|
||||
lineage: {
|
||||
worktreeLineageById: Readonly<Record<string, WorktreeLineage>>
|
||||
workspaceLineageByChildKey: Readonly<Record<string, WorkspaceLineage>>
|
||||
}
|
||||
): void {
|
||||
set((s) => {
|
||||
const worktreeLineageById = mergeLineageForHost(s, hostId, lineage.worktreeLineageById)
|
||||
const workspaceLineageByChildKey = mergeWorkspaceLineageForHost(
|
||||
s,
|
||||
hostId,
|
||||
lineage.workspaceLineageByChildKey
|
||||
)
|
||||
if (
|
||||
worktreeLineageById === s.worktreeLineageById &&
|
||||
workspaceLineageByChildKey === s.workspaceLineageByChildKey
|
||||
) {
|
||||
return s
|
||||
}
|
||||
return { worktreeLineageById, workspaceLineageByChildKey }
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshWorktreeLineageForSettings(
|
||||
settings: AppState['settings'],
|
||||
set: Parameters<StateCreator<AppState>>[0],
|
||||
options: BackgroundRuntimeRefreshOptions = {}
|
||||
): Promise<void> {
|
||||
const lineage = await listWorktreeLineageForRuntime(settings, options)
|
||||
const hostId = getSettingsFocusedExecutionHostId(settings)
|
||||
set((s) => ({
|
||||
worktreeLineageById: mergeLineageForHost(s, hostId, lineage.worktreeLineageById),
|
||||
workspaceLineageByChildKey: mergeWorkspaceLineageForHost(
|
||||
s,
|
||||
hostId,
|
||||
lineage.workspaceLineageByChildKey
|
||||
)
|
||||
}))
|
||||
applyHostLineageRefresh(set, getSettingsFocusedExecutionHostId(settings), lineage)
|
||||
}
|
||||
|
||||
async function refreshRemoteWorktreeLineageBestEffort(
|
||||
@@ -1598,15 +1616,7 @@ async function refreshRemoteWorktreeLineageBestEffort(
|
||||
const lineage = await listWorktreeLineageForRuntime(settings, {
|
||||
reuseRecentCompatibilityFailure: true
|
||||
})
|
||||
const hostId = getSettingsFocusedExecutionHostId(settings)
|
||||
set((s) => ({
|
||||
worktreeLineageById: mergeLineageForHost(s, hostId, lineage.worktreeLineageById),
|
||||
workspaceLineageByChildKey: mergeWorkspaceLineageForHost(
|
||||
s,
|
||||
hostId,
|
||||
lineage.workspaceLineageByChildKey
|
||||
)
|
||||
}))
|
||||
applyHostLineageRefresh(set, getSettingsFocusedExecutionHostId(settings), lineage)
|
||||
} catch (err) {
|
||||
// Why: lineage is supplemental, so a remote timeout here must not discard a successful worktree refresh.
|
||||
console.error('Failed to fetch worktree lineage:', err)
|
||||
@@ -1638,15 +1648,18 @@ function mergeLineageForHost(
|
||||
'repos' | 'settings' | 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'worktreeLineageById'
|
||||
>,
|
||||
hostId: ExecutionHostId,
|
||||
lineage: Record<string, WorktreeLineage>
|
||||
): Record<string, WorktreeLineage> {
|
||||
lineage: Readonly<Record<string, WorktreeLineage>>
|
||||
): Readonly<Record<string, WorktreeLineage>> {
|
||||
const next: Record<string, WorktreeLineage> = {}
|
||||
for (const [worktreeId, existing] of Object.entries(state.worktreeLineageById)) {
|
||||
if (getWorktreeHostId(state, worktreeId) !== hostId) {
|
||||
next[worktreeId] = existing
|
||||
}
|
||||
}
|
||||
return { ...next, ...lineage }
|
||||
for (const [worktreeId, incoming] of Object.entries(lineage)) {
|
||||
next[worktreeId] = incoming
|
||||
}
|
||||
return reuseEqualRecordMap(state.worktreeLineageById, next)
|
||||
}
|
||||
|
||||
function mergeWorkspaceLineageForHost(
|
||||
@@ -1659,8 +1672,8 @@ function mergeWorkspaceLineageForHost(
|
||||
| 'workspaceLineageByChildKey'
|
||||
>,
|
||||
hostId: ExecutionHostId,
|
||||
lineage: Record<string, WorkspaceLineage>
|
||||
): Record<string, WorkspaceLineage> {
|
||||
lineage: Readonly<Record<string, WorkspaceLineage>>
|
||||
): Readonly<Record<string, WorkspaceLineage>> {
|
||||
const next: Record<string, WorkspaceLineage> = {}
|
||||
for (const [childKey, existing] of Object.entries(state.workspaceLineageByChildKey)) {
|
||||
const childScope = parseWorkspaceKey(existing.childWorkspaceKey)
|
||||
@@ -1671,7 +1684,10 @@ function mergeWorkspaceLineageForHost(
|
||||
next[childKey] = existing
|
||||
}
|
||||
}
|
||||
return { ...next, ...lineage }
|
||||
for (const [childKey, incoming] of Object.entries(lineage)) {
|
||||
next[childKey] = incoming
|
||||
}
|
||||
return reuseEqualRecordMap(state.workspaceLineageByChildKey, next)
|
||||
}
|
||||
|
||||
async function persistWorktreeMeta(
|
||||
|
||||
Reference in New Issue
Block a user