Merge remote-tracking branch 'origin/main' into brennanb2025/agent-group-routing-identity

This commit is contained in:
Brennan Benson
2026-08-27 15:12:54 -07:00
91 changed files with 2760 additions and 389 deletions
@@ -26,6 +26,7 @@ function createDeferredBoolean(): DeferredBoolean {
}
type AccessoryInputCommitHarnessOptions = {
readonly composing?: boolean
readonly heldText?: string
readonly sentText?: string
readonly pendingHandle?: string | null
@@ -46,6 +47,7 @@ type AccessoryInputCommitHarness = {
}
function createAccessoryInputCommitHarness({
composing,
heldText = '',
sentText = '',
pendingHandle = null,
@@ -55,6 +57,7 @@ function createAccessoryInputCommitHarness({
}: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness {
const activeHandle = 'terminal-a'
const heldLiveInputTextRef: RefObject<string> = { current: heldText }
const liveInputComposingRef: RefObject<boolean | undefined> = { current: composing }
const sentLiveInputTextRef: RefObject<string> = { current: sentText }
const pendingLiveInputHandleRef: RefObject<string | null> = { current: pendingHandle }
const liveInputRef: RefObject<TextInput | null> = { current: null }
@@ -66,7 +69,9 @@ function createAccessoryInputCommitHarness({
return sendResult
}
}
const applyLiveInputMirror = vi.fn((_handle: string, _fieldText: string) => {})
const applyLiveInputMirror = vi.fn(
(_handle: string, _fieldText: string, _composing?: boolean) => {}
)
const clearPendingLiveInputCommit = vi.fn(() => {})
const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult)
const waitForPendingLiveInputFlush = vi.fn(async () => waitResult)
@@ -82,6 +87,7 @@ function createAccessoryInputCommitHarness({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
@@ -178,10 +184,11 @@ describe('terminal live accessory input commit hook', () => {
expect(harness.sent).toEqual([])
})
it('Given accessory backspace with a held syllable When committed Then mirrors the emptied field without terminal bytes', async () => {
it('Given accessory backspace with reported composition When committed Then keeps the edited preedit held', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '한',
composing: true,
heldText: 'ni hao',
sentText: '',
pendingHandle: 'terminal-a'
})
@@ -190,14 +197,31 @@ describe('terminal live accessory input commit hook', () => {
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '')
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'ni ha', true)
expect(result).toEqual({ kind: 'handled' })
expect(harness.sent).toEqual([])
})
it('Given accessory backspace with an unreported hold When committed Then preserves the Android fallback', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '한글',
sentText: '',
pendingHandle: 'terminal-a'
})
// When
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '한', undefined)
expect(result).toEqual({ kind: 'handled' })
})
it('Given accessory backspace with mirrored sent text When committed Then mirrors the shortened field so the diff emits DEL', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
composing: false,
heldText: '',
sentText: 'ab',
pendingHandle: 'terminal-a'
@@ -207,7 +231,7 @@ describe('terminal live accessory input commit hook', () => {
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a')
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a', false)
expect(result).toEqual({ kind: 'handled' })
})
})
@@ -21,10 +21,11 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult(
type TerminalLiveAccessoryInputCommitOptions = {
readonly activeHandle: string | null
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly liveInputComposingRef: RefObject<boolean | undefined>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly pendingLiveInputHandleRef: RefObject<string | null>
@@ -40,6 +41,7 @@ export function useTerminalLiveAccessoryInputCommit({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
@@ -81,7 +83,8 @@ export function useTerminalLiveAccessoryInputCommit({
// field is edited here and the mirror diff syncs the PTY echo.
setLiveInputCapture(editedText)
liveInputRef.current?.setNativeProps({ text: editedText })
applyLiveInputMirror(activeHandle, editedText)
// Preserve undefined so Android's heuristic hold still settles on its timer.
applyLiveInputMirror(activeHandle, editedText, liveInputComposingRef.current)
return { kind: 'handled' }
}
case 'commit-held-then-send':
@@ -101,6 +104,7 @@ export function useTerminalLiveAccessoryInputCommit({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
@@ -161,6 +161,35 @@ describe('terminal live input commit hook', () => {
expect(sent).toEqual([])
})
it('Given an iOS pinyin preedit When accessory Backspace edits it Then only the candidate reaches the terminal', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
changeLiveInput(handlers, 'ni hao', true)
// When
await handlers.handleLiveInputAccessoryBytes({ bytes: '\x7f', localEdit: 'backspace' })
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS * 2)
changeLiveInput(handlers, '你好', false)
// Then
await vi.waitFor(() => expect(sent).toEqual(['你好']))
})
it('Given an Android heuristic hold When accessory Backspace edits it Then the remaining text still settles', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
changeLiveInput(handlers, '한글')
// When
await handlers.handleLiveInputAccessoryBytes({ bytes: '\x7f', localEdit: 'backspace' })
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS)
// Then
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
// Given
vi.useFakeTimers()
@@ -68,6 +68,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
@@ -196,6 +197,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
@@ -34,6 +34,7 @@ type TerminalLivePendingInputFlush = {
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly liveInputComposingRef: RefObject<boolean | undefined>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly sentLiveInputTextRef: RefObject<string>
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
@@ -50,6 +51,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState())
const heldLiveInputTextRef = useRef('')
const liveInputComposingRef = useRef<boolean | undefined>(undefined)
const sentLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef<string | null>(null)
const runMirrorStepRef = useRef<RunTerminalLiveMirrorStep>(async () => false)
@@ -65,6 +67,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
clearHeldCommitTimer()
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
heldLiveInputTextRef.current = ''
liveInputComposingRef.current = undefined
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
}, [clearHeldCommitTimer])
@@ -106,6 +109,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
})
sentLiveInputTextRef.current = step.nextSentText
heldLiveInputTextRef.current = step.heldText
liveInputComposingRef.current = composing
pendingLiveInputHandleRef.current =
step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null
@@ -187,6 +191,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
heldCommitTimerRef.current = null
}
heldLiveInputTextRef.current = ''
liveInputComposingRef.current = undefined
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
@@ -198,6 +203,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
+5 -5
View File
@@ -268,9 +268,9 @@ Common Commands:
Selectors:
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
--worktree <selector> Worktree selector such as id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current
--worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
--parent-worktree <selector> Parent worktree selector such as id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current
--parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current
--no-parent Force no parent lineage for unrelated worktree creation/update
Terminal Send Options:
@@ -509,7 +509,7 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
return '--parent-current Use the current linked issue as parent'
}
if (command === 'worktree create' && flag === 'parent-worktree') {
return '--parent-worktree <selector> Parent selector such as active/current, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, folder:<id>, or worktree:<worktreeId>'
return '--parent-worktree <selector> Parent selector such as identity:<identity>, active/current, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, folder:<id>, or worktree:<worktreeId>'
}
if (command === 'orchestration task-create' && flag === 'task-title') {
return '--task-title <text> Concise title for the orchestration task'
@@ -577,7 +577,7 @@ export function formatFlagHelp(flag: string): string {
'no-screenshot': '--no-screenshot Skip screenshot capture after the operation',
pages: '--pages <n> Number of scroll pages',
'parent-worktree':
'--parent-worktree <selector> Parent worktree selector such as id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current',
'--parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current',
path: '--path <path> Path argument for the command',
prompt: '--prompt <text> Prompt text for agent-backed commands',
query: '--query <text> Search text for matching refs',
@@ -601,7 +601,7 @@ export function formatFlagHelp(flag: string): string {
'to-x': '--to-x <x> Destination window-local x coordinate',
'to-y': '--to-y <y> Destination window-local y coordinate',
worktree:
'--worktree <selector> Worktree selector such as id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current',
'--worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current',
workspace: '--workspace <selector> Existing worktree selector for automation runs',
'workspace-status':
'--workspace-status <id> Board status id (defaults: todo, in-progress, in-review, completed)',
+3
View File
@@ -41,6 +41,7 @@ vi.mock('child_process', async () => {
})
import { COMMAND_SPECS, main } from './index'
import { formatFlagHelp } from './help'
import { GLOBAL_FLAGS, specPaths } from './args'
import { okFixture, queueFixtures } from './test-fixtures'
@@ -480,6 +481,7 @@ describe('orca root help', () => {
const rootHelp = String(logSpy.mock.calls[0][0])
expect(rootHelp).not.toContain('--parent-workspace')
expect(rootHelp).toContain('[--parent-worktree <selector>] [--no-parent]')
expect(rootHelp).toContain('identity:<identity>')
logSpy.mockClear()
await main(['worktree', 'create', '--help'], '/tmp/repo')
@@ -504,6 +506,7 @@ describe('orca root help', () => {
const setHelp = String(logSpy.mock.calls[0][0])
expect(setHelp).not.toContain('--parent-workspace')
expect(setHelp).not.toContain('folder:<id>')
expect(formatFlagHelp('parent-worktree')).toContain('identity:<identity>')
expect(setHelp).not.toContain('worktree:<id>')
expect(callMock).not.toHaveBeenCalled()
})
+1 -1
View File
@@ -39,7 +39,7 @@ function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient)
// server, so cwd-derived worktree selectors are only valid locally.
throw new RuntimeClientError(
'invalid_argument',
`${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, or path:<absolute-server-path>.`
`${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, or path:<absolute-server-path>.`
)
}
+30 -10
View File
@@ -88,31 +88,51 @@ export const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebu
rendererDispatcherReadyForcedCount: 0
}
export let readPtyRendererDeliveryDebugSnapshot = (): PtyRendererDeliveryDebugSnapshot => ({
...EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT
})
export let resetPtyRendererDeliveryDebugSnapshot = (): void => {}
// Why null-init + wrapper fns (not `export let fn = noop`): rolldown const-folds the noop into
// call sites and drops the setter's reassignment, so bridged closures never run in the built app (STA-5661).
let readPtyRendererDeliveryDebugSnapshotImpl: (() => PtyRendererDeliveryDebugSnapshot) | null = null
let resetPtyRendererDeliveryDebugSnapshotImpl: (() => void) | null = null
// Bridged into the registerPtyHandlers closure so the module-scope lifecycle-reset handler can zero closure-owned delivery accounting on renderer reload/crash.
export let resetRendererDeliveryAccountingForLifecycleReset = (): void => {}
let resetRendererDeliveryAccountingForLifecycleResetImpl: (() => void) | null = null
// Bridged so a re-registration can cancel the prior closure's dispatcher-ready watchdog before wiring its own.
export let clearRendererDispatcherReadyWatchdog = (): void => {}
let clearRendererDispatcherReadyWatchdogImpl: (() => void) | null = null
export function readPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot {
return (
readPtyRendererDeliveryDebugSnapshotImpl?.() ?? {
...EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT
}
)
}
export function resetPtyRendererDeliveryDebugSnapshot(): void {
resetPtyRendererDeliveryDebugSnapshotImpl?.()
}
export function resetRendererDeliveryAccountingForLifecycleReset(): void {
resetRendererDeliveryAccountingForLifecycleResetImpl?.()
}
export function clearRendererDispatcherReadyWatchdog(): void {
clearRendererDispatcherReadyWatchdogImpl?.()
}
export function setReadPtyRendererDeliveryDebugSnapshot(
fn: () => PtyRendererDeliveryDebugSnapshot
): void {
readPtyRendererDeliveryDebugSnapshot = fn
readPtyRendererDeliveryDebugSnapshotImpl = fn
}
export function setResetPtyRendererDeliveryDebugSnapshot(fn: () => void): void {
resetPtyRendererDeliveryDebugSnapshot = fn
resetPtyRendererDeliveryDebugSnapshotImpl = fn
}
export function setResetRendererDeliveryAccountingForLifecycleReset(fn: () => void): void {
resetRendererDeliveryAccountingForLifecycleReset = fn
resetRendererDeliveryAccountingForLifecycleResetImpl = fn
}
export function setClearRendererDispatcherReadyWatchdog(fn: () => void): void {
clearRendererDispatcherReadyWatchdog = fn
clearRendererDispatcherReadyWatchdogImpl = fn
}
export function getPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot {
+13 -4
View File
@@ -9,8 +9,17 @@ export const interactiveOutputCharsByPty = new Map<string, number>()
export const activeRendererPtys = new Set<string>()
export const visibleRendererPtys = new Set<string>()
export const rendererVisibilityKnownPtys = new Set<string>()
export let invalidatePendingPtyDrainPriority = (_id?: string, _schedule?: boolean): void => {}
export let invalidatePendingPtyDrainPolicy = (_id?: string, _schedule?: boolean): void => {}
// Why null-init + wrapper fns: see debug.ts — rolldown const-folds `export let fn = noop` bridges (STA-5661).
let invalidatePendingPtyDrainPriorityImpl: ((id?: string, schedule?: boolean) => void) | null = null
let invalidatePendingPtyDrainPolicyImpl: ((id?: string, schedule?: boolean) => void) | null = null
export function invalidatePendingPtyDrainPriority(id?: string, schedule?: boolean): void {
invalidatePendingPtyDrainPriorityImpl?.(id, schedule)
}
export function invalidatePendingPtyDrainPolicy(id?: string, schedule?: boolean): void {
invalidatePendingPtyDrainPolicyImpl?.(id, schedule)
}
export const pendingHiddenRendererResizeOutputPtys = new Set<string>()
export const deliveredHiddenRendererResizeOutputPtys = new Set<string>()
export const KEEP_HISTORY_STOP_SETTLE_MS = 1_000
@@ -21,11 +30,11 @@ export const providerSnapshotRequiredPtys = new Set<string>()
export function setInvalidatePendingPtyDrainPriority(
fn: (id?: string, schedule?: boolean) => void
): void {
invalidatePendingPtyDrainPriority = fn
invalidatePendingPtyDrainPriorityImpl = fn
}
export function setInvalidatePendingPtyDrainPolicy(
fn: (id?: string, schedule?: boolean) => void
): void {
invalidatePendingPtyDrainPolicy = fn
invalidatePendingPtyDrainPolicyImpl = fn
}
@@ -17,7 +17,12 @@ export let rendererGateResetLoadHandler: (() => void) | null = null
export let rendererGateResetGoneHandler: (() => void) | null = null
export let rendererGateResetWebContents: WebContents | null = null
// Why: the backgrounded-delivery dedupe map lives in the registerPtyHandlers closure but teardown funnels through module-scope clearProviderPtyState.
export let clearBackgroundedDeliverySyncForPty: (id: string) => void = () => {}
// Why null-init + wrapper fn: see delivery/debug.ts — rolldown const-folds `export let fn = noop` bridges (STA-5661).
let clearBackgroundedDeliverySyncForPtyImpl: ((id: string) => void) | null = null
export function clearBackgroundedDeliverySyncForPty(id: string): void {
clearBackgroundedDeliverySyncForPtyImpl?.(id)
}
export type RendererNavigationDetails = {
isMainFrame: boolean
@@ -42,7 +47,7 @@ export function setRebindProviderListeners(fn: (() => void) | null): void {
}
export function setClearBackgroundedDeliverySyncForPty(fn: (id: string) => void): void {
clearBackgroundedDeliverySyncForPty = fn
clearBackgroundedDeliverySyncForPtyImpl = fn
}
export function setSshOutputIntakeCleanup(fn: (() => void) | null): void {
@@ -12,10 +12,8 @@ import type {
ProjectHostSetupUpdateArgs,
ProjectHostSetupUpdateResult
} from '../../../shared/project-types'
import {
getProjectHostSetupForRepo,
getProjectIdForProviderIdentity
} from '../../../shared/project-host-setup-projection'
import { getProjectIdForProviderIdentity } from '../../../shared/project-host-setup-projection'
import { getProjectHostSetupForRepo } from '../../../shared/project-host-setup-lookup'
import { parseExecutionHostId } from '../../../shared/execution-host'
import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation'
import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cache'
@@ -179,7 +179,7 @@ describe('workspace cleanup broad scan opt-in', () => {
...makeStore(),
getWorktreeMeta: (worktreeId: string) =>
worktreeId === 'repo-1::/repo-old' ? runtimeMeta : META_BY_WORKTREE_ID[worktreeId]
} as Store
} as unknown as Store
const result = await scanWorkspaceCleanup(store, {
includeAllWorkspaces: true
@@ -561,6 +561,36 @@ describe('workspace cleanup broad scan opt-in', () => {
})
})
it('keeps a canonical-only SSH cleanup row blocked while local metadata collides', async () => {
const worktreeId = 'repo-1::/shared/workspace'
const localRepo = { ...REPO, path: '/local/repo' }
const sshRepo = { ...REPO, path: '/remote/repo', connectionId: 'ssh-1' }
const localMeta = makeWorktreeMeta({
displayName: 'Local workspace',
hostId: 'local'
})
const remoteMeta = makeWorktreeMeta({
displayName: 'Canonical remote workspace',
hostId: 'ssh:ssh-1'
})
const store = {
...makeStore([localRepo, sshRepo], { [worktreeId]: localMeta }),
getAllWorktreeMetaForHost: (hostId: string) =>
hostId === 'ssh:ssh-1' ? { [worktreeId]: remoteMeta } : { [worktreeId]: localMeta }
} as unknown as Store
const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true })
expect(result.candidates).toContainEqual(
expect.objectContaining({
worktreeId,
displayName: 'Canonical remote workspace',
executionHostId: 'ssh:ssh-1',
blockers: ['ssh-disconnected']
})
)
})
it('does not synthesize a disconnected SSH row from same-id local metadata', async () => {
const sharedPath = '/shared/workspace'
const worktreeId = `${REPO.id}::${sharedPath}`
@@ -15,6 +15,10 @@ import {
isWorkspaceInactiveForCleanup
} from './workspace-cleanup-candidate'
import { getRepoOwnedWorktreeMeta, isWorktreeMetaOwnedByRepo } from '../worktree-metadata-ownership'
import {
readAllWorktreeMetaForHost,
readWorktreeMetaForHost
} from '../persistence/host-qualified-worktree-meta'
export function synthesizeDisconnectedSshCleanupCandidates(
store: Store,
@@ -25,6 +29,7 @@ export function synthesizeDisconnectedSshCleanupCandidates(
includeAllWorkspaces = false
): WorkspaceCleanupCandidate[] {
const repoWorktreePrefix = `${repo.id}::`
const executionHostId = getRepoExecutionHostId(repo)
if (targetWorktreeIds) {
const candidates: WorkspaceCleanupCandidate[] = []
// Why: targeted refreshes name their workspaces already; walking all
@@ -33,7 +38,11 @@ export function synthesizeDisconnectedSshCleanupCandidates(
if (!worktreeId.startsWith(repoWorktreePrefix)) {
continue
}
const meta = store.getWorktreeMeta(worktreeId)
const meta =
readWorktreeMetaForHost(store, worktreeId, executionHostId) ??
(typeof store.getWorktreeMetaForHost === 'function'
? undefined
: store.getWorktreeMeta(worktreeId))
if (isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount)) {
candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta))
}
@@ -42,7 +51,7 @@ export function synthesizeDisconnectedSshCleanupCandidates(
}
const candidates: WorkspaceCleanupCandidate[] = []
const allMeta = store.getAllWorktreeMeta()
const allMeta = readAllWorktreeMetaForHost(store, executionHostId)
for (const worktreeId in allMeta) {
if (!Object.hasOwn(allMeta, worktreeId) || !worktreeId.startsWith(repoWorktreePrefix)) {
continue
+6 -1
View File
@@ -1,6 +1,8 @@
import type { Store } from '../persistence'
import type { IGitProvider } from '../providers/types'
import { isFolderRepo } from '../../shared/repo-kind'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import { readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta'
import type { Repo } from '../../shared/repo-types'
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
import { mergeWorktree } from './worktree-logic'
@@ -187,8 +189,11 @@ async function scanRepoWorkspaces(
? listWorkspaceCleanupFolderWorkspaces(store, repo, repoOwnerCount)
: gitWorktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
// Host-qualified first: the same repoId::path is a different checkout on each host.
const hostMeta = readWorktreeMetaForHost(store, worktreeId, getRepoExecutionHostId(repo))
const meta = store.getWorktreeMeta(worktreeId)
const ownedMeta = isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount) ? meta : undefined
const ownedMeta =
hostMeta ?? (isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount) ? meta : undefined)
return mergeWorktree(repo.id, gitWorktree, ownedMeta, repo.displayName)
})
// Why: with includeAllWorkspaces the browser shows every workspace and lets
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { canonicalWorktreeIdentity } from '../../shared/worktree/identity'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import { mergeWorktree } from './worktree-metadata-merge'
const git: GitWorktreeInfo = {
path: '/workspace/feature',
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
describe('mergeWorktree identity projection', () => {
it('publishes canonical identity when host and instance metadata are known', () => {
const worktree = mergeWorktree('repo-1', git, {
instanceId: '11111111-1111-4111-8111-111111111111',
hostId: 'ssh:build-box',
displayName: 'Feature',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
})
expect(worktree.identity).toEqual({
key: canonicalWorktreeIdentity({
worktreeId: worktree.id,
executionHostId: 'ssh:build-box',
instanceId: '11111111-1111-4111-8111-111111111111'
}),
executionHostId: 'ssh:build-box',
instanceId: '11111111-1111-4111-8111-111111111111'
})
})
it('omits canonical identity for legacy metadata without a proven host', () => {
const worktree = mergeWorktree('repo-1', git, undefined)
expect(worktree.identity).toBeUndefined()
})
})
+12 -1
View File
@@ -4,6 +4,7 @@ import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
import { getLinkedWorkItemMetadata } from './worktree-linked-work-item-metadata'
import { normalizeWorkspaceCreatorProvenance } from '../../shared/workspace-creator-provenance'
import { createWorktreeIdentity } from '../../shared/worktree/identity'
/**
* Merge raw git worktree info with persisted user metadata into a full Worktree.
@@ -16,8 +17,18 @@ export function mergeWorktree(
): Worktree {
const branchShort = git.branch.replace(/^refs\/heads\//, '')
const creatorProvenance = normalizeWorkspaceCreatorProvenance(meta?.creatorProvenance)
const worktreeId = `${repoId}::${git.path}`
return {
id: `${repoId}::${git.path}`,
id: worktreeId,
...(meta?.instanceId && meta.hostId
? {
identity: createWorktreeIdentity({
worktreeId,
executionHostId: meta.hostId,
instanceId: meta.instanceId
})
}
: {}),
...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}),
repoId,
...(meta?.projectId !== undefined ? { projectId: meta.projectId } : {}),
+1 -1
View File
@@ -48,7 +48,7 @@ import type {
RemoteFetchResult,
RemoteTrackingBase
} from '../runtime/orca-runtime'
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection'
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-lookup'
import { getEffectiveHooks, loadHooks, parseOrcaYaml } from '../hooks'
import { buildPosixRunnerScript, buildWindowsRunnerScript } from '../setup-runner-script-text'
import { createSetupRunnerScript, resolveSetupRunnerShell } from '../worktree-runner-script'
@@ -205,6 +205,36 @@ describe('registerWorktreeHandlers', () => {
})
expect(result).toMatchObject({ comment: 'keep me', isPinned: true })
})
it('routes metadata updates through the explicitly selected host', () => {
store.setWorktreeMetaForHost.mockImplementation((_worktreeId, _executionHostId, meta) => meta)
const result = handlers['worktrees:updateMeta'](null, {
worktreeId: 'repo-1::/workspace/feature-wt',
executionHostId: 'ssh:build-box',
updates: { comment: 'remote note' }
})
expect(store.setWorktreeMetaForHost).toHaveBeenCalledWith(
'repo-1::/workspace/feature-wt',
'ssh:build-box',
{ comment: 'remote note' }
)
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
expect(result).toMatchObject({ comment: 'remote note' })
})
it('rejects malformed execution-host identities at the IPC boundary', () => {
expect(() =>
handlers['worktrees:updateMeta'](null, {
worktreeId: 'repo-1::/workspace/feature-wt',
executionHostId: 'container:untrusted',
updates: { comment: 'must not write' }
})
).toThrow('Invalid execution host identity.')
expect(store.setWorktreeMetaForHost).not.toHaveBeenCalled()
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
})
it('pushes a remote-client invalidation for renames but not read-state updates', () => {
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listWorktreesMock, getSshGitProviderMock } from './worktrees-test-module-mocks'
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
import { makeWorktreeMeta } from './worktrees-test-fixtures'
import { buildDetectedGitWorktrees } from './worktrees/listing/ssh-worktree-fallback'
import type { Store } from '../persistence/loading-store/store'
vi.mock('electron', async () =>
(await import('./worktrees-test-module-mocks')).electronModuleMock()
@@ -224,6 +226,41 @@ describe('registerWorktreeHandlers', () => {
})
})
it('lists a canonical-only SSH row without leaking the same-id local row', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'SSH Repo',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1'
}
const worktreeId = 'repo-ssh::/shared/feature-wt'
const canonicalRemote = makeWorktreeMeta({
displayName: 'Canonical remote',
hostId: 'ssh:conn-1'
})
store.getRepo.mockReturnValue(repo)
store.getAllWorktreeMeta.mockReturnValue({
[worktreeId]: makeWorktreeMeta({ displayName: 'Legacy local', hostId: 'local' })
})
;(
store as typeof store & {
getAllWorktreeMetaForHost: ReturnType<typeof vi.fn>
}
).getAllWorktreeMetaForHost = vi.fn(() => ({ [worktreeId]: canonicalRemote }))
const listed = await handlers['worktrees:list'](null, { repoId: repo.id })
expect(listed).toEqual([
expect.objectContaining({
id: worktreeId,
displayName: 'Canonical remote',
hostId: 'ssh:conn-1'
})
])
})
it('falls back to reconstructed SSH rows when provider listing throws', async () => {
const repo = {
id: 'repo-ssh',
@@ -424,4 +461,31 @@ describe('registerWorktreeHandlers', () => {
expect.objectContaining({ id: 'repo-ssh-b::/remote/b/two' })
])
})
it('loads repo ownership once while building multiple detected rows', () => {
const owner = {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
store.getRepos.mockReturnValue([owner])
const detected = buildDetectedGitWorktrees(
store as unknown as Store,
owner,
['/workspace/one', '/workspace/two', '/workspace/three'].map((path) => ({
path,
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
})),
{}
)
expect(detected).toHaveLength(3)
expect(store.getRepos).toHaveBeenCalledTimes(1)
})
})
+12
View File
@@ -73,6 +73,8 @@ export {
/** Registers worktree IPC handlers against freshly reset shared mocks and returns the runtime stub. */
export function setupWorktreeHandlers(): WorktreeRuntimeStub {
delete (store as typeof store & { getAllWorktreeMetaForHost?: (...args: unknown[]) => unknown })
.getAllWorktreeMetaForHost
setPlatform(ORIGINAL_PLATFORM)
clearConfiguredWorktreeSharedDirectoriesCacheForTests()
__resetSshWorktreeCreateFetchCacheForTests()
@@ -125,8 +127,10 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub {
store.getSparsePresets,
store.getSettings,
store.getWorktreeMeta,
store.getWorktreeMetaForHost,
store.getAllWorktreeMeta,
store.setWorktreeMeta,
store.setWorktreeMetaForHost,
store.getProjectHostSetups,
store.removeWorktreeMeta,
store.removeWorkspaceSessionStateForWorktree,
@@ -190,10 +194,18 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub {
workspaceDir: '/workspace'
})
store.getWorktreeMeta.mockReturnValue(undefined)
// Host-qualified accessors delegate by default so payload assertions stay on one spy;
// host routing itself is covered by worktree-identity-persistence.test.ts.
store.getWorktreeMetaForHost.mockImplementation((...args: unknown[]) =>
store.getWorktreeMeta(args[0] as string)
)
store.getAllWorktreeMeta.mockReturnValue({})
store.getRetiredWorktreeNameRegistry.mockReturnValue({ exhaustedTiers: 0, names: [] })
resetRetirementCollisionKeyCacheForTests()
store.setWorktreeMeta.mockReturnValue({})
store.setWorktreeMetaForHost.mockImplementation((...args: unknown[]) =>
store.setWorktreeMeta(args[0] as string, args[2] as object)
)
store.getProjectHostSetups.mockReturnValue([
{
id: 'repo-1',
@@ -22,8 +22,10 @@ export type TestStore = {
getSparsePresets: StoreMock
getSettings: StoreMock
getWorktreeMeta: KeyedStoreMock
getWorktreeMetaForHost: StoreMock
getAllWorktreeMeta: StoreMock
setWorktreeMeta: KeyedStoreWriteMock
setWorktreeMetaForHost: StoreMock
getProjectHostSetups: StoreMock
removeWorktreeMeta: KeyedStoreMock
removeWorkspaceSessionStateForWorktree: KeyedStoreMock
@@ -54,8 +56,10 @@ export const store: TestStore = {
getSparsePresets: vi.fn(),
getSettings: vi.fn(),
getWorktreeMeta: vi.fn(),
getWorktreeMetaForHost: vi.fn(),
getAllWorktreeMeta: vi.fn(),
setWorktreeMeta: vi.fn(),
setWorktreeMetaForHost: vi.fn(),
getProjectHostSetups: vi.fn(),
removeWorktreeMeta: vi.fn(),
removeWorkspaceSessionStateForWorktree: vi.fn(),
+11
View File
@@ -165,6 +165,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
getProjectHostSetups: vi.fn(),
getSettings: vi.fn(),
getWorktreeMeta: vi.fn(),
getAllWorktreeMeta: vi.fn(),
setWorktreeMeta: vi.fn(),
removeWorktreeMeta: vi.fn(),
addRetiredWorktreeName: vi.fn(),
@@ -209,6 +210,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
store.getProjectHostSetups.mockReset()
store.getSettings.mockReset()
store.getWorktreeMeta.mockReset()
store.getAllWorktreeMeta.mockReset()
store.setWorktreeMeta.mockReset()
store.removeWorktreeMeta.mockReset()
store.addRetiredWorktreeName.mockReset()
@@ -251,6 +253,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
})
resolveSetupRunnerShellMock.mockReturnValue(undefined)
store.getWorktreeMeta.mockReturnValue(undefined)
store.getAllWorktreeMeta.mockReturnValue({})
store.getRetiredWorktreeNameRegistry.mockReturnValue({ exhaustedTiers: 0, names: [] })
store.setWorktreeMeta.mockReturnValue({})
resolveLocalGitUsernameMock.mockResolvedValue('')
@@ -501,6 +504,14 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
}
: undefined
)
store.getAllWorktreeMeta.mockReturnValue({
'repo-1::C:/workspaces/improve-dashboard': {
lastActivityAt: 123,
displayName: 'Improve Dashboard',
linkedIssue: 123,
linkedPR: 456
}
})
await handlers['worktrees:create'](null, {
repoId: 'repo-1',
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto'
import type { Repo } from '../../../../shared/repo-types'
import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-projection'
import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-lookup'
import type { CreateWorktreeResult } from '../../../../shared/worktree/create-types'
import type { Store } from '../../../persistence/loading-store/store'
import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas'
@@ -24,6 +24,8 @@ import {
rememberLocalWorktreeRoots
} from './detected-worktree-scan-cache'
import { loggedWorktreeListFailures, warnOnce } from './worktree-listing-diagnostics'
import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
export async function listDetectedWorktreesForCapturedRepo(
store: Store,
@@ -36,8 +38,11 @@ export async function listDetectedWorktreesForCapturedRepo(
providerAbort?.signal.aborted
? ({ providerAbortStatus: providerAbort.status() } as const)
: undefined
const allMeta = isFolderRepo(repo)
? undefined
: readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
const sshWorktreeMetaIndex = repo.connectionId
? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta()))
? createSshWorktreeMetaIndex(Object.entries(allMeta ?? {}))
: new Map()
try {
@@ -118,7 +123,7 @@ export async function listDetectedWorktreesForCapturedRepo(
repoId: repo.id,
authoritative: true,
source: 'git',
worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees)
worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees, allMeta)
}
} catch (err) {
const aborted = abortedResult()
@@ -23,6 +23,7 @@ import {
listDisconnectedSshWorktrees
} from './ssh-worktree-fallback'
import type { WorktreeIpcContext } from '../worktree-ipc-context'
import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta'
export function registerHostCatalogHandlers(context: WorktreeIpcContext): void {
const { store } = context
@@ -76,7 +77,10 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void {
)
)
}
const metaIndex = createSshWorktreeMetaIndexForRepo(store.getAllWorktreeMeta(), repo.id)
const metaIndex = createSshWorktreeMetaIndexForRepo(
readAllWorktreeMetaForHost(store, requestedExecutionHostId),
repo.id
)
return complete(
buildDisconnectedDetectedWorktrees(
store,
@@ -111,7 +115,7 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void {
if (isFolderRepo(repo)) {
return nothingForgotten
}
const allMeta = store.getAllWorktreeMeta()
const allMeta = readAllWorktreeMetaForHost(store, requestedExecutionHostId)
const forgottenWorktreeIds: string[] = []
for (const worktreeId of worktreeIds) {
const meta = typeof worktreeId === 'string' ? allMeta[worktreeId] : undefined
@@ -1,5 +1,6 @@
import { ipcMain } from 'electron'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import { getSshGitProvider } from '../../../providers/ssh-git-dispatch'
import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning'
import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry'
@@ -21,6 +22,8 @@ import {
warnOnce
} from './worktree-listing-diagnostics'
import type { WorktreeIpcContext } from '../worktree-ipc-context'
import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta'
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
const WORKTREE_LIST_ALL_CONCURRENCY = 8
@@ -49,9 +52,36 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
ipcMain.handle('worktrees:listAll', async () => {
const repos = store.getRepos()
const sshWorktreeMetaIndex = repos.some((repo) => repo.connectionId)
? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta()))
: new Map()
const legacyMetadata =
typeof store.getAllWorktreeMetaForHost === 'function' ? undefined : store.getAllWorktreeMeta()
const metadataByHost = new Map<ExecutionHostId, Record<string, WorktreeMeta>>()
const metadataForRepo = (repo: (typeof repos)[number]): Record<string, WorktreeMeta> => {
const hostId = getRepoExecutionHostId(repo)
const cached = metadataByHost.get(hostId)
if (cached) {
return cached
}
const metadata =
typeof store.getAllWorktreeMetaForHost === 'function'
? store.getAllWorktreeMetaForHost(hostId)
: readAllWorktreeMetaForHost({ getAllWorktreeMeta: () => legacyMetadata ?? {} }, hostId)
metadataByHost.set(hostId, metadata)
return metadata
}
const sshMetaIndexByHost = new Map<
ExecutionHostId,
ReturnType<typeof createSshWorktreeMetaIndex>
>()
const sshMetaIndexForRepo = (repo: (typeof repos)[number]) => {
const hostId = getRepoExecutionHostId(repo)
const cached = sshMetaIndexByHost.get(hostId)
if (cached) {
return cached
}
const index = createSshWorktreeMetaIndex(Object.entries(metadataForRepo(repo)))
sshMetaIndexByHost.set(hostId, index)
return index
}
// Why: each local repo listing can spawn `git worktree list`; cap fan-out so large fleets don't start unbounded subprocesses.
const results = await mapWithConcurrency(repos, WORKTREE_LIST_ALL_CONCURRENCY, async (repo) => {
@@ -68,7 +98,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
`${repo.connectionId}:${repo.id}`,
`[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}`
)
return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
return listDisconnectedSshWorktrees(store, repo, sshMetaIndexForRepo(repo))
}
loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`)
try {
@@ -80,7 +110,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
`[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`,
err
)
return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
return listDisconnectedSshWorktrees(store, repo, sshMetaIndexForRepo(repo))
}
} else {
const scan = await listDetectedGitWorktrees(store, repo)
@@ -92,9 +122,10 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
}
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
return buildDetectedGitWorktrees(store, repo, gitWorktrees)
const metadata = metadataForRepo(repo)
return buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata)
.filter((worktree) => worktree.visible)
.map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree))
.map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata))
} catch (err) {
warnOnce(
loggedWorktreeListFailures,
@@ -123,8 +154,11 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
if (!repo) {
return []
}
const allMeta = repo.connectionId
? readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
: undefined
const sshWorktreeMetaIndex = repo.connectionId
? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta()))
? createSshWorktreeMetaIndex(Object.entries(allMeta ?? {}))
: new Map()
try {
@@ -164,9 +198,10 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
}
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
return buildDetectedGitWorktrees(store, repo, gitWorktrees)
const metadata = allMeta ?? readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
return buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata)
.filter((worktree) => worktree.visible)
.map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree))
.map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata))
} catch (err) {
warnOnce(
loggedWorktreeListFailures,
@@ -8,6 +8,11 @@ import type { Repo } from '../../../../shared/repo-types'
import type { GitWorktreeInfo, DetectedWorktree, Worktree } from '../../../../shared/worktree/types'
import type { Store } from '../../../persistence/loading-store/store'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import {
readWorktreeMetaForHost,
writeWorktreeMetaForHost
} from '../../../persistence/host-qualified-worktree-meta'
import { getRepoOwnedWorktreeMeta } from '../../../worktree-metadata-ownership'
import {
buildKnownOrcaWorkspaceLayouts,
isLegacyRepoForExternalWorktreeVisibility,
@@ -110,7 +115,7 @@ export function listDisconnectedSshWorktrees(
? { ...candidate.meta, ...ownershipUpdates }
: candidate.meta
if (Object.keys(ownershipUpdates).length > 0) {
store.setWorktreeMeta(candidate.id, ownershipUpdates)
writeWorktreeMetaForHost(store, candidate.id, expectedHostId, ownershipUpdates)
}
// Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename()
// applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must
@@ -131,7 +136,8 @@ export function listDisconnectedSshWorktrees(
export function buildDetectedGitWorktrees(
store: Store,
repo: Repo,
gitWorktrees: GitWorktreeInfo[]
gitWorktrees: GitWorktreeInfo[],
allMetaOverride?: Record<string, WorktreeMeta>
): DetectedWorktree[] {
const settings = store.getSettings()
const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo)
@@ -145,9 +151,15 @@ export function buildDetectedGitWorktrees(
resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
)
const allMeta = allMetaOverride ?? store.getAllWorktreeMeta?.()
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
const detected = liveWorktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
let meta = store.getWorktreeMeta(worktreeId)
const legacyMeta = store.getWorktreeMeta?.(worktreeId)
const metaById = allMeta ?? (legacyMeta ? { [worktreeId]: legacyMeta } : {})
let meta =
readWorktreeMetaForHost(store, worktreeId, getRepoExecutionHostId(repo)) ??
getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
const detected = toDetectedWorktree({
repo,
@@ -162,7 +174,13 @@ export function buildDetectedGitWorktrees(
return detected
}
meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, worktreeId)
meta = resolveWorktreeMetaWithDiscoveryBackfill(
store,
repo,
worktreeId,
allMeta,
repoOwnerCount
)
return toDetectedWorktree({
repo,
worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName),
@@ -179,8 +197,9 @@ export function buildDetectedGitWorktrees(
export function stampAndMergeVisibleDetectedWorktree(
store: Store,
repo: Repo,
detected: DetectedWorktree
detected: DetectedWorktree,
allMetaOverride?: Record<string, WorktreeMeta>
) {
const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id)
const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id, allMetaOverride)
return mergeWorktree(repo.id, detected, meta, repo.displayName)
}
@@ -1,7 +1,13 @@
import type { Store } from '../../../persistence/loading-store/store'
import type { Repo } from '../../../../shared/repo-types'
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-projection'
import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-lookup'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import {
readWorktreeMetaForHost,
writeWorktreeMetaForHost
} from '../../../persistence/host-qualified-worktree-meta'
import { getRepoOwnedWorktreeMeta } from '../../../worktree-metadata-ownership'
import { randomUUID } from 'node:crypto'
export function getProjectHostSetupMetaUpdates(
@@ -29,9 +35,21 @@ export function getProjectHostSetupMetaUpdates(
export function resolveWorktreeMetaWithDiscoveryBackfill(
store: Store,
repo: Repo,
worktreeId: string
worktreeId: string,
allMetaOverride?: Record<string, WorktreeMeta>,
repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
): WorktreeMeta {
const existing = store.getWorktreeMeta(worktreeId)
const executionHostId = getRepoExecutionHostId(repo)
const legacyMeta = store.getWorktreeMeta?.(worktreeId)
const allMeta = allMetaOverride ?? store.getAllWorktreeMeta?.()
const existing =
readWorktreeMetaForHost(store, worktreeId, executionHostId) ??
getRepoOwnedWorktreeMeta(
repo,
worktreeId,
allMeta ?? (legacyMeta ? { [worktreeId]: legacyMeta } : {}),
repoOwnerCount
)
const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing)
if (existing) {
const updates = {
@@ -40,11 +58,11 @@ export function resolveWorktreeMetaWithDiscoveryBackfill(
}
if (Object.keys(updates).length > 0) {
// Why: pre-lineage profiles already have WorktreeMeta rows; backfill on discovery so upgraded workspaces get lineage and host routing.
return store.setWorktreeMeta(worktreeId, updates)
return writeWorktreeMetaForHost(store, worktreeId, executionHostId, updates)
}
return existing
}
return store.setWorktreeMeta(worktreeId, {
return writeWorktreeMetaForHost(store, worktreeId, executionHostId, {
lastActivityAt: Date.now(),
...ownershipUpdates
})
@@ -1,5 +1,6 @@
import { ipcMain } from 'electron'
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { stripOrcaProvenanceMetaUpdates } from '../../../worktree-removal-safety'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
import type {
@@ -16,10 +17,23 @@ import type { WorktreeIpcContext } from '../worktree-ipc-context'
export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): void {
const { mainWindow, store, runtime } = context
ipcMain.handle(
'worktrees:updateMeta',
(_event, args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => {
(
_event,
args: {
worktreeId: string
executionHostId?: string
updates: Partial<WorktreeMeta>
}
) => {
const executionHostId =
args.executionHostId === undefined
? undefined
: parseExecutionHostId(args.executionHostId)?.id
if (args.executionHostId !== undefined && !executionHostId) {
throw new Error('Invalid execution host identity.')
}
const validatedUpdates = normalizeLinkedWorkItemFields(args.updates)
const updates =
validatedUpdates.displayName !== undefined
@@ -29,7 +43,10 @@ export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): v
firstAgentMessageRenameError: null
}
: validatedUpdates
const meta = store.setWorktreeMeta(args.worktreeId, stripOrcaProvenanceMetaUpdates(updates))
const sanitizedUpdates = stripOrcaProvenanceMetaUpdates(updates)
const meta = executionHostId
? store.setWorktreeMetaForHost(args.worktreeId, executionHostId, sanitizedUpdates)
: store.setWorktreeMeta(args.worktreeId, sanitizedUpdates)
// Do NOT notify here: renderer already applied this optimistically; a notification would re-sort the sidebar (bug PR #209).
if (args.updates.displayName !== undefined) {
// Why: remote clients have no optimistic rename and stopped polling titles, so push a remote-only invalidation; gate on displayName so per-click isUnread updates stay event-free.
@@ -395,6 +395,12 @@ describe('Store', () => {
// Local worktree meta survives; the SSH host's meta is pruned.
expect(store.getWorktreeMeta('shared::/local/repo/wt')).toBeDefined()
expect(store.getWorktreeMeta('shared::/remote/repo/wt')).toBeUndefined()
store.flush()
const persisted = readDataFile() as PersistedState
const identityMetadata = Object.values(persisted.worktreeMetaByIdentity ?? {})
expect(identityMetadata).toEqual([
expect.objectContaining({ hostId: 'local', displayName: 'local-wt' })
])
})
it('removeProjectForHost keeps the surviving host session for a shared repo id + path', async () => {
@@ -0,0 +1,65 @@
import type { ExecutionHostId } from '../../shared/execution-host'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
/**
* The listing paths take partial store shapes (`Pick<Store, ...>`), so the
* host-qualified accessors are optional here and fall back to the locator-keyed
* ones. A real Store always has them.
*/
export type HostQualifiedWorktreeMetaStore = {
getAllWorktreeMeta: () => Record<string, WorktreeMeta>
getWorktreeMetaForHost?: (
worktreeId: string,
executionHostId: ExecutionHostId
) => WorktreeMeta | undefined
getAllWorktreeMetaForHost?: (executionHostId: ExecutionHostId) => Record<string, WorktreeMeta>
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => WorktreeMeta
setWorktreeMetaForHost?: (
worktreeId: string,
executionHostId: ExecutionHostId,
meta: Partial<WorktreeMeta>
) => WorktreeMeta
}
/** Canonical host snapshot, with a filtered legacy fallback for partial test/compatibility stores. */
export function readAllWorktreeMetaForHost(
store: Pick<HostQualifiedWorktreeMetaStore, 'getAllWorktreeMeta' | 'getAllWorktreeMetaForHost'>,
executionHostId: ExecutionHostId
): Record<string, WorktreeMeta> {
const qualified = store.getAllWorktreeMetaForHost?.(executionHostId)
if (qualified) {
return qualified
}
const projected: Record<string, WorktreeMeta> = {}
for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) {
if (!meta.hostId || meta.hostId === executionHostId) {
projected[worktreeId] = meta
}
}
return projected
}
/**
* The row this host owns at `worktreeId`, or undefined. Deliberately does NOT fall back to the
* locator-keyed read: that would hand one host's metadata to another's row, which is the collision
* every caller here is trying to avoid. Callers keep their own same-id ownership guard as fallback.
*/
export function readWorktreeMetaForHost(
store: Pick<HostQualifiedWorktreeMetaStore, 'getWorktreeMetaForHost'>,
worktreeId: string,
executionHostId: ExecutionHostId
): WorktreeMeta | undefined {
return store.getWorktreeMetaForHost?.(worktreeId, executionHostId)
}
export function writeWorktreeMetaForHost(
store: Pick<HostQualifiedWorktreeMetaStore, 'setWorktreeMeta' | 'setWorktreeMetaForHost'>,
worktreeId: string,
executionHostId: ExecutionHostId,
meta: Partial<WorktreeMeta>
): WorktreeMeta {
return (
store.setWorktreeMetaForHost?.(worktreeId, executionHostId, meta) ??
store.setWorktreeMeta(worktreeId, { ...meta, hostId: executionHostId })
)
}
@@ -0,0 +1,128 @@
import { randomUUID } from 'node:crypto'
import { isDeepStrictEqual } from 'node:util'
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
import { canonicalWorktreeIdentity } from '../../../shared/worktree/identity'
import {
composeWorktreeHostIdentity,
getExecutionHostIdFromWorktreeHostIdentity,
getWorktreeIdFromHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
type IdentityMove = {
sourceKey: string
nextKey: string
instanceId: string
sourceMeta: WorktreeMeta
nextMeta: WorktreeMeta
}
type AliasMove = {
oldAlias: string
nextAlias: string
identities: IdentityMove[]
}
/** Move canonical aliases only after every destination occupant is proven compatible. */
export function reassignCanonicalWorktreeMetadataHost(
state: PersistedState,
oldHostId: ExecutionHostId,
newHostId: ExecutionHostId
): { changed: boolean; preservedWorktreeIds: ReadonlySet<string> } {
const aliases = state.worktreeIdentityAliases ?? {}
const identityMeta = state.worktreeMetaByIdentity ?? {}
const repairedInstanceIds = new Map<string, string>()
const plans: AliasMove[] = []
for (const [oldAlias, rawKeys] of Object.entries(aliases)) {
if (getExecutionHostIdFromWorktreeHostIdentity(oldAlias) !== oldHostId) {
continue
}
const worktreeId = getWorktreeIdFromHostIdentity(oldAlias)
if (!worktreeId || rawKeys.length === 0) {
continue
}
const identities: IdentityMove[] = []
let safe = true
for (const sourceKey of new Set(rawKeys)) {
const sourceMeta = identityMeta[sourceKey]
if (!sourceMeta) {
safe = false
break
}
let instanceId = sourceMeta.instanceId ?? repairedInstanceIds.get(sourceKey)
if (instanceId === undefined) {
instanceId = randomUUID()
repairedInstanceIds.set(sourceKey, instanceId)
}
const nextKey = canonicalWorktreeIdentity({
worktreeId,
executionHostId: newHostId,
instanceId
})
const nextMeta = { ...sourceMeta, instanceId, hostId: newHostId }
const duplicate = identities.find((identity) => identity.nextKey === nextKey)
if (duplicate && !isDeepStrictEqual(duplicate.nextMeta, nextMeta)) {
safe = false
break
}
if (!duplicate) {
identities.push({ sourceKey, nextKey, instanceId, sourceMeta, nextMeta })
}
}
if (!safe) {
continue
}
const nextAlias = composeWorktreeHostIdentity(newHostId, worktreeId)
const candidateKeys = new Set(identities.map((identity) => identity.nextKey))
if ((aliases[nextAlias] ?? []).some((key) => !candidateKeys.has(key))) {
continue
}
if (
identities.some((identity) => {
const existing = identityMeta[identity.nextKey]
return existing !== undefined && !isDeepStrictEqual(existing, identity.nextMeta)
})
) {
continue
}
plans.push({ oldAlias, nextAlias, identities })
}
if (plans.length > 0) {
state.worktreeIdentityAliases ??= {}
state.worktreeMetaByIdentity ??= {}
for (const plan of plans) {
for (const identity of plan.identities) {
identity.sourceMeta.instanceId ??= identity.instanceId
state.worktreeMetaByIdentity[identity.nextKey] ??= identity.nextMeta
}
state.worktreeIdentityAliases[plan.nextAlias] = [
...new Set([
...(state.worktreeIdentityAliases[plan.nextAlias] ?? []),
...plan.identities.map((identity) => identity.nextKey)
])
]
delete state.worktreeIdentityAliases[plan.oldAlias]
}
const referenced = new Set(Object.values(state.worktreeIdentityAliases).flat())
for (const sourceKey of new Set(
plans.flatMap((plan) => plan.identities.map((identity) => identity.sourceKey))
)) {
if (!referenced.has(sourceKey)) {
delete state.worktreeMetaByIdentity[sourceKey]
}
}
}
const preservedWorktreeIds = new Set<string>()
for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) {
if (getExecutionHostIdFromWorktreeHostIdentity(alias) === oldHostId) {
preservedWorktreeIds.add(getWorktreeIdFromHostIdentity(alias))
}
}
return { changed: plans.length > 0, preservedWorktreeIds }
}
@@ -17,6 +17,7 @@ import {
migrateAutomationsForSshReadoption
} from '../../automations/automation-ssh-readoption-migration'
import { automationIdsPinnedToSshTarget } from '../scheduling-automations/automation-owner-projection'
import { reassignCanonicalWorktreeMetadataHost } from './canonical-worktree-host-reassignment'
export type SshTargetReassignmentOperations = {
state: PersistedState
@@ -80,10 +81,16 @@ export function reassignSshTargetId(
}
repoIds.add(repo.id)
}
// Re-point worktree metas whose hostId pointed at the old SSH host.
// Legacy locator rows cannot recover canonical-only metadata after target readoption.
const identityResult = reassignCanonicalWorktreeMetadataHost(
operations.state,
oldHostId,
newHostId
)
// Re-point legacy rows unless a conflicting destination kept their canonical source in place.
let metaChanged = false
for (const meta of Object.values(operations.state.worktreeMeta)) {
if (meta.hostId === oldHostId) {
for (const [worktreeId, meta] of Object.entries(operations.state.worktreeMeta)) {
if (meta.hostId === oldHostId && !identityResult.preservedWorktreeIds.has(worktreeId)) {
meta.hostId = newHostId
metaChanged = true
}
@@ -175,7 +182,13 @@ export function reassignSshTargetId(
if (repoIds.size > 0 || setupsChanged) {
operations.syncProjectHostSetupCompatibilityState()
}
if (repoIds.size > 0 || metaChanged || carrierChanged || setupsChanged) {
if (
repoIds.size > 0 ||
metaChanged ||
carrierChanged ||
setupsChanged ||
identityResult.changed
) {
// The rewrites above patch rows in place; the list projection caches on array
// identity, so a same-identity array would keep serving pre-readoption owners.
operations.state.repos = [...operations.state.repos]
@@ -1,45 +1,14 @@
import { randomUUID } from 'node:crypto'
import type { WorkspaceKey } from '../../../shared/folder-workspace-types'
import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types'
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context'
import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item'
import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../shared/workspace-statuses'
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope'
import {
workspaceSessionOwnerPartitionForHost,
workspaceSessionPartitionIdsForHost
} from '../restoring-sessions/session-owner-removal'
import { migrateWorktreeIdentity as migrateWorktreeIdentityOperation } from '../tracking-repos/worktree-identity-migration'
function getDefaultWorktreeMeta(): WorktreeMeta {
return {
instanceId: randomUUID(),
displayName: '',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
linkedBitbucketPR: null,
linkedAzureDevOpsPR: null,
linkedGiteaPR: null,
linkedWorkItem: null,
linkedTaskSourceContext: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: Date.now(),
lastActivityAt: 0,
workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID
}
}
import type { StoreRuntimeState } from './store-runtime-state'
import type { WriteSchedulingOperations } from './write-scheduling'
import type { SessionHostPartitionOperations } from './session-host-partitions'
@@ -50,6 +19,15 @@ import {
partitionHasOtherRepoWorktreeTabs,
removeWorkspaceSessionOwnerInPartition
} from './session-host-partitions'
import { migrateWorktreeIdentity as migrateWorktreeIdentityOperation } from '../tracking-repos/worktree-identity-migration'
import {
getAllWorktreeMetaForHost as getAllWorktreeMetaForHostOperation,
getWorktreeMetaForHost as getWorktreeMetaForHostOperation,
migrateWorktreeMetadataLocator,
removeWorktreeMetadataForHost,
setWorktreeMetaForHost as setWorktreeMetaForHostOperation
} from './worktree-identity-metadata'
import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization'
type MetadataLineageOperationsRuntime = Pick<StoreRuntimeState, 'state'>
@@ -70,34 +48,62 @@ export class MetadataLineageOperations {
) {
this[metadataLineageOperationsContext] = { runtime, scheduling, sessions }
}
getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined {
return this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId]
}
getWorktreeMetaForHost(
worktreeId: string,
executionHostId: ExecutionHostId
): WorktreeMeta | undefined {
return getWorktreeMetaForHostOperation(
this[metadataLineageOperationsContext].runtime,
this[metadataLineageOperationsContext].scheduling,
worktreeId,
executionHostId
)
}
setWorktreeMetaForHost(
worktreeId: string,
executionHostId: ExecutionHostId,
meta: Partial<WorktreeMeta>
): WorktreeMeta {
return setWorktreeMetaForHostOperation(
this[metadataLineageOperationsContext].runtime,
this[metadataLineageOperationsContext].scheduling,
worktreeId,
executionHostId,
meta
)
}
getAllWorktreeMeta(): Record<string, WorktreeMeta> {
return this[metadataLineageOperationsContext].runtime.state.worktreeMeta
}
getAllWorktreeMetaForHost(executionHostId: ExecutionHostId): Record<string, WorktreeMeta> {
return getAllWorktreeMetaForHostOperation(
this[metadataLineageOperationsContext].runtime,
executionHostId
)
}
setWorktreeMeta(worktreeId: string, meta: Partial<WorktreeMeta>): WorktreeMeta {
const existing =
this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] ||
getDefaultWorktreeMeta()
const updated = { ...existing, ...meta }
updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem)
const linkedTaskSourceContext = normalizeStoredTaskSourceContext(
updated.linkedTaskSourceContext
)
updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch(
updated.linkedWorkItem,
linkedTaskSourceContext
)
? linkedTaskSourceContext
: null
if (!updated.instanceId) {
updated.instanceId = randomUUID()
const state = this[metadataLineageOperationsContext].runtime.state
const stored = state.worktreeMeta[worktreeId]
const executionHostId = meta.hostId ?? stored?.hostId
if (executionHostId) {
return setWorktreeMetaForHostOperation(
this[metadataLineageOperationsContext].runtime,
this[metadataLineageOperationsContext].scheduling,
worktreeId,
executionHostId,
meta
)
}
this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] = updated
const updated = mergeWorktreeMetaForWrite(stored, meta)
state.worktreeMeta[worktreeId] = updated
scheduleSave(this[metadataLineageOperationsContext].scheduling)
return updated
}
@@ -120,6 +126,13 @@ export class MetadataLineageOperations {
ownerPartition
))
)
// Mirror the legacy delete's scope below: a full removal takes every host's identity rows,
// otherwise the surviving owner keeps its own.
removeWorktreeMetadataForHost(
this[metadataLineageOperationsContext].runtime.state,
worktreeId,
hostId === undefined ? undefined : (hostId ?? undefined)
)
// Skip partitions main never wrote: materializing one fences every sibling worktree of the repo.
const partitions = new Set<ExecutionHostId>(
workspaceSessionPartitionIdsForHost(owner).filter(
@@ -190,14 +203,27 @@ export class MetadataLineageOperations {
scheduleSave(this[metadataLineageOperationsContext].scheduling)
}
migrateWorktreeIdentity(oldWorktreeId: string, newWorktreeId: string): void {
if (
migrateWorktreeIdentityOperation(
this[metadataLineageOperationsContext].runtime.state,
oldWorktreeId,
newWorktreeId
)
) {
migrateWorktreeIdentity(
oldWorktreeId: string,
newWorktreeId: string,
executionHostId?: ExecutionHostId
): void {
const state = this[metadataLineageOperationsContext].runtime.state
const persistedOwner = state.worktreeMeta[oldWorktreeId]?.hostId
const mover = executionHostId ?? persistedOwner ?? LOCAL_EXECUTION_HOST_ID
const legacyChanged =
executionHostId !== undefined &&
persistedOwner !== undefined &&
persistedOwner !== executionHostId
? false
: migrateWorktreeIdentityOperation(state, oldWorktreeId, newWorktreeId)
const canonicalChanged = migrateWorktreeMetadataLocator(
state,
oldWorktreeId,
newWorktreeId,
mover
)
if (legacyChanged || canonicalChanged) {
scheduleSave(this[metadataLineageOperationsContext].scheduling)
}
}
@@ -0,0 +1,236 @@
import { randomUUID } from 'node:crypto'
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { canonicalWorktreeIdentity } from '../../../shared/worktree/identity'
import {
composeWorktreeHostIdentity,
getExecutionHostIdFromWorktreeHostIdentity,
getWorktreeIdFromHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { StoreRuntimeState } from './store-runtime-state'
import type { WriteSchedulingOperations } from './write-scheduling'
import { scheduleSave } from './write-scheduling'
import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization'
type MetadataRuntime = Pick<StoreRuntimeState, 'state'>
/** Select one readable row without discarding competing alias candidates. */
function resolveAliasIdentityKey(state: PersistedState, alias: string): string | undefined {
const identityKeys = state.worktreeIdentityAliases?.[alias] ?? []
if (identityKeys.length === 0) {
return undefined
}
const resolvable = identityKeys.filter((key) => state.worktreeMetaByIdentity?.[key])
const candidates = resolvable.length > 0 ? resolvable : identityKeys
// Newest activity wins, then the greater key, so every host agrees on the survivor.
const winner = candidates.reduce((best, key) => {
const bestTouch = state.worktreeMetaByIdentity?.[best]?.lastActivityAt ?? 0
const keyTouch = state.worktreeMetaByIdentity?.[key]?.lastActivityAt ?? 0
if (keyTouch !== bestTouch) {
return keyTouch > bestTouch ? key : best
}
return key > best ? key : best
})
return winner
}
/** Drop identity metadata no alias points at any more. */
export function pruneUnreferencedWorktreeIdentityMeta(state: PersistedState): boolean {
const referenced = new Set(Object.values(state.worktreeIdentityAliases ?? {}).flat())
let changed = false
for (const identityKey of Object.keys(state.worktreeMetaByIdentity ?? {})) {
if (!referenced.has(identityKey)) {
delete state.worktreeMetaByIdentity?.[identityKey]
changed = true
}
}
return changed
}
function migrateLegacyWorktreeMetadata(
state: PersistedState,
worktreeId: string,
executionHostId: ExecutionHostId
): boolean {
const meta = state.worktreeMeta[worktreeId]
if (!meta || (meta.hostId !== undefined && meta.hostId !== executionHostId)) {
return false
}
state.worktreeMetaByIdentity ??= {}
state.worktreeIdentityAliases ??= {}
let changed = false
const instanceId = meta.instanceId ?? randomUUID()
if (!meta.instanceId) {
meta.instanceId = instanceId
changed = true
}
if (!meta.hostId) {
meta.hostId = executionHostId
changed = true
}
const identityKey = canonicalWorktreeIdentity({
worktreeId,
executionHostId,
instanceId
})
if (!state.worktreeMetaByIdentity[identityKey]) {
state.worktreeMetaByIdentity[identityKey] = {
...meta,
instanceId,
hostId: executionHostId
}
changed = true
}
const alias = composeWorktreeHostIdentity(executionHostId, worktreeId)
const aliases = state.worktreeIdentityAliases[alias] ?? []
if (aliases.length === 0) {
state.worktreeIdentityAliases[alias] = [identityKey]
changed = true
}
return changed
}
/** Drop the identity rows a locator owns, for one host or for every host. */
export function removeWorktreeMetadataForHost(
state: PersistedState,
worktreeId: string,
executionHostId: ExecutionHostId | undefined
): boolean {
let changed = false
for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) {
if (getWorktreeIdFromHostIdentity(alias) !== worktreeId) {
continue
}
if (
executionHostId !== undefined &&
getExecutionHostIdFromWorktreeHostIdentity(alias) !== executionHostId
) {
continue
}
delete state.worktreeIdentityAliases?.[alias]
changed = true
}
return pruneUnreferencedWorktreeIdentityMeta(state) || changed
}
/**
* Re-point one host's alias at a renamed locator. Host-scoped on purpose: a local
* folder move must not drag a remote host's alias to a path that host does not have.
*/
export function migrateWorktreeMetadataLocator(
state: PersistedState,
oldWorktreeId: string,
newWorktreeId: string,
executionHostId: ExecutionHostId
): boolean {
if (oldWorktreeId === newWorktreeId) {
return false
}
const oldAlias = composeWorktreeHostIdentity(executionHostId, oldWorktreeId)
const identityKeys = state.worktreeIdentityAliases?.[oldAlias]
if (!identityKeys || identityKeys.length === 0) {
return false
}
const newAlias = composeWorktreeHostIdentity(executionHostId, newWorktreeId)
state.worktreeIdentityAliases ??= {}
// A taken destination keeps its own occupant; stranding the mover at the old locator loses less
// than merging (which makes both unreadable) or dropping its row outright.
if ((state.worktreeIdentityAliases[newAlias] ?? []).length > 0) {
return false
}
state.worktreeIdentityAliases[newAlias] = [...identityKeys]
delete state.worktreeIdentityAliases[oldAlias]
return true
}
export function getWorktreeMetaForHost(
runtime: MetadataRuntime,
scheduling: WriteSchedulingOperations,
worktreeId: string,
executionHostId: ExecutionHostId
): WorktreeMeta | undefined {
const state = runtime.state
const changed = migrateLegacyWorktreeMetadata(state, worktreeId, executionHostId)
const alias = composeWorktreeHostIdentity(executionHostId, worktreeId)
const identityKey = resolveAliasIdentityKey(state, alias)
if (changed) {
scheduleSave(scheduling)
}
if (identityKey) {
return state.worktreeMetaByIdentity?.[identityKey]
}
const legacy = state.worktreeMeta[worktreeId]
return !legacy?.hostId || legacy.hostId === executionHostId ? legacy : undefined
}
/** Read-only host projection used by listings that must include canonical-only rows. */
export function getAllWorktreeMetaForHost(
runtime: MetadataRuntime,
executionHostId: ExecutionHostId
): Record<string, WorktreeMeta> {
const state = runtime.state
const projected: Record<string, WorktreeMeta> = {}
for (const [worktreeId, meta] of Object.entries(state.worktreeMeta)) {
if (!meta.hostId || meta.hostId === executionHostId) {
projected[worktreeId] = meta
}
}
for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) {
if (getExecutionHostIdFromWorktreeHostIdentity(alias) !== executionHostId) {
continue
}
const worktreeId = getWorktreeIdFromHostIdentity(alias)
const identityKey = resolveAliasIdentityKey(state, alias)
const meta = identityKey ? state.worktreeMetaByIdentity?.[identityKey] : undefined
if (!worktreeId || !meta || (meta.hostId && meta.hostId !== executionHostId)) {
continue
}
projected[worktreeId] =
meta.hostId === executionHostId ? meta : { ...meta, hostId: executionHostId }
}
return projected
}
export function setWorktreeMetaForHost(
runtime: MetadataRuntime,
scheduling: WriteSchedulingOperations,
worktreeId: string,
executionHostId: ExecutionHostId,
meta: Partial<WorktreeMeta>
): WorktreeMeta {
const state = runtime.state
migrateLegacyWorktreeMetadata(state, worktreeId, executionHostId)
const alias = composeWorktreeHostIdentity(executionHostId, worktreeId)
const identityKeys = state.worktreeIdentityAliases?.[alias] ?? []
if (identityKeys.length > 1 && meta.instanceId === undefined) {
throw new Error('Worktree identity is ambiguous for this host and locator.')
}
const existingIdentityKey = resolveAliasIdentityKey(state, alias)
const existingIdentityMeta = existingIdentityKey
? state.worktreeMetaByIdentity?.[existingIdentityKey]
: undefined
const legacy = state.worktreeMeta[worktreeId]
const existing =
existingIdentityMeta ??
(!legacy?.hostId || legacy.hostId === executionHostId ? legacy : undefined)
// An explicit instanceId is a deliberate rotation (see worktree-lineage-pruning), so it wins.
const instanceId = meta.instanceId ?? existing?.instanceId ?? randomUUID()
const identityKey = canonicalWorktreeIdentity({ worktreeId, executionHostId, instanceId })
const updated = mergeWorktreeMetaForWrite(existing, meta, {
instanceId,
hostId: executionHostId
})
state.worktreeMetaByIdentity ??= {}
state.worktreeIdentityAliases ??= {}
if (existingIdentityKey && existingIdentityKey !== identityKey) {
delete state.worktreeMetaByIdentity[existingIdentityKey]
}
state.worktreeMetaByIdentity[identityKey] = updated
state.worktreeIdentityAliases[alias] = [identityKey]
// Keep the legacy projection only for the first known owner.
if (!legacy || legacy.hostId === executionHostId) {
state.worktreeMeta[worktreeId] = updated
}
scheduleSave(scheduling)
return updated
}
@@ -0,0 +1,55 @@
import { randomUUID } from 'node:crypto'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context'
import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item'
import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../shared/workspace-statuses'
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
type WorktreeMetaIdentity = {
instanceId: string
hostId: ExecutionHostId
}
function createDefaultWorktreeMeta(): WorktreeMeta {
return {
instanceId: randomUUID(),
displayName: '',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
linkedBitbucketPR: null,
linkedAzureDevOpsPR: null,
linkedGiteaPR: null,
linkedWorkItem: null,
linkedTaskSourceContext: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: Date.now(),
lastActivityAt: 0,
workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID
}
}
/** Merge and normalize the metadata shape shared by legacy and identity-keyed writes. */
export function mergeWorktreeMetaForWrite(
existing: WorktreeMeta | undefined,
updates: Partial<WorktreeMeta>,
identity?: WorktreeMetaIdentity
): WorktreeMeta {
const updated = { ...(existing ?? createDefaultWorktreeMeta()), ...updates, ...identity }
updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem)
const sourceContext = normalizeStoredTaskSourceContext(updated.linkedTaskSourceContext)
updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch(
updated.linkedWorkItem,
sourceContext
)
? sourceContext
: null
updated.instanceId ||= randomUUID()
return updated
}
@@ -4,9 +4,11 @@ import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal'
import {
getExecutionHostIdFromWorktreeHostIdentity,
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
import { pruneUnreferencedWorktreeIdentityMeta } from '../loading-store/worktree-identity-metadata'
export function pruneWorktreeStateForRepo(
state: PersistedState,
@@ -67,6 +69,14 @@ export function pruneWorktreeStateForRepo(
delete state.worktreeMeta[key]
}
}
for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) {
const rawId = getWorktreeIdFromHostIdentity(alias)
const aliasHost = getExecutionHostIdFromWorktreeHostIdentity(alias)
if (rawId.startsWith(prefix) && (hostId === null || aliasHost === hostId)) {
delete state.worktreeIdentityAliases?.[alias]
}
}
pruneUnreferencedWorktreeIdentityMeta(state)
// Why: a host-scoped prune must touch only that host's session (legacy blob = local, one partition
// per remote host); pruning every partition would wipe a surviving host's tabs and sleeping agents
// for a shared repo id/path. A full removal (hostId === null) still clears every host.
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { normalizeWorktreeLinkedItemMetadata } from './worktree-metadata-normalization'
import {
gcStaleWorktreeMeta,
normalizeWorktreeLinkedItemMetadata,
WORKTREE_META_GC_GRACE_MS
} from './worktree-metadata-normalization'
// Only the presence of an entry matters here; the normalizer never reads its linked-item fields.
function makeMeta(): WorktreeMeta {
@@ -38,6 +42,99 @@ describe('normalizeWorktreeLinkedItemMetadata', () => {
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true)
expect(state.workspaceLineageByChildKey).toEqual({})
})
it('repairs malformed canonical metadata and alias maps', () => {
const state = makeState({
worktreeMetaByIdentity: null as unknown as PersistedState['worktreeMetaByIdentity'],
worktreeIdentityAliases: {
'local|r1::/tmp/wt': ['wt2:local:one', 'wt2:local:one', '', 42]
} as unknown as PersistedState['worktreeIdentityAliases']
})
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true)
expect(state.worktreeMetaByIdentity).toEqual({})
// A key with no backing row is what turns the next write into an ambiguous alias, so it goes.
expect(state.worktreeIdentityAliases).toEqual({})
})
it('keeps alias keys that still resolve to a metadata row', () => {
const state = makeState({
worktreeMetaByIdentity: { 'wt2:local:one': makeMeta() },
worktreeIdentityAliases: { 'local|r1::/tmp/wt': ['wt2:local:one'] }
})
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(false)
expect(state.worktreeIdentityAliases).toEqual({ 'local|r1::/tmp/wt': ['wt2:local:one'] })
})
it('drops malformed canonical metadata entries', () => {
const state = makeState({
worktreeMetaByIdentity: {
valid: makeMeta(),
malformed: null
} as unknown as PersistedState['worktreeMetaByIdentity'],
worktreeIdentityAliases: { 'local|r1::/tmp/wt': ['valid', 'malformed'] }
})
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true)
expect(state.worktreeMetaByIdentity).toEqual({ valid: makeMeta() })
expect(state.worktreeIdentityAliases).toEqual({ 'local|r1::/tmp/wt': ['valid'] })
})
it('preserves every host canonical row when one legacy locator row is malformed', () => {
const localKey = 'wt2:local:local-instance'
const remoteKey = 'wt2:ssh%3Abuilder:remote-instance'
const worktreeId = 'r1::/tmp/wt'
const state = makeState({
worktreeMeta: { [worktreeId]: null } as unknown as PersistedState['worktreeMeta'],
worktreeMetaByIdentity: { [localKey]: makeMeta(), [remoteKey]: makeMeta() },
worktreeIdentityAliases: {
[`local|${worktreeId}`]: [localKey],
[`ssh:builder|${worktreeId}`]: [remoteKey]
}
})
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true)
expect(state.worktreeMeta).toEqual({})
expect(state.worktreeMetaByIdentity).toEqual({
[localKey]: makeMeta(),
[remoteKey]: makeMeta()
})
expect(state.worktreeIdentityAliases).toEqual({
[`local|${worktreeId}`]: [localKey],
[`ssh:builder|${worktreeId}`]: [remoteKey]
})
})
it('preserves valid canonical state when the whole legacy projection is malformed', () => {
const identityKey = 'wt2:ssh%3Abuilder:remote-instance'
const alias = 'ssh:builder|r1::/tmp/wt'
const state = makeState({
worktreeMeta: null as unknown as PersistedState['worktreeMeta'],
worktreeMetaByIdentity: { [identityKey]: makeMeta() },
worktreeIdentityAliases: { [alias]: [identityKey] }
})
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true)
expect(state.worktreeMeta).toEqual({})
expect(state.worktreeMetaByIdentity).toEqual({ [identityKey]: makeMeta() })
expect(state.worktreeIdentityAliases).toEqual({ [alias]: [identityKey] })
})
it('prunes canonical metadata left unreachable by dangling-alias cleanup', () => {
const liveKey = 'wt2:local:live'
const orphanKey = 'wt2:local:orphan'
const state = makeState({
worktreeMetaByIdentity: { [liveKey]: makeMeta(), [orphanKey]: makeMeta() },
worktreeIdentityAliases: {
'local|r1::/tmp/live': [liveKey],
'local|r1::/tmp/dangling': ['missing-key']
}
})
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true)
expect(state.worktreeMetaByIdentity).toEqual({ [liveKey]: makeMeta() })
expect(state.worktreeIdentityAliases).toEqual({ 'local|r1::/tmp/live': [liveKey] })
})
it('leaves already-normalized state clean', () => {
const state = makeState({
@@ -47,3 +144,36 @@ describe('normalizeWorktreeLinkedItemMetadata', () => {
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(false)
})
})
describe('gcStaleWorktreeMeta', () => {
it('reclaims the identity rows of a collected worktree', () => {
const worktreeId = 'r1::/definitely/missing/orca/path'
const identityKey = 'wt2:local:dead'
const remoteIdentityKey = 'wt2:ssh:live'
const state = makeState({
repos: [],
projects: [],
worktreeMeta: {
[worktreeId]: {
hostId: 'local',
instanceId: 'dead',
displayName: 'dead',
lastActivityAt: Date.now() - WORKTREE_META_GC_GRACE_MS - 1
} as unknown as WorktreeMeta
},
worktreeMetaByIdentity: { [identityKey]: makeMeta(), [remoteIdentityKey]: makeMeta() },
worktreeIdentityAliases: {
[`local|${worktreeId}`]: [identityKey],
[`ssh:conn-1|${worktreeId}`]: [remoteIdentityKey]
}
})
expect(gcStaleWorktreeMeta(state)).toBe(1)
// A surviving alias would re-attach this dead metadata to a worktree later
// created at the same repoId::path, and nothing else ever reclaims it.
expect(state.worktreeMetaByIdentity).toEqual({ [remoteIdentityKey]: expect.anything() })
expect(state.worktreeIdentityAliases).toEqual({
[`ssh:conn-1|${worktreeId}`]: [remoteIdentityKey]
})
})
})
@@ -15,6 +15,11 @@ import {
} from '../../../shared/workspace-linked-item'
import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context'
import type { PersistedState } from '../../../shared/persisted-state-types'
import {
pruneUnreferencedWorktreeIdentityMeta,
removeWorktreeMetadataForHost
} from '../loading-store/worktree-identity-metadata'
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
// Why: worktrees deleted outside Orca orphan their worktreeMeta, so the map grew monotonically (63% dead on a heavy install).
// GC stays narrow: local-host entries only (a local existsSync would falsely condemn SSH/WSL remote paths) and only after a 30-day idle grace.
@@ -75,11 +80,35 @@ export function gcStaleWorktreeMeta(state: PersistedState): number {
delete state.worktreeMeta[key]
delete state.worktreeLineageById[key]
delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)]
// Identity rows are companions too: a surviving alias would re-attach this dead metadata to a
// worktree later created at the same repoId::path, and nothing else ever reclaims them.
removeWorktreeMetadataForHost(state, key, LOCAL_EXECUTION_HOST_ID)
removed++
}
return removed
}
function normalizeLinkedMetadata(meta: WorktreeMeta): boolean {
const linkedWorkItem = normalizeWorkspaceLinkedItem(meta.linkedWorkItem)
const sourceContext = normalizeStoredTaskSourceContext(meta.linkedTaskSourceContext)
const linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch(
linkedWorkItem,
sourceContext
)
? sourceContext
: null
let changed = false
if (!areWorkspaceLinkedItemsEqual(meta.linkedWorkItem, linkedWorkItem)) {
meta.linkedWorkItem = linkedWorkItem
changed = true
}
if (!areTaskSourceContextsEqual(meta.linkedTaskSourceContext, linkedTaskSourceContext)) {
meta.linkedTaskSourceContext = linkedTaskSourceContext
changed = true
}
return changed
}
export function normalizeWorktreeLinkedItemMetadata(state: PersistedState): boolean {
// Why: a hand-corrupted null lineage map would throw on the companion deletes below. The repair
// itself is dirty state — without it the map stays null on disk and is repaired again every load.
@@ -98,8 +127,7 @@ export function normalizeWorktreeLinkedItemMetadata(state: PersistedState): bool
Object.keys(state.worktreeLineageById).length > 0 ||
Object.keys(state.workspaceLineageByChildKey).length > 0
state.worktreeMeta = {}
// Companions go with the discarded map; a stranded lineage row would otherwise re-attach to a
// worktree recreated at the same repoId::path.
// Legacy lineage belongs to the discarded locator projection; canonical host state does not.
state.worktreeLineageById = {}
state.workspaceLineageByChildKey = {}
changed ||= rawWorktreeMeta !== undefined || hadLineage
@@ -109,29 +137,74 @@ export function normalizeWorktreeLinkedItemMetadata(state: PersistedState): bool
// keeps timestamp-less keys forever and every downstream consumer trusts the Record<string, WorktreeMeta> type.
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) {
delete state.worktreeMeta[key]
// Companions go with it, matching gcStaleWorktreeMeta/removeWorktreeMeta; a stranded lineage row would
// otherwise re-attach to a worktree recreated at the same repoId::path.
// Legacy lineage belongs to this corrupt locator row; canonical host state is independent.
delete state.worktreeLineageById[key]
delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)]
changed = true
continue
}
const linkedWorkItem = normalizeWorkspaceLinkedItem(meta.linkedWorkItem)
const sourceContext = normalizeStoredTaskSourceContext(meta.linkedTaskSourceContext)
const linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch(
linkedWorkItem,
sourceContext
)
? sourceContext
: null
if (!areWorkspaceLinkedItemsEqual(meta.linkedWorkItem, linkedWorkItem)) {
meta.linkedWorkItem = linkedWorkItem
changed = normalizeLinkedMetadata(meta) || changed
}
const rawIdentityMetadata = state.worktreeMetaByIdentity as unknown
if (
rawIdentityMetadata !== undefined &&
(typeof rawIdentityMetadata !== 'object' ||
rawIdentityMetadata === null ||
Array.isArray(rawIdentityMetadata))
) {
state.worktreeMetaByIdentity = {}
changed = true
}
for (const [identityKey, meta] of Object.entries(state.worktreeMetaByIdentity ?? {})) {
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) {
delete state.worktreeMetaByIdentity?.[identityKey]
changed = true
continue
}
if (!areTaskSourceContextsEqual(meta.linkedTaskSourceContext, linkedTaskSourceContext)) {
meta.linkedTaskSourceContext = linkedTaskSourceContext
changed = normalizeLinkedMetadata(meta) || changed
}
const rawAliases = state.worktreeIdentityAliases as unknown
const aliasesAreObject =
typeof rawAliases === 'object' && rawAliases !== null && !Array.isArray(rawAliases)
const aliasesAreMalformed = rawAliases !== undefined && !aliasesAreObject
const aliasesCanProveReachability = aliasesAreObject && Object.keys(rawAliases).length > 0
if (aliasesAreMalformed) {
state.worktreeIdentityAliases = {}
changed = true
}
for (const [alias, rawIdentityKeys] of Object.entries(state.worktreeIdentityAliases ?? {})) {
if (!Array.isArray(rawIdentityKeys)) {
delete state.worktreeIdentityAliases?.[alias]
changed = true
continue
}
// Drop keys with no backing row: a dangling key is what turns the next write into an
// ambiguous alias, and nothing downstream can resolve it anyway.
const identityKeys = [
...new Set(
rawIdentityKeys.filter(
(key): key is string =>
typeof key === 'string' &&
key.length > 0 &&
Boolean(state.worktreeMetaByIdentity?.[key])
)
)
]
if (identityKeys.length === 0) {
delete state.worktreeIdentityAliases?.[alias]
changed = true
} else if (
identityKeys.length !== rawIdentityKeys.length ||
identityKeys.some((key, index) => key !== rawIdentityKeys[index])
) {
state.worktreeIdentityAliases ??= {}
state.worktreeIdentityAliases[alias] = identityKeys
changed = true
}
}
if (aliasesCanProveReachability) {
changed = pruneUnreferencedWorktreeIdentityMeta(state) || changed
}
return changed
}
+1 -1
View File
@@ -15,7 +15,7 @@ import {
} from '../shared/ephemeral-vm-recipes'
import { listEphemeralVmRuntimes } from '../shared/ephemeral-vm-runtime-store'
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
import { getProjectHostSetupWorktreeMeta } from '../shared/project-host-setup-projection'
import { getProjectHostSetupWorktreeMeta } from '../shared/project-host-setup-lookup'
import { isTuiAgent } from '../shared/tui-agent-config'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import {
+23 -7
View File
@@ -539,11 +539,11 @@ import {
splitWorktreeIdForFilesystem,
worktreeIdComparisonKey
} from '../../shared/worktree/id'
import { getProjectIdForProviderIdentity } from '../../shared/project-host-setup-projection'
import {
getProjectIdForProviderIdentity,
getProjectHostSetupForRepo,
getProjectHostSetupWorktreeMeta
} from '../../shared/project-host-setup-projection'
} from '../../shared/project-host-setup-lookup'
import { parsePtySessionId } from '../../shared/pty-session-id-format'
import { clampLinearIssueListLimit } from '../../shared/linear/issue-read-limits'
import { isFolderRepo } from '../../shared/repo-kind'
@@ -919,6 +919,7 @@ import {
type RepoWorktreeRowDeps
} from './repo-worktree-row-resolution'
import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership'
import { readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta'
import { withTimeout } from '../../shared/promise-timeout-fallback'
import {
getLocalWorktreePathAccess,
@@ -1355,6 +1356,7 @@ type RuntimeStore = {
getAllWorktreeMeta: Store['getAllWorktreeMeta']
getWorktreeMeta: Store['getWorktreeMeta']
setWorktreeMeta: Store['setWorktreeMeta']
setWorktreeMetaForHost?: Store['setWorktreeMetaForHost']
removeWorktreeMeta: Store['removeWorktreeMeta']
getWorktreeLineage?: Store['getWorktreeLineage']
getAllWorktreeLineage?: Store['getAllWorktreeLineage']
@@ -20853,7 +20855,7 @@ export class OrcaRuntimeService {
workspaceKind: 'git',
worktreeId: worktree.id,
repoId: worktree.repoId,
...((worktree.hostId ?? meta?.hostId) ? { hostId: worktree.hostId ?? meta?.hostId } : {}),
...((meta?.hostId ?? worktree.hostId) ? { hostId: meta?.hostId ?? worktree.hostId } : {}),
terminalPlatform,
repo: repo?.displayName ?? worktree.repoId,
path: worktree.path,
@@ -24279,7 +24281,10 @@ export class OrcaRuntimeService {
const metaById = store.getAllWorktreeMeta()
const detected = scan.worktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
const meta = getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
// A host-qualified row is exact; the locator-keyed one is only trustworthy when this repo owns it.
const meta =
readWorktreeMetaForHost(store, worktreeId, expectedHostId) ??
getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
const worktree = {
...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName),
hostId: repoOwnerCount === 1 ? (meta?.hostId ?? expectedHostId) : expectedHostId
@@ -27147,12 +27152,20 @@ export class OrcaRuntimeService {
createdAt
})
}
this.store.setWorktreeMeta(worktree.id, stripOrcaProvenanceMetaUpdates(persistedMetaUpdates))
const metadataUpdates = stripOrcaProvenanceMetaUpdates(persistedMetaUpdates)
const executionHostId = worktree.identity?.executionHostId ?? worktree.hostId
if (executionHostId && this.store.setWorktreeMetaForHost) {
this.store.setWorktreeMetaForHost(worktree.id, executionHostId, metadataUpdates)
} else {
this.store.setWorktreeMeta(worktree.id, metadataUpdates)
}
// Why: unlike renderer-initiated optimistic updates, CLI callers need an
// explicit push so the editor refreshes metadata changed outside the UI.
this.invalidateResolvedWorktreeCache()
this.notifyWorktreesChanged(worktree.repoId)
return await this.showManagedWorktree(`id:${worktree.id}`)
return await this.showManagedWorktree(
worktree.identity?.key ? `identity:${worktree.identity.key}` : `id:${worktree.id}`
)
}
persistManagedWorktreeSortOrder(orderedIds: string[]): { updated: number } {
@@ -32340,7 +32353,10 @@ export class OrcaRuntimeService {
throw new Error('selector_not_found')
}
if (selector.startsWith('id:')) {
if (selector.startsWith('identity:')) {
const identityKey = selector.slice('identity:'.length)
candidates = worktrees.filter((worktree) => worktree.identity?.key === identityKey)
} else if (selector.startsWith('id:')) {
const worktreeId = explicitWorktreeId ?? selector.slice(3)
candidates = worktrees.filter((worktree) => worktree.id === worktreeId)
if (candidates.length === 0) {
@@ -5,6 +5,7 @@ import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
import type { Store } from '../persistence'
import {
listStoredWorktreeRowsForRepo,
resolveRepoWorktreeRows,
resolveScopedWorktreeIdRow,
type RepoWorktreeRowDeps
@@ -111,6 +112,70 @@ describe('host-qualified scoped worktree resolution', () => {
displayName: 'feature'
})
})
it('uses the host-qualified metadata owner when a legacy row has another host', async () => {
const deps = createDeps([
repo('shared', '/remote/repo', {
connectionId: 'builder',
executionHostId: 'ssh:builder'
})
])
const worktreeId = 'shared::/same/worktree'
const remoteMeta = {
displayName: 'remote workspace',
hostId: 'ssh:builder',
instanceId: 'remote-instance'
} as unknown as WorktreeMeta
deps.metaById[worktreeId] = {
displayName: 'stale local workspace',
hostId: 'local',
instanceId: 'local-instance'
} as unknown as WorktreeMeta
;(
deps.store as Store & {
getWorktreeMetaForHost: () => WorktreeMeta
}
).getWorktreeMetaForHost = () => remoteMeta
const rows = await resolveRepoWorktreeRows(
deps,
deps.store.getRepos()[0]!,
deps.metaById,
new Map()
)
expect(rows[0]).toMatchObject({
displayName: 'remote workspace',
hostId: 'ssh:builder',
instanceId: 'remote-instance'
})
})
it('restores canonical-only SSH rows without leaking colliding local metadata', () => {
const local = repo('shared', '/local/repo', { executionHostId: 'local' })
const remote = repo('shared', '/remote/repo', {
connectionId: 'builder',
executionHostId: 'ssh:builder'
})
const deps = createDeps([local, remote])
deps.metaById['shared::/local/worktree'] = {
displayName: 'local workspace',
hostId: 'local'
} as WorktreeMeta
;(
deps.store as Store & {
getAllWorktreeMetaForHost: () => Record<string, WorktreeMeta>
}
).getAllWorktreeMetaForHost = () => ({
'shared::/remote/worktree': {
displayName: 'remote workspace',
hostId: 'ssh:builder'
} as unknown as WorktreeMeta
})
expect(listStoredWorktreeRowsForRepo(deps.store, remote, 2)).toEqual([
expect.objectContaining({ path: '/remote/worktree' })
])
})
it.each([
['runtime:windows', String.raw`C:\Users\dev\orca worktree`],
@@ -4,6 +4,11 @@ import {
worktreeIdComparisonKey
} from '../../shared/worktree/id'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import {
readAllWorktreeMetaForHost,
readWorktreeMetaForHost,
writeWorktreeMetaForHost
} from '../persistence/host-qualified-worktree-meta'
import { isFolderRepo } from '../../shared/repo-kind'
import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage'
import { withTimeout } from '../../shared/promise-timeout-fallback'
@@ -62,7 +67,9 @@ export function listStoredWorktreeRowsForRepo(
): GitWorktreeInfo[] {
const expectedHostId = getRepoExecutionHostId(repo)
const byWorktreeId = new Map<string, GitWorktreeInfo>()
for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) {
for (const [worktreeId, meta] of Object.entries(
readAllWorktreeMetaForHost(store, expectedHostId)
)) {
const parsed = splitWorktreeId(worktreeId)
if (!parsed || parsed.repoId !== repo.id) {
continue
@@ -134,15 +141,18 @@ export async function resolveRepoWorktreeRows(
const worktreeId = `${repo.id}::${gitWorktree.path}`
// Why: lineage validation needs a durable instance ID even when the runtime sees a workspace before renderer discovery-stamp.
const existingMeta = metaById[worktreeId]
const ownedExistingMeta = getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
// A host-qualified row is exact; the locator-keyed one is only trustworthy when this repo owns it.
const ownedExistingMeta =
readWorktreeMetaForHost(store, worktreeId, expectedHostId) ??
getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
const meta = ownedExistingMeta?.instanceId
? ownedExistingMeta
: ownedExistingMeta || (!existingMeta && repoOwnerCount === 1)
? store.setWorktreeMeta(worktreeId, {})
? writeWorktreeMetaForHost(store, worktreeId, expectedHostId, {})
: undefined
const merged = {
...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName),
hostId: repoOwnerCount === 1 ? (existingMeta?.hostId ?? expectedHostId) : expectedHostId
hostId: meta?.hostId ?? expectedHostId
}
return {
...merged,
@@ -43,6 +43,7 @@ import {
WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS
} from './orca-runtime'
import { RESOLVED_WORKTREE_REPO_TIMEOUT_MS } from './repo-worktree-row-resolution'
import { canonicalWorktreeIdentity } from '../../shared/worktree/identity'
const REPO_ID = 'repo-local'
const REPO_PATH = '/Users/me/dev/app'
@@ -75,8 +76,15 @@ function makeMeta(overrides: Record<string, unknown> = {}) {
function makeStore(options: { connectionId?: string; repoCount?: number; repoPath?: string } = {}) {
const metaById: Record<string, ReturnType<typeof makeMeta>> = {
[WORKTREE_ID]: makeMeta(),
[MAIN_WORKTREE_ID]: makeMeta({ displayName: 'main' })
[WORKTREE_ID]: makeMeta({
hostId: 'local',
instanceId: '11111111-1111-4111-8111-111111111111'
}),
[MAIN_WORKTREE_ID]: makeMeta({
displayName: 'main',
hostId: 'local',
instanceId: '22222222-2222-4222-8222-222222222222'
})
}
const basePath = options.repoPath ?? REPO_PATH
const repos = Array.from({ length: options.repoCount ?? 1 }, (_unused, index) => ({
@@ -586,6 +594,22 @@ describe('scoped explicit worktree-id resolution', () => {
expect(resolved.id).toBe(MAIN_WORKTREE_ID)
expect(scannedRepoPaths()).toEqual([REPO_PATH])
})
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)
const identityKey = canonicalWorktreeIdentity({
worktreeId: MAIN_WORKTREE_ID,
executionHostId: 'local',
instanceId: '22222222-2222-4222-8222-222222222222'
})
const resolved = await resolve(`identity:${identityKey}`)
expect(resolved.id).toBe(MAIN_WORKTREE_ID)
})
it('still finds worktrees in other repos through the fleet path', async () => {
const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never)
+11 -1
View File
@@ -10,6 +10,8 @@ import type {
} from '../shared/workspace-space-types'
import { mapWithConcurrency } from '../shared/map-with-concurrency'
import { getRepoExecutionHostId } from '../shared/execution-host'
import { readWorktreeMetaForHost } from './persistence/host-qualified-worktree-meta'
import { getRepoOwnedWorktreeMeta } from './worktree-metadata-ownership'
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees'
@@ -104,7 +106,15 @@ function reportProgress(
function mergeForSpaceScan(repo: Repo, gitWorktree: GitWorktreeInfo, store: Store): Worktree {
const worktreeId = `${repo.id}::${gitWorktree.path}`
return mergeWorktree(repo.id, gitWorktree, store.getWorktreeMeta(worktreeId), repo.displayName)
const executionHostId = getRepoExecutionHostId(repo)
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
const allMeta = store.getAllWorktreeMeta?.()
const legacyMeta = store.getWorktreeMeta?.(worktreeId)
const metaById = allMeta ?? (legacyMeta ? { [worktreeId]: legacyMeta } : {})
const meta =
readWorktreeMetaForHost(store, worktreeId, executionHostId) ??
getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
}
export async function scanWorkspaceSpaceRepo(args: {
@@ -0,0 +1,351 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { PersistedState } from '../shared/persisted-state-types'
import { canonicalWorktreeIdentity } from '../shared/worktree/identity'
import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity'
import { createStore, readDataFile, testState, writeDataFile } from './persistence-test-harness'
describe('host-qualified worktree metadata', () => {
const worktreeId = 'repo-1::/workspace/feature'
const ROTATED_INSTANCE_ID = '44444444-4444-4444-8444-444444444444'
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-worktree-identity-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('stores independent metadata for colliding locators on two hosts', () => {
const store = createStore()
store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' })
store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' })
expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.displayName).toBe('Local feature')
expect(store.getWorktreeMetaForHost(worktreeId, 'ssh:build-box')?.displayName).toBe(
'Remote feature'
)
store.flush()
const persisted = readDataFile() as PersistedState
expect(Object.keys(persisted.worktreeMetaByIdentity ?? {})).toHaveLength(2)
expect(store.getWorktreeMeta(worktreeId)?.displayName).toBe('Local feature')
})
it('projects canonical-only metadata for exactly one host', () => {
const store = createStore()
store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' })
store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', {
displayName: 'Remote feature'
})
expect(store.getAllWorktreeMetaForHost('local')).toEqual({
[worktreeId]: expect.objectContaining({ displayName: 'Local feature', hostId: 'local' })
})
expect(store.getAllWorktreeMetaForHost('ssh:build-box')).toEqual({
[worktreeId]: expect.objectContaining({
displayName: 'Remote feature',
hostId: 'ssh:build-box'
})
})
})
it('reloads host-specific metadata without collapsing it to the legacy locator', () => {
const store = createStore()
store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' })
store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' })
store.flush()
const reloaded = createStore()
expect(reloaded.getWorktreeMetaForHost(worktreeId, 'local')?.displayName).toBe('Local feature')
expect(reloaded.getWorktreeMetaForHost(worktreeId, 'ssh:build-box')?.displayName).toBe(
'Remote feature'
)
})
it('keeps canonical metadata current for legacy writers on a known owner', () => {
const store = createStore()
store.setWorktreeMetaForHost(worktreeId, 'local', { comment: 'before' })
store.setWorktreeMeta(worktreeId, { comment: 'after' })
expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.comment).toBe('after')
})
it('backfills one stable instance for legacy metadata that omitted it', () => {
const seed = createStore()
seed.setWorktreeMeta(worktreeId, { displayName: 'Legacy feature' })
seed.flush()
const legacy = readDataFile() as PersistedState
delete legacy.worktreeMeta[worktreeId]?.instanceId
delete legacy.worktreeMetaByIdentity
delete legacy.worktreeIdentityAliases
writeDataFile(legacy)
const store = createStore()
const first = store.getWorktreeMetaForHost(worktreeId, 'local')
const second = store.getWorktreeMetaForHost(worktreeId, 'local')
store.setWorktreeMeta(worktreeId, { comment: 'after migration' })
store.flush()
expect(first?.instanceId).toBeTruthy()
expect(second?.instanceId).toBe(first?.instanceId)
expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.comment).toBe('after migration')
const migrated = readDataFile() as PersistedState
expect(migrated.worktreeMeta[worktreeId]?.hostId).toBe('local')
expect(Object.keys(migrated.worktreeMetaByIdentity ?? {})).toHaveLength(1)
})
// Fails open on purpose: an ambiguous alias used to brick reads and throw out of the worktree
// listing loop, taking every workspace in the repo down with it and never self-healing.
it('collapses an ambiguous locator onto its most recently active instance', () => {
const seed = createStore()
const first = seed.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'First' })
seed.flush()
const persisted = readDataFile() as PersistedState
const alias = composeWorktreeHostIdentity('local', worktreeId)
const secondKey = canonicalWorktreeIdentity({
worktreeId,
executionHostId: 'local',
instanceId: '33333333-3333-4333-8333-333333333333'
})
persisted.worktreeMetaByIdentity ??= {}
persisted.worktreeIdentityAliases ??= {}
persisted.worktreeMetaByIdentity[secondKey] = {
...first,
instanceId: '33333333-3333-4333-8333-333333333333',
displayName: 'Second',
lastActivityAt: 1
}
persisted.worktreeIdentityAliases[alias] = [
...(persisted.worktreeIdentityAliases[alias] ?? []),
secondKey
]
writeDataFile(persisted)
const store = createStore()
expect(() =>
store.setWorktreeMetaForHost(worktreeId, 'local', { comment: 'ambiguous write' })
).toThrow('Worktree identity is ambiguous for this host and locator.')
const repaired = createStore()
expect(repaired.getWorktreeMetaForHost(worktreeId, 'local')?.displayName).toBe('Second')
repaired.flush()
const afterRead = readDataFile() as PersistedState
expect(afterRead.worktreeIdentityAliases?.[alias]).toEqual([
persisted.worktreeIdentityAliases?.[alias]?.[0],
secondKey
])
expect(Object.keys(afterRead.worktreeMetaByIdentity ?? {})).toHaveLength(2)
})
it('removes only the selected legacy-owner metadata when locators collide', () => {
const store = createStore()
store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' })
store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' })
store.removeWorktreeMeta(worktreeId, 'local')
expect(store.getWorktreeMetaForHost(worktreeId, 'local')).toBeUndefined()
expect(store.getWorktreeMetaForHost(worktreeId, 'ssh:build-box')?.displayName).toBe(
'Remote feature'
)
})
it('removes only the selected host metadata when locators collide', () => {
const store = createStore()
store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' })
store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' })
store.removeWorktreeMeta(worktreeId, 'ssh:build-box')
expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.displayName).toBe('Local feature')
expect(store.getWorktreeMetaForHost(worktreeId, 'ssh:build-box')).toBeUndefined()
store.flush()
expect(
Object.keys((readDataFile() as PersistedState).worktreeMetaByIdentity ?? {})
).toHaveLength(1)
})
it('moves the locator alias without changing canonical identity', () => {
const store = createStore()
const meta = store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Feature' })
const identityKey = canonicalWorktreeIdentity({
worktreeId,
executionHostId: 'local',
instanceId: meta.instanceId!
})
const renamedId = 'repo-1::/workspace/renamed-feature'
store.migrateWorktreeIdentity(worktreeId, renamedId)
expect(store.getWorktreeMetaForHost(worktreeId, 'local')).toBeUndefined()
expect(store.getWorktreeMetaForHost(renamedId, 'local')?.instanceId).toBe(meta.instanceId)
store.flush()
const persisted = readDataFile() as PersistedState
expect(persisted.worktreeMetaByIdentity?.[identityKey]?.displayName).toBe('Feature')
expect(
persisted.worktreeIdentityAliases?.[composeWorktreeHostIdentity('local', renamedId)]
).toEqual([identityKey])
})
it('keeps the canonical identity stable when the locator changes', () => {
const store = createStore()
const meta = store.setWorktreeMetaForHost(worktreeId, 'local', {
displayName: 'Feature'
})
const before = canonicalWorktreeIdentity({
worktreeId,
executionHostId: 'local',
instanceId: meta.instanceId!
})
const after = canonicalWorktreeIdentity({
worktreeId: 'repo-1::/workspace/renamed-feature',
executionHostId: 'local',
instanceId: meta.instanceId!
})
expect(after).toBe(before)
expect(composeWorktreeHostIdentity('local', worktreeId)).toBe(
'local|repo-1::/workspace/feature'
)
})
it('honours a deliberate instanceId rotation instead of pinning the stored one', () => {
const store = createStore()
store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Feature' })
// worktree-lineage-pruning rotates a proven-missing parent so path reuse cannot
// validate old lineage; pinning the existing id silently disarmed that guard.
const rotated = store.setWorktreeMeta(worktreeId, { instanceId: ROTATED_INSTANCE_ID })
expect(rotated.instanceId).toBe(ROTATED_INSTANCE_ID)
expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.instanceId).toBe(ROTATED_INSTANCE_ID)
store.flush()
const persisted = readDataFile() as PersistedState
// The old identity row goes with the rotation rather than lingering unreachable.
expect(Object.keys(persisted.worktreeMetaByIdentity ?? {})).toEqual([
canonicalWorktreeIdentity({
worktreeId,
executionHostId: 'local',
instanceId: ROTATED_INSTANCE_ID
})
])
})
it('leaves other hosts at the old locator when one host renames its folder', () => {
const store = createStore()
store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' })
store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' })
const renamedId = 'repo-1::/workspace/renamed-feature'
store.migrateWorktreeIdentity(worktreeId, renamedId, 'local')
expect(store.getWorktreeMetaForHost(renamedId, 'local')?.displayName).toBe('Local feature')
// The SSH host never moved, so its row must stay reachable at the path it still has.
expect(store.getWorktreeMetaForHost(worktreeId, 'ssh:build-box')?.displayName).toBe(
'Remote feature'
)
})
it('drops every host identity row when a locator is removed outright', () => {
const store = createStore()
store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' })
store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' })
store.removeWorktreeMeta(worktreeId)
store.flush()
const persisted = readDataFile() as PersistedState
expect(persisted.worktreeMetaByIdentity ?? {}).toEqual({})
expect(persisted.worktreeIdentityAliases ?? {}).toEqual({})
})
it('repairs a missing canonical instance id while re-adopting an SSH target', () => {
const oldHostId = 'ssh:old-target' as const
const newHostId = 'ssh:new-target' as const
const seed = createStore()
seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' })
seed.flush()
const persisted = readDataFile() as PersistedState
const oldAlias = composeWorktreeHostIdentity(oldHostId, worktreeId)
const oldKey = persisted.worktreeIdentityAliases?.[oldAlias]?.[0]
expect(oldKey).toBeTruthy()
delete persisted.worktreeMetaByIdentity?.[oldKey!]?.instanceId
writeDataFile(persisted)
const store = createStore()
store.reassignSshTargetId('old-target', 'new-target')
store.flush()
const repaired = readDataFile() as PersistedState
const newAlias = composeWorktreeHostIdentity(newHostId, worktreeId)
const newKey = repaired.worktreeIdentityAliases?.[newAlias]?.[0]
expect(repaired.worktreeIdentityAliases?.[oldAlias]).toBeUndefined()
expect(newKey).toBeTruthy()
expect(repaired.worktreeMetaByIdentity?.[newKey!]).toMatchObject({
displayName: 'Remote feature',
hostId: newHostId,
instanceId: expect.any(String)
})
expect(repaired.worktreeMetaByIdentity?.[oldKey!]).toBeUndefined()
})
it('preserves source metadata when the re-adoption destination is divergent', () => {
const oldHostId = 'ssh:old-target' as const
const newHostId = 'ssh:new-target' as const
const store = createStore()
store.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Source feature' })
store.setWorktreeMetaForHost(worktreeId, newHostId, {
displayName: 'Destination feature'
})
store.reassignSshTargetId('old-target', 'new-target')
expect(store.getWorktreeMetaForHost(worktreeId, oldHostId)?.displayName).toBe('Source feature')
expect(store.getWorktreeMetaForHost(worktreeId, newHostId)?.displayName).toBe(
'Destination feature'
)
expect(store.getWorktreeMeta(worktreeId)?.hostId).toBe(oldHostId)
store.flush()
const persisted = readDataFile() as PersistedState
expect(
persisted.worktreeIdentityAliases?.[composeWorktreeHostIdentity(oldHostId, worktreeId)]
).toHaveLength(1)
expect(
persisted.worktreeIdentityAliases?.[composeWorktreeHostIdentity(newHostId, worktreeId)]
).toHaveLength(1)
})
it('deduplicates an equivalent destination during SSH target re-adoption', () => {
const oldHostId = 'ssh:old-target' as const
const newHostId = 'ssh:new-target' as const
const seed = createStore()
seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' })
seed.flush()
const persisted = readDataFile() as PersistedState
const oldAlias = composeWorktreeHostIdentity(oldHostId, worktreeId)
const oldKey = persisted.worktreeIdentityAliases?.[oldAlias]?.[0]
const source = oldKey ? persisted.worktreeMetaByIdentity?.[oldKey] : undefined
expect(source?.instanceId).toBeTruthy()
const newAlias = composeWorktreeHostIdentity(newHostId, worktreeId)
const newKey = canonicalWorktreeIdentity({
worktreeId,
executionHostId: newHostId,
instanceId: source!.instanceId!
})
persisted.worktreeMetaByIdentity ??= {}
persisted.worktreeIdentityAliases ??= {}
persisted.worktreeMetaByIdentity[newKey] = { ...source!, hostId: newHostId }
persisted.worktreeIdentityAliases[newAlias] = [newKey]
writeDataFile(persisted)
const store = createStore()
store.reassignSshTargetId('old-target', 'new-target')
store.flush()
const deduped = readDataFile() as PersistedState
expect(deduped.worktreeIdentityAliases?.[oldAlias]).toBeUndefined()
expect(deduped.worktreeIdentityAliases?.[newAlias]).toEqual([newKey])
expect(deduped.worktreeMetaByIdentity?.[oldKey!]).toBeUndefined()
expect(deduped.worktreeMetaByIdentity?.[newKey]).toEqual({ ...source!, hostId: newHostId })
})
})
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { getRepoOwnedWorktreeMeta } from './worktree-metadata-ownership'
import type { Repo } from '../shared/repo-types'
import type { WorktreeMeta } from '../shared/worktree/meta-types'
const worktreeId = 'repo-1::/workspace/feature'
const localRepo: Repo = {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'Local repo',
badgeColor: '#000',
addedAt: 1,
executionHostId: 'local'
}
const sshRepo: Repo = {
...localRepo,
path: '/workspace/repo',
connectionId: 'build-box',
executionHostId: 'ssh:build-box'
}
const localMeta = { hostId: 'local', displayName: 'local metadata' } as WorktreeMeta
function resolve(repo: Repo, ownerCount: number): WorktreeMeta | undefined {
return getRepoOwnedWorktreeMeta(repo, worktreeId, { [worktreeId]: localMeta }, ownerCount)
}
describe('getRepoOwnedWorktreeMeta', () => {
it('rejects another host metadata when repo IDs and paths collide', () => {
expect(resolve(sshRepo, 2)).toBeUndefined()
})
it('keeps legacy metadata for a single-owner repo', () => {
expect(resolve(localRepo, 1)).toBe(localMeta)
})
})
+5 -1
View File
@@ -112,7 +112,11 @@ export type WorktreeApi = {
expectedHead: string
hostId?: ExecutionHostId
}) => Promise<ForceDeleteWorktreeBranchResult>
updateMeta: (args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => Promise<Worktree>
updateMeta: (args: {
worktreeId: string
executionHostId?: ExecutionHostId
updates: Partial<WorktreeMeta>
}) => Promise<Worktree>
listLineage: () => Promise<{
lineage: Record<string, WorktreeLineage>
workspaceLineage?: Record<string, WorkspaceLineage>
@@ -349,11 +349,20 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe
)
const handleUnlinkPullRequest = useCallback(() => {
if (!activeWorktreeId || activeReview?.provider !== 'github' || linkedPR === null) {
if (
!activeWorktreeId ||
!activeWorktree ||
activeReview?.provider !== 'github' ||
linkedPR === null
) {
return
}
void updateWorktreeMeta(activeWorktreeId, { linkedPR: null })
}, [activeReview?.provider, activeWorktreeId, linkedPR, updateWorktreeMeta])
void updateWorktreeMeta(
activeWorktreeId,
{ linkedPR: null },
{ executionHostId: activeWorktree.hostId }
)
}, [activeReview?.provider, activeWorktree, activeWorktreeId, linkedPR, updateWorktreeMeta])
const handleLinkAnotherPullRequest = useCallback(() => {
if (!activeWorktreeId || !activeWorktree || activeReview?.provider !== 'github') {
@@ -364,6 +373,7 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe
// Why: the same workspace ID can exist under two hosts. Naming the owner
// keeps the dialog on this workspace instead of the ambiguous lookup.
repoId: activeWorktree.repoId,
executionHostId: activeWorktree.hostId,
currentDisplayName: activeWorktree.displayName,
currentIssue: activeWorktree.linkedIssue,
currentPR: activeWorktree.linkedPR ?? activeReview.number,
@@ -8,6 +8,7 @@ import { useAppStore } from '@/store'
import type { Repo } from '../../../../shared/repo-types'
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { WorktreeMetaBatchUpdate } from '../../store/slices/worktree-helpers'
import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
import type { WorkspaceKanbanLaneView } from './workspace-kanban-search'
@@ -164,7 +165,7 @@ vi.mock('./workspace-board-task-status-sync', async (importOriginal) => ({
}))
type UpdateWorktreesMeta = (
updatesByWorktreeId: ReadonlyMap<string, Partial<WorktreeMeta>>
updates: readonly WorktreeMetaBatchUpdate[] | ReadonlyMap<string, Partial<WorktreeMeta>>
) => Promise<void>
const statuses = [
@@ -410,7 +411,13 @@ describe('WorkspaceKanbanDrawer search', () => {
})
})
const dropped = updateWorktreesMeta.mock.calls.at(-1)?.[0].get(omega.id)
const payload = updateWorktreesMeta.mock.calls.at(-1)?.[0]
let dropped: Partial<WorktreeMeta> | undefined
if (Array.isArray(payload)) {
dropped = payload.find((entry) => entry.worktreeId === omega.id)?.updates
} else if (payload && 'get' in payload) {
dropped = payload.get(omega.id)
}
expect(dropped?.workspaceStatus).toBe('todo')
expect(dropped?.manualOrder).toBeGreaterThan(gamma.manualOrder ?? 0)
expect(dropped?.manualOrder).toBeLessThan(delta.manualOrder ?? 0)
@@ -8,6 +8,7 @@ import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types'
import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types'
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
import type { WorkspaceStatus, Worktree } from '../../../../shared/worktree/types'
import type { WorktreeMetaBatchUpdate } from '../../store/slices/worktree-helpers'
import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-status-defaults'
import {
WORKSPACE_STATUS_DRAG_IDS_TYPE,
@@ -369,7 +370,13 @@ async function dropWorktreesOnStatusLane(
function committedStatusUpdates(): Map<string, Partial<WorktreeMeta>> {
expect(mockStore.updateWorktreesMeta).toHaveBeenCalledTimes(1)
return mockStore.updateWorktreesMeta.mock.calls[0]![0] as Map<string, Partial<WorktreeMeta>>
const input = mockStore.updateWorktreesMeta.mock.calls[0]![0] as
| readonly WorktreeMetaBatchUpdate[]
| ReadonlyMap<string, Partial<WorktreeMeta>>
if ('get' in input) {
return new Map(input)
}
return new Map(input.map((entry) => [entry.worktreeId, entry.updates]))
}
describe('WorktreeList status-lane drop carries visible lineage children (#9083)', () => {
@@ -133,6 +133,7 @@ function openDialog(
/** Extra owners of the same workspace ID, which the index reads as ambiguous. */
otherRepos?: { repoId: string; worktree?: Partial<Worktree> }[]
modalRepoId?: string
modalExecutionHostId?: string
linearViewerOrganizationUrlKey?: string
} = {}
): void {
@@ -169,6 +170,7 @@ function openDialog(
modalData: {
worktreeId: options.worktreeId ?? worktree.id,
...(options.modalRepoId ? { repoId: options.modalRepoId } : {}),
...(options.modalExecutionHostId ? { executionHostId: options.modalExecutionHostId } : {}),
currentDisplayName: worktree.displayName,
currentComment: worktree.comment,
focus: 'comment'
@@ -328,6 +330,27 @@ describe('WorktreeMetaDialog issue link row', () => {
expect(updates.linkedIssue).toBe(99)
expect(updates).not.toHaveProperty('linkedLinearIssue')
})
it('qualifies a save with the host selected by the opening row', async () => {
openDialog({
worktree: { hostId: 'ssh:build-box' },
modalExecutionHostId: 'ssh:build-box'
})
fireEvent.change(screen.getByPlaceholderText('Notes about this worktree...'), {
target: { value: 'remote note' }
})
await act(async () => {
fireEvent.click(saveButton())
})
await waitFor(() =>
expect(updateWorktreeMeta).toHaveBeenCalledWith(
WORKTREE_ID,
expect.objectContaining({ comment: 'remote note' }),
{ executionHostId: 'ssh:build-box' }
)
)
})
// updateWorktreeMeta stamps lastActivityAt on any comment write, which would
// reorder the workspace under the time-decay sidebar sort.
@@ -30,6 +30,7 @@ import {
parseIssueLinkInput,
type IssueLinkProvider
} from '../../../../shared/issue-link-input'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { WorktreeDisplayNameField } from './WorktreeDisplayNameField'
function resizeCommentTextarea(textarea: HTMLTextAreaElement): void {
@@ -56,6 +57,10 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
const isOpen = isEditMeta
const worktreeId = typeof modalData.worktreeId === 'string' ? modalData.worktreeId : ''
const executionHostId =
typeof modalData.executionHostId === 'string'
? (parseExecutionHostId(modalData.executionHostId)?.id ?? undefined)
: undefined
const currentDisplayName =
typeof modalData.currentDisplayName === 'string' ? modalData.currentDisplayName : ''
const currentComment =
@@ -224,7 +229,9 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
try {
const updates = buildWorktreeMetaUpdates(draft, snapshot, liveLinks)
const result = await updateWorktreeMeta(worktreeId, updates)
const result = executionHostId
? await updateWorktreeMeta(worktreeId, updates, { executionHostId })
: await updateWorktreeMeta(worktreeId, updates)
// Why: a failed save refetches and reverts the optimistic write. Closing
// here would report success for an edit that silently undid itself, and
// would discard the name, comment and PR changes in the same payload.
@@ -249,6 +256,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
}
}, [
worktreeId,
executionHostId,
canSave,
draft,
snapshot,
@@ -5,6 +5,7 @@ import { groupWorkspaceKanbanWorktrees } from './workspace-kanban-worktree-group
import { buildWorkspaceKanbanLaneViews } from './workspace-kanban-search'
import { useWorkspaceKanbanSearch } from './use-workspace-kanban-search'
import { registerWorkspaceKanbanSidebarDropGroups } from './workspace-kanban-sidebar-drop'
import { buildUnambiguousWorktreeIdIndex } from './worktree-unambiguous-id-index'
import {
composeWorktreeHostIdentity,
getWorktreeHostIdentity
@@ -38,7 +39,7 @@ export function useWorkspaceKanbanBoardProjection(args: {
[args.allWorktrees, args.sortBy, args.workspaceStatuses, visibleWorktreeIds]
)
const worktreeById = useMemo(
() => new Map(args.allWorktrees.map((worktree) => [worktree.id, worktree])),
() => buildUnambiguousWorktreeIdIndex(args.allWorktrees),
[args.allWorktrees]
)
const boardWorktrees = useMemo(
@@ -90,7 +90,11 @@ export function useWorkspaceKanbanStatusActions(args: {
recordInteraction()
for (const worktree of args.allWorktrees) {
if (getWorkspaceStatus(worktree, args.workspaceStatuses) === statusId) {
void args.updateWorktreeMeta(worktree.id, { workspaceStatus: fallbackStatus })
void args.updateWorktreeMeta(
worktree.id,
{ workspaceStatus: fallbackStatus },
{ executionHostId: worktree.hostId ?? 'local' }
)
}
}
},
@@ -9,6 +9,7 @@ import {
} from './worktree-manual-order'
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
import type { WorkspaceStatus, Worktree } from '../../../../shared/worktree/types'
import type { WorktreeMetaBatchUpdate } from '../../store/slices/worktree-helpers'
import type { WorktreeManualOrderCatalog } from './worktree-manual-order-catalog'
type LaneView = { items: readonly Worktree[] }
@@ -54,14 +55,18 @@ export function useWorkspaceKanbanWorktreeActions(args: {
return
}
recordInteraction()
void args.updateWorktreeMeta(worktreeId, { workspaceStatus: status })
void args.updateWorktreeMeta(
worktreeId,
{ workspaceStatus: status },
{ executionHostId: current.hostId ?? 'local' }
)
args.maybeSyncTaskStatuses([worktreeId], status)
},
[args]
)
const moveWorktreesToStatus = useCallback(
(worktreeIds: readonly string[], status: WorkspaceStatus) => {
const updates = new Map<string, Partial<WorktreeMeta>>()
const updates: WorktreeMetaBatchUpdate[] = []
const changedIds: string[] = []
for (const worktreeId of worktreeIds) {
const current = args.worktreeById.get(worktreeId)
@@ -69,7 +74,11 @@ export function useWorkspaceKanbanWorktreeActions(args: {
continue
}
changedIds.push(worktreeId)
updates.set(worktreeId, { workspaceStatus: status })
updates.push({
worktreeId,
updates: { workspaceStatus: status },
executionHostId: current.hostId ?? 'local'
})
}
if (changedIds.length === 0) {
return
@@ -87,7 +96,7 @@ export function useWorkspaceKanbanWorktreeActions(args: {
dropIndex: number
writeManualOrder?: boolean
}) => {
const updates = new Map<string, Partial<WorktreeMeta>>()
const updates: WorktreeMetaBatchUpdate[] = []
const writeManualOrder =
drop.writeManualOrder ?? shouldWriteDropManualOrder(drop.worktreeIds, drop.status)
const order = writeManualOrder
@@ -106,23 +115,40 @@ export function useWorkspaceKanbanWorktreeActions(args: {
if (!current) {
continue
}
const next: Partial<WorktreeMeta> = {}
if (getWorkspaceStatus(current, args.workspaceStatuses) !== drop.status) {
updates.set(worktreeId, { workspaceStatus: drop.status })
next.workspaceStatus = drop.status
}
updates.push({
worktreeId,
updates: next,
executionHostId: current.hostId ?? 'local'
})
}
for (const [worktreeId, manualOrder] of order.updates) {
const entry = updates.find((candidate) => candidate.worktreeId === worktreeId)
if (entry) {
entry.updates = { ...entry.updates, ...manualOrder }
continue
}
const current = args.worktreeById.get(worktreeId)
if (current) {
updates.push({
worktreeId,
updates: manualOrder,
executionHostId: current.hostId ?? 'local'
})
}
}
if (writeManualOrder) {
for (const [worktreeId, manualOrder] of order.updates) {
updates.set(worktreeId, { ...updates.get(worktreeId), ...manualOrder })
}
}
if (updates.size === 0) {
const changed = updates.filter((entry) => Object.keys(entry.updates).length > 0)
if (changed.length === 0) {
return
}
if (writeManualOrder && order.changed) {
args.setSortBy('manual')
}
recordInteraction()
void args.updateWorktreesMeta(updates)
void args.updateWorktreesMeta(changed)
args.maybeSyncTaskStatuses(drop.worktreeIds, drop.status)
},
[args, shouldWriteDropManualOrder]
@@ -157,20 +183,28 @@ export function useWorkspaceKanbanWorktreeActions(args: {
if (!current || current.isPinned) {
return
}
void args.updateWorktreeMeta(worktreeId, { isPinned: true })
void args.updateWorktreeMeta(
worktreeId,
{ isPinned: true },
{ executionHostId: current.hostId ?? 'local' }
)
},
[args]
)
const pinWorktrees = useCallback(
(worktreeIds: readonly string[]) => {
const updates = new Map<string, { isPinned: true }>()
const updates: WorktreeMetaBatchUpdate[] = []
for (const worktreeId of worktreeIds) {
const current = args.worktreeById.get(worktreeId)
if (current && !current.isPinned) {
updates.set(worktreeId, { isPinned: true })
updates.push({
worktreeId,
updates: { isPinned: true },
executionHostId: current.hostId ?? 'local'
})
}
}
if (updates.size > 0) {
if (updates.length > 0) {
recordInteraction()
void args.updateWorktreesMeta(updates)
}
@@ -107,9 +107,13 @@ export function useWorktreeCardActivationActions({
// Inline rename has no surface for the failure; the store already logs and
// refetches, which reverts the optimistic title in place.
async (displayName: string): Promise<void> => {
await updateWorktreeMeta(worktree.id, { displayName })
await updateWorktreeMeta(
worktree.id,
{ displayName },
{ executionHostId: worktree.hostId ?? 'local' }
)
},
[updateWorktreeMeta, worktree.id]
[updateWorktreeMeta, worktree.hostId, worktree.id]
)
const handleDoubleClick = useCallback(
@@ -123,6 +127,7 @@ export function useWorktreeCardActivationActions({
openModal('edit-meta', {
worktreeId: worktree.id,
repoId: worktree.repoId,
executionHostId: worktree.hostId,
currentDisplayName: worktree.displayName,
currentIssue: worktree.linkedIssue,
currentPR: worktree.linkedPR,
@@ -134,6 +139,7 @@ export function useWorktreeCardActivationActions({
affiliateListMode,
worktree.comment,
worktree.displayName,
worktree.hostId,
worktree.id,
worktree.linkedIssue,
worktree.linkedPR,
@@ -145,9 +151,13 @@ export function useWorktreeCardActivationActions({
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
event.stopPropagation()
updateWorktreeMeta(worktree.id, { isUnread: !worktree.isUnread })
updateWorktreeMeta(
worktree.id,
{ isUnread: !worktree.isUnread },
{ executionHostId: worktree.hostId ?? 'local' }
)
},
[worktree.id, worktree.isUnread, updateWorktreeMeta]
[worktree.hostId, worktree.id, worktree.isUnread, updateWorktreeMeta]
)
return { handleClick, handleRenameTitle, handleDoubleClick, handleToggleUnreadQuick }
@@ -50,6 +50,7 @@ export function useWorktreeCardFoundation({
// Why: the same workspace ID can exist under two hosts. Naming the owner
// keeps the dialog on the clicked row instead of the ambiguous lookup.
repoId: worktree.repoId,
executionHostId: worktree.hostId,
currentDisplayName: worktree.displayName,
currentIssue: worktree.linkedIssue,
currentPR: worktree.linkedPR,
@@ -66,6 +67,7 @@ export function useWorktreeCardFoundation({
openModal('edit-meta', {
worktreeId: worktree.id,
repoId: worktree.repoId,
executionHostId: worktree.hostId,
currentDisplayName: worktree.displayName,
currentIssue: worktree.linkedIssue,
currentPR: worktree.linkedPR,
@@ -149,27 +149,28 @@ export function useWorktreeCardSecondaryDetails({
(hoverReviewProvider === 'azure-devops' && linkedAzureDevOpsPR !== null) ||
(hoverReviewProvider === 'gitea' && linkedGiteaPR !== null)
const handleUnlinkReview = useCallback(() => {
const options = { executionHostId: worktree.hostId ?? 'local' }
switch (hoverReviewProvider) {
case 'github':
void updateWorktreeMeta(worktree.id, { linkedPR: null })
void updateWorktreeMeta(worktree.id, { linkedPR: null }, options)
return
case 'gitlab':
void updateWorktreeMeta(worktree.id, { linkedGitLabMR: null })
void updateWorktreeMeta(worktree.id, { linkedGitLabMR: null }, options)
return
case 'bitbucket':
void updateWorktreeMeta(worktree.id, { linkedBitbucketPR: null })
void updateWorktreeMeta(worktree.id, { linkedBitbucketPR: null }, options)
return
case 'azure-devops':
void updateWorktreeMeta(worktree.id, { linkedAzureDevOpsPR: null })
void updateWorktreeMeta(worktree.id, { linkedAzureDevOpsPR: null }, options)
return
case 'gitea':
void updateWorktreeMeta(worktree.id, { linkedGiteaPR: null })
void updateWorktreeMeta(worktree.id, { linkedGiteaPR: null }, options)
break
case 'unsupported':
case undefined:
break
}
}, [hoverReviewProvider, updateWorktreeMeta, worktree.id])
}, [hoverReviewProvider, updateWorktreeMeta, worktree.hostId, worktree.id])
const handleOpenLinearIssueInOrca = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -42,7 +42,11 @@ export function useWorktreeContextMenuCommands(args: {
window.api.ui.writeClipboardText(args.worktree.path)
}, [args])
const handleToggleRead = useCallback(() => {
args.updateWorktreeMeta(args.worktree.id, { isUnread: !args.worktree.isUnread })
args.updateWorktreeMeta(
args.worktree.id,
{ isUnread: !args.worktree.isUnread },
{ executionHostId: args.worktree.hostId ?? 'local' }
)
}, [args])
const handleTogglePin = useCallback(() => {
args.setWorktreesPinnedAndReveal([args.worktree.id], !args.worktree.isPinned)
@@ -100,8 +104,17 @@ export function useWorktreeContextMenuCommands(args: {
args.onAssignWorkspaceStatus?.(plan.worktreeIds, status)
return
}
const localWriteIds = new Set(plan.localWriteIds)
void Promise.all(
plan.localWriteIds.map((id) => args.updateWorktreeMeta(id, { workspaceStatus: status }))
args.activeContextWorktrees
.filter((worktree) => localWriteIds.has(worktree.id))
.map((worktree) =>
args.updateWorktreeMeta(
worktree.id,
{ workspaceStatus: status },
{ executionHostId: worktree.hostId ?? 'local' }
)
)
)
},
[args]
@@ -110,6 +123,7 @@ export function useWorktreeContextMenuCommands(args: {
args.openModal('edit-meta', {
worktreeId: args.worktree.id,
repoId: args.worktree.repoId,
executionHostId: args.worktree.hostId,
currentDisplayName: args.worktree.displayName,
currentIssue: args.worktree.linkedIssue,
currentPR: args.worktree.linkedPR,
@@ -574,7 +574,9 @@ describe('workspace kanban sidebar drop updates', () => {
})
expect(result.shouldSwitchToManual).toBe(false)
expect(Array.from(result.updates)).toEqual([['todo-a', { workspaceStatus: 'doing' }]])
expect(result.updates).toEqual([
{ worktreeId: 'todo-a', updates: { workspaceStatus: 'doing' }, executionHostId: 'local' }
])
})
it('keeps the dropped board position when Manual sort is active', () => {
@@ -601,12 +603,16 @@ describe('workspace kanban sidebar drop updates', () => {
})
expect(result.shouldSwitchToManual).toBe(true)
expect(result.updates.get('todo-a')).toEqual({
expect(result.updates.find((entry) => entry.worktreeId === 'todo-a')?.updates).toEqual({
workspaceStatus: 'doing',
manualOrder: 9000
})
expect(result.updates.get('doing-a')).toEqual({ manualOrder: 10_000 })
expect(result.updates.get('doing-b')).toEqual({ manualOrder: 8000 })
expect(result.updates.find((entry) => entry.worktreeId === 'doing-a')?.updates).toEqual({
manualOrder: 10_000
})
expect(result.updates.find((entry) => entry.worktreeId === 'doing-b')?.updates).toEqual({
manualOrder: 8000
})
})
it('keeps board drops sparse when filtered rows already have durable ranks', () => {
@@ -662,8 +668,12 @@ describe('workspace kanban sidebar drop updates', () => {
now: 10_000
})
expect([...result.updates]).toEqual([
['todo-a', { workspaceStatus: 'doing', manualOrder: 1500 }]
expect(result.updates).toEqual([
{
worktreeId: 'todo-a',
updates: { workspaceStatus: 'doing', manualOrder: 1500 },
executionHostId: 'local'
}
])
})
})
@@ -14,6 +14,7 @@ import {
shouldWriteManualOrderForGroupDrop,
type WorktreeDragGroup
} from './worktree-manual-order'
import type { WorktreeMetaBatchUpdate } from '../../store/slices/worktree-helpers'
import {
CARD_SELECTOR,
getCardDropTarget,
@@ -221,7 +222,7 @@ export function buildWorkspaceKanbanSidebarDropUpdates(args: {
sortBy: string
now: number
}): {
updates: Map<string, Partial<WorktreeMeta>>
updates: WorktreeMetaBatchUpdate[]
shouldSwitchToManual: boolean
} {
const sourceGroupKeys = args.worktreeIds.flatMap((worktreeId) => {
@@ -245,25 +246,41 @@ export function buildWorkspaceKanbanSidebarDropUpdates(args: {
})
: { changed: false, updates: new Map<string, { manualOrder: number }>() }
const updates = new Map<string, Partial<WorktreeMeta>>()
const updates: WorktreeMetaBatchUpdate[] = []
for (const worktreeId of args.worktreeIds) {
const current = args.worktreeById.get(worktreeId)
if (!current) {
continue
}
const next: Partial<WorktreeMeta> = {}
if (getWorkspaceStatus(current, args.workspaceStatuses) !== args.status) {
updates.set(worktreeId, { workspaceStatus: args.status })
next.workspaceStatus = args.status
}
updates.push({
worktreeId,
updates: next,
executionHostId: current.hostId ?? 'local'
})
}
if (writeManualOrder) {
for (const [worktreeId, manualOrder] of order.updates) {
updates.set(worktreeId, { ...updates.get(worktreeId), ...manualOrder })
const current = args.worktreeById.get(worktreeId)
const entry = updates.find((candidate) => candidate.worktreeId === worktreeId)
if (entry) {
entry.updates = { ...entry.updates, ...manualOrder }
} else if (current) {
updates.push({
worktreeId,
updates: manualOrder,
executionHostId: current.hostId ?? 'local'
})
}
}
}
return {
updates,
updates: updates.filter((entry) => Object.keys(entry.updates).length > 0),
shouldSwitchToManual: writeManualOrder && order.changed
}
}
@@ -6,6 +6,7 @@ import type {
Worktree
} from '../../../../../../shared/worktree/types'
import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types'
import type { WorktreeMetaBatchUpdate } from '../../../../store/slices/worktree-helpers'
import { getWorkspaceStatus, getWorkspaceStatusGroupKey } from '../../workspace-status'
import {
buildManualOrderUpdatesForGroupDrop,
@@ -37,22 +38,30 @@ export function useWorktreeStatusMutations(args: {
if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) {
return
}
void updateWorktreeMeta(worktreeId, { workspaceStatus: status })
void updateWorktreeMeta(
worktreeId,
{ workspaceStatus: status },
{ executionHostId: current.hostId ?? 'local' }
)
},
[updateWorktreeMeta, worktreeMap, workspaceStatuses]
)
const moveWorktreesToStatus = useCallback(
(worktreeIds: readonly string[], status: WorkspaceStatus) => {
const updates = new Map<string, { workspaceStatus: WorkspaceStatus }>()
const updates: WorktreeMetaBatchUpdate[] = []
for (const worktreeId of worktreeIds) {
const current = worktreeMap.get(worktreeId)
if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) {
continue
}
updates.set(worktreeId, { workspaceStatus: status })
updates.push({
worktreeId,
updates: { workspaceStatus: status },
executionHostId: current.hostId ?? 'local'
})
}
if (updates.size > 0) {
if (updates.length > 0) {
void updateWorktreesMeta(updates)
}
},
@@ -70,7 +79,7 @@ export function useWorktreeStatusMutations(args: {
rankByWorktreeId: manualOrderCatalog.rankByWorktreeId,
allWorktreeIds: manualOrderCatalog.orderedIds
})
const updates = new Map<string, Partial<WorktreeMeta>>()
const updates = new Map<string, WorktreeMetaBatchUpdate>()
for (const worktreeId of dropArgs.worktreeIds) {
const current = worktreeMap.get(worktreeId)
if (!current) {
@@ -80,13 +89,20 @@ export function useWorktreeStatusMutations(args: {
if (getWorkspaceStatus(current, workspaceStatuses) !== dropArgs.status) {
next.workspaceStatus = dropArgs.status
}
updates.set(worktreeId, next)
updates.set(worktreeId, {
worktreeId,
updates: next,
executionHostId: current.hostId ?? 'local'
})
}
for (const [worktreeId, manualOrder] of order.updates) {
updates.set(worktreeId, { ...updates.get(worktreeId), ...manualOrder })
const entry = updates.get(worktreeId)
if (entry) {
entry.updates = { ...entry.updates, ...manualOrder }
}
}
for (const [worktreeId, update] of Array.from(updates)) {
if (Object.keys(update).length === 0) {
for (const [worktreeId, entry] of updates) {
if (Object.keys(entry.updates).length === 0) {
updates.delete(worktreeId)
}
}
@@ -97,7 +113,7 @@ export function useWorktreeStatusMutations(args: {
if (order.changed) {
setSortBy('manual')
}
void updateWorktreesMeta(updates)
void updateWorktreesMeta([...updates.values()])
},
[manualOrderCatalog, setSortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses]
)
@@ -129,14 +145,18 @@ export function useWorktreeStatusMutations(args: {
rankByWorktreeId: manualOrderCatalog.rankByWorktreeId,
allWorktreeIds: manualOrderCatalog.orderedIds
})
if (!result.changed) {
return
if (result.changed) {
setSortBy('manual')
}
// Why: only switch to Manual after a real move so accidental click-drags don't change the sort.
setSortBy('manual')
void updateWorktreesMeta(result.updates)
void updateWorktreesMeta(
[...result.updates].map(([worktreeId, updates]) => ({
worktreeId,
updates,
executionHostId: worktreeMap.get(worktreeId)?.hostId ?? 'local'
}))
)
},
[manualOrderCatalog, setSortBy, updateWorktreesMeta]
[manualOrderCatalog, setSortBy, updateWorktreesMeta, worktreeMap]
)
const shouldShowWorkspaceBoardDropIndicator = useCallback(
@@ -165,7 +185,7 @@ export function useWorktreeStatusMutations(args: {
allWorktreeIds: manualOrderCatalog.orderedIds,
rankByWorktreeId: manualOrderCatalog.rankByWorktreeId
})
if (result.updates.size === 0) {
if (result.updates.length === 0) {
return
}
// Why: switch to Manual when the drop changes order so the placement stays visible.
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { buildUnambiguousWorktreeIdIndex } from './worktree-unambiguous-id-index'
import type { Worktree } from '../../../../shared/worktree/types'
function worktree(id: string, hostId: Worktree['hostId']): Worktree {
return {
id,
repoId: 'repo',
path: `/repo/${id}`,
branch: id,
isMainWorktree: false,
hostId
} as Worktree
}
describe('buildUnambiguousWorktreeIdIndex', () => {
it('keeps unique bare ids', () => {
const local = worktree('repo::local', 'local')
const remote = worktree('repo::remote', 'ssh:host')
expect(buildUnambiguousWorktreeIdIndex([local, remote])).toEqual(
new Map([
['repo::local', local],
['repo::remote', remote]
])
)
})
it('drops bare ids claimed by multiple hosts', () => {
const local = worktree('repo::feature', 'local')
const remote = worktree('repo::feature', 'ssh:host')
expect(buildUnambiguousWorktreeIdIndex([local, remote]).has('repo::feature')).toBe(false)
})
it('keeps later unique ids after an ambiguity is found', () => {
const local = worktree('repo::feature', 'local')
const remote = worktree('repo::feature', 'ssh:host')
const other = worktree('repo::other', 'local')
expect(buildUnambiguousWorktreeIdIndex([local, remote, other]).get('repo::other')).toBe(other)
})
})
@@ -0,0 +1,17 @@
import type { Worktree } from '../../../../shared/worktree/types'
export function buildUnambiguousWorktreeIdIndex(
worktrees: readonly Worktree[]
): Map<string, Worktree> {
const index = new Map<string, Worktree>()
const ambiguous = new Set<string>()
for (const worktree of worktrees) {
if (index.has(worktree.id)) {
index.delete(worktree.id)
ambiguous.add(worktree.id)
} else if (!ambiguous.has(worktree.id)) {
index.set(worktree.id, worktree)
}
}
return index
}
@@ -79,11 +79,12 @@ describe('folder workspace owner-routed mutations', () => {
folderWorkspaces: [folderWorkspace]
})
await store
.getState()
.updateWorktreesMeta(
new Map([[folderWorkspaceKey(folderWorkspace.id), { manualOrder: 9000 }]])
)
await store.getState().updateWorktreesMeta([
{
worktreeId: folderWorkspaceKey(folderWorkspace.id),
updates: { manualOrder: 9000 }
}
])
expect(folderWorkspacesUpdate).toHaveBeenCalledWith({
folderWorkspaceId: folderWorkspace.id,
@@ -37,7 +37,6 @@ import type {
PendingWorktreeCreation,
WorktreeCreationPhase
} from '@/lib/pending-worktree-creation'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
import type { AppState } from '../types'
import type { WorktreeRefreshAllOptions } from './worktree-refresh-options'
export type { WorktreePurgeTarget, WorktreePurgeTargets } from './worktree-purge-target'
@@ -46,6 +45,10 @@ export type { WorktreeDeleteState, WorktreeDeleteStateTarget } from './worktree-
import type { WorktreeDeleteState, WorktreeDeleteStateTarget } from './worktree-delete-state-types'
export { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
export {
applyWorktreeUpdates,
withoutErasedRequiredWorktreeFields
} from './worktree-meta-update-application'
import type { RendererRemoveWorktreeResult } from './renderer-remove-worktree-result'
export type WorktreeFetchOptions = {
@@ -64,10 +67,17 @@ export type DirectSshWorktreeFetchOptions = WorktreeFetchOptions & {
export type WorktreeMetaUpdateGuard = (worktree: Worktree | DetectedWorktree | undefined) => boolean
export type WorktreeMetaUpdateOptions = {
/** Required to mutate one row when the legacy locator exists on multiple hosts. */
executionHostId?: ExecutionHostId
shouldApply?: WorktreeMetaUpdateGuard
/** Skip the automatic review refetch when the caller owns an equivalent refresh. */
suppressHostedReviewRefresh?: boolean
}
export type WorktreeMetaBatchUpdate = {
worktreeId: string
updates: Partial<WorktreeMeta>
executionHostId?: ExecutionHostId
}
export type WorktreeRenameRequest = {
worktreeId: string
@@ -264,9 +274,7 @@ export type WorktreeSlice = {
options?: WorktreeMetaUpdateOptions
) => Promise<{ ok: true } | { ok: false; error: string }>
ensureHostedReviewPushTarget: (worktreeId: string) => Promise<void>
updateWorktreesMeta: (
updatesByWorktreeId: ReadonlyMap<string, Partial<WorktreeMeta>>
) => Promise<void>
updateWorktreesMeta: (updatesByWorktreeId: readonly WorktreeMetaBatchUpdate[]) => Promise<void>
/**
* Pin/unpin worktrees, then reveal the first changed one. The reveal keeps
* the shortcut action visible even though pinned worktrees also remain in
@@ -369,68 +377,3 @@ export function findWorktreeById(
return undefined
}
type RequiredKey<T> = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T]
// Why: a present-but-undefined key in a spread ERASES the field. That is the
// intended wire signal for clearing optional metadata (pushTarget), but on a
// field Worktree declares required it produced a live `displayName: undefined`
// that crashed the worktree palette (crash a1f81ea1). Typed off Worktree so a
// newly-required field is protected automatically.
const ERASURE_PROTECTED_KEYS: Record<Extract<RequiredKey<Worktree>, keyof WorktreeMeta>, true> = {
displayName: true,
comment: true,
linkedIssue: true,
linkedPR: true,
linkedLinearIssue: true,
isArchived: true,
isUnread: true,
isPinned: true,
sortOrder: true,
lastActivityAt: true
}
export function withoutErasedRequiredWorktreeFields(
updates: Partial<WorktreeMeta>
): Partial<WorktreeMeta> {
const erased = Object.keys(ERASURE_PROTECTED_KEYS).filter(
(key) => updates[key as keyof WorktreeMeta] === undefined && Object.hasOwn(updates, key)
)
if (erased.length === 0) {
return updates
}
const next = { ...updates }
for (const key of erased) {
delete next[key as keyof WorktreeMeta]
}
return next
}
export function applyWorktreeUpdates(
worktreesByRepo: Record<string, Worktree[]>,
worktreeId: string,
rawUpdates: Partial<WorktreeMeta>
): Record<string, Worktree[]> {
const updates = withoutErasedRequiredWorktreeFields(rawUpdates)
const repoId = getRepoIdFromWorktreeId(worktreeId)
const worktrees = worktreesByRepo[repoId]
if (!worktrees) {
return worktreesByRepo
}
let changed = false
const nextWorktrees = worktrees.map((worktree) => {
if (worktree.id !== worktreeId) {
return worktree
}
changed = true
return { ...worktree, ...updates }
})
if (!changed) {
return worktreesByRepo
}
return { ...worktreesByRepo, [repoId]: nextWorktrees }
}
@@ -0,0 +1,69 @@
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
import type { Worktree } from '../../../../shared/worktree/types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { worktreeRowMatchesMetaHost } from './worktrees/listing/worktree-meta-host-match'
type RequiredKey<T> = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T]
// Why: a present-but-undefined key in a spread ERASES the field. That is the
// intended wire signal for clearing optional metadata, but this protects required
// Worktree fields from disappearing in optimistic projections.
const ERASURE_PROTECTED_KEYS: Record<Extract<RequiredKey<Worktree>, keyof WorktreeMeta>, true> = {
displayName: true,
comment: true,
linkedIssue: true,
linkedPR: true,
linkedLinearIssue: true,
isArchived: true,
isUnread: true,
isPinned: true,
sortOrder: true,
lastActivityAt: true
}
export function withoutErasedRequiredWorktreeFields(
updates: Partial<WorktreeMeta>
): Partial<WorktreeMeta> {
const erased = Object.keys(ERASURE_PROTECTED_KEYS).filter(
(key) => updates[key as keyof WorktreeMeta] === undefined && Object.hasOwn(updates, key)
)
if (erased.length === 0) {
return updates
}
const next = { ...updates }
for (const key of erased) {
delete next[key as keyof WorktreeMeta]
}
return next
}
export function applyWorktreeUpdates(
worktreesByRepo: Record<string, Worktree[]>,
worktreeId: string,
rawUpdates: Partial<WorktreeMeta>,
executionHostId?: ExecutionHostId
): Record<string, Worktree[]> {
const updates = withoutErasedRequiredWorktreeFields(rawUpdates)
const repoId = getRepoIdFromWorktreeId(worktreeId)
const worktrees = worktreesByRepo[repoId]
if (!worktrees) {
return worktreesByRepo
}
let changed = false
const nextWorktrees = worktrees.map((worktree) => {
if (worktree.id !== worktreeId || !worktreeRowMatchesMetaHost(worktree, executionHostId)) {
return worktree
}
changed = true
return { ...worktree, ...updates }
})
if (!changed) {
return worktreesByRepo
}
return { ...worktreesByRepo, [repoId]: nextWorktrees }
}
@@ -102,7 +102,7 @@ describe('worktree remote runtime mutations', () => {
[
'updateWorktreesMeta',
(store: ReturnType<typeof createTestStore>, worktreeId: string) =>
store.getState().updateWorktreesMeta(new Map([[worktreeId, { isUnread: true }]]))
store.getState().updateWorktreesMeta([{ worktreeId, updates: { isUnread: true } }])
],
[
'markWorktreeUnread',
@@ -282,12 +282,10 @@ describe('fetchWorktrees', () => {
const refresh = store.getState().fetchWorktrees('repo1')
await vi.waitFor(() => expect(worktreeListMock).toHaveBeenCalledTimes(1))
await store.getState().updateWorktreesMeta(
new Map([
[daily.id, { manualOrder: 100 }],
[relay.id, { manualOrder: 200 }]
])
)
await store.getState().updateWorktreesMeta([
{ worktreeId: daily.id, updates: { manualOrder: 100 } },
{ worktreeId: relay.id, updates: { manualOrder: 200 } }
])
resolveListing([refreshedDaily, relay])
await refresh
@@ -65,6 +65,7 @@ describe('worktree remote runtime mutations', () => {
})
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
executionHostId: wt.hostId ?? 'local',
updates: { linkedPR: 2548, pushTarget }
})
expect(store.getState().worktreesByRepo.repo1[0]?.pushTarget).toEqual(pushTarget)
@@ -88,9 +89,9 @@ describe('worktree remote runtime mutations', () => {
} as Partial<AppState>)
await store.getState().updateWorktreeMeta(wt.id, { linkedPR: null })
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
executionHostId: wt.hostId ?? 'local',
updates: { linkedPR: null, pushTarget: undefined }
})
expect(store.getState().worktreesByRepo.repo1[0]?.pushTarget).toBeUndefined()
@@ -119,9 +120,9 @@ describe('worktree remote runtime mutations', () => {
.getState()
.updateWorktreeMeta(wt.id, { linkedPR: null }, { suppressHostedReviewRefresh: true })
expect(fetchHostedReviewForBranch).not.toHaveBeenCalled()
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
executionHostId: wt.hostId ?? 'local',
updates: { linkedPR: null, pushTarget: undefined }
})
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
@@ -152,9 +153,9 @@ describe('worktree remote runtime mutations', () => {
} as Partial<AppState>)
await store.getState().updateWorktreeMeta(wt.id, { linkedGitLabMR: 42 })
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
executionHostId: wt.hostId ?? 'local',
updates: { linkedGitLabMR: 42, linkedPR: null, pushTarget: undefined }
})
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
@@ -186,6 +187,7 @@ describe('worktree remote runtime mutations', () => {
})
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
executionHostId: wt.hostId ?? 'local',
updates: { pushTarget: newPushTarget }
})
expect(store.getState().worktreesByRepo.repo1[0]?.pushTarget).toEqual(newPushTarget)
@@ -300,9 +302,9 @@ describe('worktree remote runtime mutations', () => {
await store.getState().updateWorktreeMeta(wt.id, { linkedPR: 0 })
expect(mockApi.worktrees.resolvePrBase).not.toHaveBeenCalled()
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
executionHostId: wt.hostId ?? 'local',
updates: { linkedPR: 0 }
})
})
@@ -326,9 +328,9 @@ describe('worktree remote runtime mutations', () => {
await store.getState().updateWorktreeMeta(wt.id, { linkedPR: 2548 })
expect(mockApi.worktrees.resolvePrBase).not.toHaveBeenCalled()
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
executionHostId: wt.hostId ?? 'local',
updates: { linkedPR: 2548 }
})
})
@@ -361,6 +363,7 @@ describe('worktree remote runtime mutations', () => {
})
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
executionHostId: wt.hostId ?? 'local',
updates: { linkedPR: 2548, pushTarget }
})
expect(store.getState().worktreesByRepo.repo1[0]?.pushTarget).toEqual(pushTarget)
@@ -397,6 +400,7 @@ describe('worktree remote runtime mutations', () => {
})
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
executionHostId: wt.hostId ?? 'local',
updates: { pushTarget }
})
expect(store.getState().worktreesByRepo.repo1[0]?.pushTarget).toEqual(pushTarget)
@@ -520,7 +524,8 @@ describe('worktree remote runtime mutations', () => {
id: 'repo-ssh::/home/orca/runtime-wt',
repoId: 'repo-ssh',
path: '/home/orca/runtime-wt',
linkedPR: 5571
linkedPR: 5571,
hostId: 'ssh:ssh-1'
})
mockApi.worktrees.resolvePrBase.mockResolvedValueOnce({
baseBranch: 'fork-head',
@@ -549,6 +554,7 @@ describe('worktree remote runtime mutations', () => {
})
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
executionHostId: wt.hostId ?? 'local',
updates: { pushTarget }
})
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
@@ -607,6 +613,51 @@ describe('worktree remote runtime mutations', () => {
expect(mockApi.worktrees.resolvePrBase).not.toHaveBeenCalled()
expect(mockApi.worktrees.updateMeta).not.toHaveBeenCalled()
})
it('updates only the explicitly selected owner when locators collide', async () => {
const store = createTestStore()
const worktreeId = 'repo-shared::/same/path'
const sshA = makeWorktree({
id: worktreeId,
repoId: 'repo-shared',
hostId: 'ssh:ssh-a',
runtimeOwnerEnvironmentId: 'hub-a',
comment: 'A'
})
const sshB = makeWorktree({
id: worktreeId,
repoId: 'repo-shared',
hostId: 'ssh:ssh-b',
runtimeOwnerEnvironmentId: 'hub-b',
comment: 'B'
})
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-set-selected-owner',
ok: true,
result: { worktree: sshB },
_meta: { runtimeId: 'hub-b' }
})
store.setState({
settings: { activeRuntimeEnvironmentId: 'hub-a' } as never,
worktreesByRepo: { 'repo-shared': [sshA, sshB] }
} as Partial<AppState>)
const result = await store
.getState()
.updateWorktreeMeta(worktreeId, { comment: 'selected B' }, { executionHostId: 'ssh:ssh-b' })
expect(result).toEqual({ ok: true })
expect(store.getState().worktreesByRepo['repo-shared']).toEqual([
expect.objectContaining({ hostId: 'ssh:ssh-a', comment: 'A' }),
expect.objectContaining({ hostId: 'ssh:ssh-b', comment: 'selected B' })
])
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({
selector: 'hub-b',
method: 'worktree.set',
params: expect.objectContaining({ comment: 'selected B' })
})
)
})
it('cleans up the in-flight lookup when restore is skipped for a genuinely ambiguous owner', async () => {
const store = createTestStore()
@@ -703,6 +754,7 @@ describe('worktree remote runtime mutations', () => {
})
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
executionHostId: wt.hostId ?? 'local',
updates: { pushTarget }
})
expect(store.getState().worktreesByRepo.repo1[0]?.pushTarget).toEqual(pushTarget)
@@ -155,7 +155,8 @@ describe('worktree remote runtime mutations', () => {
const wt = makeWorktree({
id: 'repo-ssh::/home/orca/wt1',
repoId: 'repo-ssh',
path: '/home/orca/wt1'
path: '/home/orca/wt1',
hostId: 'ssh:ssh-1'
})
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
@@ -176,6 +177,7 @@ describe('worktree remote runtime mutations', () => {
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
executionHostId: 'ssh:ssh-1',
updates: expect.objectContaining({ comment: 'ssh note' })
})
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
@@ -199,6 +201,7 @@ describe('worktree remote runtime mutations', () => {
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
executionHostId: 'local',
updates: {
displayName: 'Fix auth',
pendingFirstAgentMessageRename: false,
@@ -378,12 +381,10 @@ describe('worktree remote runtime mutations', () => {
} as Partial<AppState>)
const unsubscribe = store.subscribe(subscriber)
await store.getState().updateWorktreesMeta(
new Map([
[first.id, { workspaceStatus: 'in-review' }],
[second.id, { workspaceStatus: 'completed' }]
])
)
await store.getState().updateWorktreesMeta([
{ worktreeId: first.id, updates: { workspaceStatus: 'in-review' } },
{ worktreeId: second.id, updates: { workspaceStatus: 'completed' } }
])
unsubscribe()
expect(store.getState().worktreesByRepo.repo1.map((w) => w.workspaceStatus)).toEqual([
@@ -412,13 +413,14 @@ describe('worktree remote runtime mutations', () => {
})
store.setState({ worktreesByRepo: { repo1: [local, remote] } } as Partial<AppState>)
await store.getState().updateWorktreesMeta(new Map([[worktreeId, { manualOrder: 9000 }]]))
await store.getState().updateWorktreesMeta([{ worktreeId, updates: { manualOrder: 9000 } }])
expect(store.getState().worktreesByRepo.repo1.map((row) => row.manualOrder)).toEqual([
9000, 9000
])
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId,
executionHostId: 'local',
updates: { manualOrder: 9000 }
})
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
@@ -428,4 +430,40 @@ describe('worktree remote runtime mutations', () => {
timeoutMs: 15_000
})
})
it('persists an explicit bulk host update to only the selected owner', async () => {
const store = createTestStore()
const worktreeId = 'repo1::/same/path'
const local = makeWorktree({ id: worktreeId, repoId: 'repo1', hostId: 'local' })
const remote = makeWorktree({
id: worktreeId,
repoId: 'repo1',
hostId: 'runtime:env-1'
})
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-set-selected-owner',
ok: true,
result: { worktree: { ...remote, comment: 'selected' } },
_meta: { runtimeId: 'runtime-remote' }
})
store.setState({ worktreesByRepo: { repo1: [local, remote] } } as Partial<AppState>)
await store
.getState()
.updateWorktreesMeta([
{ worktreeId, updates: { comment: 'selected' }, executionHostId: 'runtime:env-1' }
])
expect(store.getState().worktreesByRepo.repo1).toEqual([
expect.objectContaining({ hostId: 'local', comment: '' }),
expect.objectContaining({ hostId: 'runtime:env-1', comment: 'selected' })
])
expect(mockApi.worktrees.updateMeta).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({
selector: 'env-1',
method: 'worktree.set',
params: expect.objectContaining({ comment: 'selected' })
})
)
})
})
@@ -77,6 +77,7 @@ describe('worktree remote runtime mutations', () => {
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
executionHostId: 'local',
updates: { linkedPR: 42 }
})
})
@@ -123,6 +124,7 @@ describe('worktree remote runtime mutations', () => {
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
executionHostId: 'local',
updates: { linkedPR: 42 }
})
})
@@ -314,6 +316,7 @@ describe('worktree remote runtime mutations', () => {
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
executionHostId: 'local',
updates: { linkedPR: 42 }
})
})
@@ -16,10 +16,13 @@ import { worktreeMatchesHost } from './worktree-host-ownership'
const folderWorkspaceWorktreeCache = new WeakMap<FolderWorkspace, Worktree>()
import { worktreeRowMatchesMetaHost } from './worktree-meta-host-match'
export function applyDetectedWorktreeUpdates(
detectedWorktreesByRepo: AppState['detectedWorktreesByRepo'],
worktreeId: string,
rawUpdates: Partial<WorktreeMeta>
rawUpdates: Partial<WorktreeMeta>,
executionHostId?: ExecutionHostId
): AppState['detectedWorktreesByRepo'] {
// Why: mirrors applyWorktreeUpdates — detected rows feed the same palette.
const updates = withoutErasedRequiredWorktreeFields(rawUpdates)
@@ -29,7 +32,7 @@ export function applyDetectedWorktreeUpdates(
for (const [repoId, result] of Object.entries(detectedWorktreesByRepo)) {
let repoChanged = false
const nextWorktrees = result.worktrees.map((worktree) => {
if (worktree.id !== worktreeId) {
if (worktree.id !== worktreeId || !worktreeRowMatchesMetaHost(worktree, executionHostId)) {
return worktree
}
repoChanged = true
@@ -0,0 +1,19 @@
import {
LOCAL_EXECUTION_HOST_ID,
type ExecutionHostId
} from '../../../../../../shared/execution-host'
/**
* Mirror findKnownWorktreeById: an unstamped row belongs to the local host. A stricter test
* silently no-ops the optimistic write, so a rename appears to do nothing until the next refetch.
*/
export function worktreeRowMatchesMetaHost(
worktree: { hostId?: ExecutionHostId },
executionHostId: ExecutionHostId | undefined
): boolean {
return (
executionHostId === undefined ||
worktree.hostId === executionHostId ||
(worktree.hostId === undefined && executionHostId === LOCAL_EXECUTION_HOST_ID)
)
}
@@ -28,13 +28,19 @@ import {
trySettingsForWorktreeOwner
} from '../listing/worktree-owner-settings'
import { findRepoForHost } from '../../repo-host-identity'
export function createUpdateWorktreeMeta(
set: WorktreeSliceSet,
get: WorktreeSliceGet
): WorktreeSlice['updateWorktreeMeta'] {
return async (worktreeId, updates, options) => {
const shouldApplyUpdate = options?.shouldApply
const existingWorktree = get().getKnownWorktreeById(worktreeId)
const requestedHostId = options?.executionHostId
const existingWorktree = findKnownWorktreeById(get(), worktreeId, requestedHostId)
const executionHostId =
requestedHostId ??
existingWorktree?.hostId ??
(get().settings?.activeRuntimeEnvironmentId ? undefined : 'local')
if (shouldApplyUpdate && !shouldApplyUpdate(existingWorktree)) {
return { ok: true }
}
@@ -78,7 +84,7 @@ export function createUpdateWorktreeMeta(
normalizedUpdates.pushTarget === undefined &&
existingWorktree &&
!existingWorktree.pushTarget
? trySettingsForWorktreeOwner(get(), worktreeId)
? trySettingsForWorktreeOwner(get(), worktreeId, executionHostId)
: null
const resolvedPushTarget =
pushTargetOwnerSettings && existingWorktree && linkedPrForPushTarget !== null
@@ -101,7 +107,7 @@ export function createUpdateWorktreeMeta(
resolvedPushTarget === undefined &&
existingHostedReviewPushTargetLookup !== null &&
existingHostedReviewPushTargetLookup.key !== nextHostedReviewPushTargetLookup?.key
const worktreeForUpdate = get().getKnownWorktreeById(worktreeId)
const worktreeForUpdate = get().getKnownWorktreeById(worktreeId, executionHostId)
if (shouldApplyUpdate && !shouldApplyUpdate(worktreeForUpdate)) {
return { ok: true }
}
@@ -116,7 +122,10 @@ export function createUpdateWorktreeMeta(
(normalizedUpdates.linkedGiteaPR === null &&
(worktreeForUpdate?.linkedGiteaPR ?? null) !== null)
const reviewRepo = shouldRefreshHostedReview
? get().repos.find((repo) => repo.id === worktreeForUpdate?.repoId)
? (findRepoForHost(get().repos, worktreeForUpdate?.repoId ?? '', {
hostId: executionHostId,
settings: get().settings
}) ?? undefined)
: undefined
const reviewBranch = worktreeForUpdate?.branch.replace(/^refs\/heads\//, '')
@@ -139,15 +148,24 @@ export function createUpdateWorktreeMeta(
let didApply = false
set((s) => {
if (shouldApplyUpdate && !shouldApplyUpdate(findKnownWorktreeById(s, worktreeId))) {
if (
shouldApplyUpdate &&
!shouldApplyUpdate(findKnownWorktreeById(s, worktreeId, executionHostId))
) {
return {}
}
didApply = true
const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, enriched)
const nextWorktrees = applyWorktreeUpdates(
s.worktreesByRepo,
worktreeId,
enriched,
executionHostId
)
const nextDetectedWorktrees = applyDetectedWorktreeUpdates(
s.detectedWorktreesByRepo,
worktreeId,
enriched
enriched,
executionHostId
)
const cacheKey =
reviewRepo && reviewBranch
@@ -231,7 +249,13 @@ export function createUpdateWorktreeMeta(
}
try {
await persistWorktreeMeta(settingsForWorktreeOwner(get(), worktreeId), worktreeId, enriched)
await persistWorktreeMeta(
settingsForWorktreeOwner(get(), worktreeId, executionHostId),
worktreeId,
enriched,
executionHostId ?? existingWorktree?.hostId,
worktreeForUpdate?.identity?.key
)
if (
!options?.suppressHostedReviewRefresh &&
reviewRepo &&
@@ -1,4 +1,4 @@
import type { WorktreeSlice } from '../../worktree-helpers'
import type { WorktreeMetaBatchUpdate, WorktreeSlice } from '../../worktree-helpers'
import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types'
import { applyWorktreeUpdates, getRepoIdFromWorktreeId } from '../../worktree-helpers'
import {
@@ -11,7 +11,6 @@ import { settingsForWorktreeOwner } from '../listing/worktree-owner-settings'
import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { parseExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host'
import { getIndexedWorktreesById } from '../../../worktree-repo-index'
import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types'
function getKnownOwnerHostIds(
state: ReturnType<WorktreeSliceGet>,
@@ -26,25 +25,24 @@ function getKnownOwnerHostIds(
}
return [...hostIds]
}
export function createUpdateWorktreesMeta(
set: WorktreeSliceSet,
get: WorktreeSliceGet
): WorktreeSlice['updateWorktreesMeta'] {
return async (updatesByWorktreeId) => {
if (updatesByWorktreeId.size === 0) {
return async (updates) => {
if (updates.length === 0) {
return
}
const gitWorktreeUpdates = new Map<string, Partial<WorktreeMeta>>()
const gitWorktreeUpdates: WorktreeMetaBatchUpdate[] = []
const folderWorkspaceUpdates: {
folderWorkspaceId: string
updates: ReturnType<typeof getFolderWorkspaceMetaUpdates>
}[] = []
for (const [worktreeId, updates] of updatesByWorktreeId) {
const scope = parseWorkspaceKey(worktreeId)
for (const entry of updates) {
const scope = parseWorkspaceKey(entry.worktreeId)
if (scope?.type === 'folder') {
const folderUpdates = getFolderWorkspaceMetaUpdates(updates)
const folderUpdates = getFolderWorkspaceMetaUpdates(entry.updates)
if (Object.keys(folderUpdates).length > 0) {
folderWorkspaceUpdates.push({
folderWorkspaceId: scope.folderWorkspaceId,
@@ -52,19 +50,25 @@ export function createUpdateWorktreesMeta(
})
}
} else {
gitWorktreeUpdates.set(worktreeId, updates)
gitWorktreeUpdates.push(entry)
}
}
set((s) => {
let nextWorktrees = s.worktreesByRepo
let nextDetectedWorktrees = s.detectedWorktreesByRepo
for (const [worktreeId, updates] of gitWorktreeUpdates) {
nextWorktrees = applyWorktreeUpdates(nextWorktrees, worktreeId, updates)
for (const entry of gitWorktreeUpdates) {
nextWorktrees = applyWorktreeUpdates(
nextWorktrees,
entry.worktreeId,
entry.updates,
entry.executionHostId
)
nextDetectedWorktrees = applyDetectedWorktreeUpdates(
nextDetectedWorktrees,
worktreeId,
updates
entry.worktreeId,
entry.updates,
entry.executionHostId
)
}
return nextWorktrees === s.worktreesByRepo &&
@@ -84,20 +88,27 @@ export function createUpdateWorktreesMeta(
...folderWorkspaceUpdates.map(({ folderWorkspaceId, updates }) =>
get().updateFolderWorkspace(folderWorkspaceId, updates)
),
...Array.from(gitWorktreeUpdates, async ([worktreeId, updates]) => {
...gitWorktreeUpdates.map(async ({ worktreeId, updates, executionHostId }) => {
try {
const state = get()
const ownerHostIds = getKnownOwnerHostIds(state, worktreeId)
const ownerHostIds = executionHostId
? [executionHostId]
: getKnownOwnerHostIds(state, worktreeId)
await (ownerHostIds.length === 0
? persistWorktreeMeta(settingsForWorktreeOwner(state, worktreeId), worktreeId, updates)
: Promise.all(
ownerHostIds.map((hostId) =>
persistWorktreeMeta(
ownerHostIds.map((hostId) => {
const worktree = getIndexedWorktreesById(state.worktreesByRepo, worktreeId).find(
(candidate) => candidate.hostId === hostId
)
return persistWorktreeMeta(
settingsForWorktreeOwner(state, worktreeId, hostId),
worktreeId,
updates
updates,
hostId,
worktree?.identity?.key
)
)
})
))
} catch (err) {
if (isRuntimeSelectorNotFoundError(err)) {
@@ -11,16 +11,22 @@ import { toRuntimeWorktreeSelector } from '../../../../runtime/runtime-worktree-
import { translate } from '@/i18n/i18n'
import type { AppState } from '../../../types'
import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { encodePushTargetClearForRuntimeRpc } from './hosted-review-link-mutation'
export async function persistWorktreeMeta(
settings: AppState['settings'],
worktreeId: string,
updates: Partial<WorktreeMeta>
updates: Partial<WorktreeMeta>,
executionHostId?: ExecutionHostId,
identityKey?: string
): Promise<void> {
const target = getActiveRuntimeTarget(settings)
if (target.kind === 'local') {
await window.api.worktrees.updateMeta({ worktreeId, updates })
await window.api.worktrees.updateMeta({
worktreeId,
...(executionHostId ? { executionHostId } : {}),
updates
})
return
}
// Why: `worktree.set` parses in strip mode, so an older runtime drops the key
@@ -55,7 +61,7 @@ export async function persistWorktreeMeta(
target,
'worktree.set',
{
worktree: toRuntimeWorktreeSelector(worktreeId),
worktree: identityKey ? `identity:${identityKey}` : toRuntimeWorktreeSelector(worktreeId),
...encodePushTargetClearForRuntimeRpc(updates)
},
{ timeoutMs: 15_000 }
@@ -1,4 +1,4 @@
import type { WorktreeSlice } from '../../worktree-helpers'
import type { WorktreeMetaBatchUpdate, WorktreeSlice } from '../../worktree-helpers'
import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types'
import {
getActiveSidebarWorkspaceId,
@@ -9,11 +9,9 @@ import {
isValidResolvedWorktreeLineageEdge
} from '../../../../../../shared/resolved-worktree-lineage'
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
type WorktreeWithEmbeddedLineage = Worktree & { lineage?: WorktreeLineage | null }
function getProjectedLineage(get: WorktreeSliceGet, worktree: Worktree): WorktreeLineage | null {
if (Object.hasOwn(get().worktreeLineageById, worktree.id)) {
return get().worktreeLineageById[worktree.id] ?? null
@@ -66,7 +64,7 @@ export function createSetWorktreesPinnedAndReveal(
get().activeWorktreeId
)
// Skip worktrees already in the target state so a no-op toggle doesn't scroll the viewport away.
const updates = new Map<string, Partial<WorktreeMeta>>()
const updates: WorktreeMetaBatchUpdate[] = []
const changedWorktreeIds = new Set<string>()
let didChange = false
let revealWorktreeId: string | null = null
@@ -79,9 +77,17 @@ export function createSetWorktreesPinnedAndReveal(
changedWorktreeIds.add(worktreeId)
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
void get().updateWorktreeMeta(worktreeId, { isPinned })
void get().updateWorktreeMeta(
worktreeId,
{ isPinned },
{ executionHostId: current.hostId ?? 'local' }
)
} else {
updates.set(worktreeId, { isPinned })
updates.push({
worktreeId,
updates: { isPinned },
executionHostId: current.hostId ?? 'local'
})
}
if (revealWorktreeId === null && worktreeId === activeSidebarWorktreeId) {
revealWorktreeId = worktreeId
@@ -99,7 +105,7 @@ export function createSetWorktreesPinnedAndReveal(
revealWorktreeId = activeSidebarWorktreeId
}
// updateWorktreesMeta applies the store update synchronously, so the reveal below sees the row already rendered.
if (updates.size > 0) {
if (updates.length > 0) {
void get().updateWorktreesMeta(updates)
}
if (revealWorktreeId !== null) {
+13
View File
@@ -120,3 +120,16 @@ describe('execution host identity', () => {
)
})
})
describe('execution host id delimiter invariant', () => {
it('rejects an unencoded pipe so a crafted id cannot rebind a worktree identity alias', () => {
// composeWorktreeHostIdentity splits at the first `|`, so `ssh:a|b` would resolve as `ssh:a`.
expect(parseExecutionHostId('ssh:a|b')).toBeNull()
expect(parseExecutionHostId('runtime:a|b')).toBeNull()
expect(parseExecutionHostId(toSshExecutionHostId('a|b'))).toEqual({
kind: 'ssh',
id: 'ssh:a%7Cb',
targetId: 'a|b'
})
})
})
+8
View File
@@ -81,6 +81,11 @@ export function parseExecutionHostId(value: string | null | undefined): ParsedEx
if (!encoded) {
return null
}
// `|` must stay out of a host id: composeWorktreeHostIdentity uses it as its delimiter and
// splits at the first one, so an unencoded pipe would rebind an alias to a different host.
if (encoded.includes('|')) {
return null
}
try {
const targetId = decodeURIComponent(encoded)
return targetId ? { kind: 'ssh', id: `ssh:${encoded}`, targetId } : null
@@ -93,6 +98,9 @@ export function parseExecutionHostId(value: string | null | undefined): ParsedEx
if (!encoded) {
return null
}
if (encoded.includes('|')) {
return null
}
try {
const environmentId = decodeURIComponent(encoded)
return environmentId ? { kind: 'runtime', id: `runtime:${encoded}`, environmentId } : null
@@ -0,0 +1,176 @@
import { join, resolve } from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import type { parseAst as ParseAstFn } from 'vite' with { 'resolution-mode': 'import' }
import { scanSourceTree } from './source-scan/source-tree-scan'
// Dynamic import: vite is ESM-only and this file typechecks under tsconfig.tc.cli.json's node16/CJS.
let parseAst: typeof ParseAstFn
beforeAll(async () => {
;({ parseAst } = await import('vite'))
})
/**
* Ban `export let fn = <function>` tree-wide: rolldown const-folds the
* initializer into call sites and drops the setter's reassignment, so the
* bridged closure silently never runs in the built app (dev is fine). Use a
* module-local null-initialized impl var behind exported wrapper functions
* instead (see src/main/ipc/pty/delivery/debug.ts); null-initialized
* `export let` slots compile correctly and stay allowed. Detection uses
* vite/rolldown's own oxc parser so this test sees exactly what the bundler sees.
*/
type AstNode = {
type: string
start?: number
expression?: AstNode
expressions?: AstNode[]
declaration?: AstNode
kind?: string
declarations?: {
id?: { name?: string }
init?: AstNode | null
start?: number
}[]
specifiers?: { local?: { name?: string } }[]
}
/** Casts, parens, `!`, and comma sequences around the function don't change what rolldown folds. */
function unwrapInitializer(node: AstNode): AstNode {
let current = node
for (;;) {
if (
current.expression &&
[
'ParenthesizedExpression',
'TSAsExpression',
'TSSatisfiesExpression',
'TSTypeAssertion',
'TSNonNullExpression'
].includes(current.type)
) {
current = current.expression
continue
}
// A sequence evaluates to its last expression — that's what gets folded.
const sequenceTail =
current.type === 'SequenceExpression' ? current.expressions?.at(-1) : undefined
if (sequenceTail) {
current = sequenceTail
continue
}
return current
}
}
export type FunctionInitializedExportLet = { name: string; line: number }
export function findFunctionInitializedExportLets(
fileName: string,
sourceText: string
): FunctionInitializedExportLet[] {
// Cheap prefilter: full parses only for files that could contain the pattern.
if (!/\bexport\b/.test(sourceText) || !/\blet\b/.test(sourceText)) {
return []
}
const lineOf = (offset: number): number => sourceText.slice(0, offset).split('\n').length
const program = parseAst(sourceText, {
lang: fileName.endsWith('.tsx') ? 'tsx' : 'ts'
}) as unknown as { body: AstNode[] }
const functionLets: (FunctionInitializedExportLet & { exported: boolean })[] = []
const exportedNames = new Set<string>()
const collectLets = (declaration: AstNode | undefined, exported: boolean): void => {
if (declaration?.type !== 'VariableDeclaration' || declaration.kind !== 'let') {
return
}
for (const declarator of declaration.declarations ?? []) {
const initializer = declarator.init && unwrapInitializer(declarator.init)
if (
initializer &&
['ArrowFunctionExpression', 'FunctionExpression'].includes(initializer.type)
) {
functionLets.push({
name: declarator.id?.name ?? '<destructured>',
line: lineOf(declarator.start ?? 0),
exported
})
}
}
}
for (const statement of program.body) {
if (statement.type === 'ExportNamedDeclaration') {
collectLets(statement.declaration, true)
// `let fn = () => {}; export { fn }` is the same live binding — same hazard.
for (const specifier of statement.specifiers ?? []) {
if (specifier.local?.name) {
exportedNames.add(specifier.local.name)
}
}
continue
}
collectLets(statement, false)
}
return functionLets
.filter((candidate) => candidate.exported || exportedNames.has(candidate.name))
.map(({ name, line }) => ({ name, line }))
}
describe('function-initialized export let ban', () => {
it('flags the shapes rolldown miscompiles', () => {
const flagged = [
'export let f = () => {}',
'export let f = function () {}',
'export let f = async () => {}',
'export let f = (() => {}) as () => void',
'export let f = (() => {})!',
'export let f = (0, () => {})',
'export let f: (id: string) => void = (_id) => {}',
'let f = () => {}\nexport { f }',
'let f = () => {}\nexport { f as g }'
]
for (const source of flagged) {
expect(findFunctionInitializedExportLets('a.ts', source), source).toHaveLength(1)
}
})
it('reports the declaration line', () => {
const source = 'const a = 1\n\nexport let f = () => {}'
expect(findFunctionInitializedExportLets('a.ts', source)).toEqual([{ name: 'f', line: 3 }])
})
it('allows the safe shapes', () => {
const allowed = [
'export let f: (() => void) | null = null',
'export const f = () => {}',
'let f = () => {}',
'let f: (() => void) | null = null\nexport { f }',
'export let n = 0',
'export let f'
]
for (const source of allowed) {
expect(findFunctionInitializedExportLets('a.ts', source), source).toEqual([])
}
})
const repoRoot = resolve(__dirname, '..', '..')
// Tests are not rolldown-bundled (scanSourceTree excludes them by default);
// several drive the banned shape on purpose.
const files = scanSourceTree(join(repoRoot, 'src'))
it('scans a plausible number of files', () => {
// A broken root or extension list would make the guard silently vacuous.
expect(files.length).toBeGreaterThan(500)
})
it('has no function-initialized export let in shipped code', () => {
const offenders = files.flatMap(({ relativePath, source }) =>
findFunctionInitializedExportLets(relativePath, source).map(
(offender) => `src/${relativePath}:${offender.line} (${offender.name})`
)
)
expect(
offenders,
'Rolldown miscompiles `export let fn = <function>` bridges: the initializer is folded into call sites and the setter reassignment is dropped in the built app. Use a module-local null-initialized impl var behind an exported wrapper function instead — see src/main/ipc/pty/delivery/debug.ts.'
).toEqual([])
})
})
+4
View File
@@ -77,6 +77,10 @@ export type PersistedState = {
/** Per paired device last tab selection by worktree; keeps mobile navigation across host restarts. */
mobileClientTabSelectionsByDeviceId?: PersistedMobileClientTabSelections
worktreeMeta: Record<string, WorktreeMeta>
/** Canonical host/instance metadata; optional for legacy profiles. */
worktreeMetaByIdentity?: Record<string, WorktreeMeta>
/** Host-qualified locator aliases to canonical identity keys. */
worktreeIdentityAliases?: Record<string, string[]>
worktreeLineageById: Record<string, WorktreeLineage>
workspaceLineageByChildKey: Record<WorkspaceKey, WorkspaceLineage>
settings: GlobalSettings
+30
View File
@@ -0,0 +1,30 @@
import { getRepoExecutionHostId } from './execution-host'
import type { ProjectHostSetup } from './project-types'
import type { Repo } from './repo-types'
import type { WorktreeMeta } from './worktree/meta-types'
import { projectHostSetupProjectionFromRepos } from './project-host-setup-projection'
export function getProjectHostSetupForRepo(
setups: readonly ProjectHostSetup[],
repo: Repo
): ProjectHostSetup {
// A repo id can exist on multiple hosts; the host-qualified setup is authoritative.
const executionHostId = getRepoExecutionHostId(repo)
return (
setups.find((setup) => setup.repoId === repo.id && setup.hostId === executionHostId) ??
setups.find((setup) => setup.repoId === repo.id) ??
projectHostSetupProjectionFromRepos([repo]).setups[0]
)
}
export function getProjectHostSetupWorktreeMeta(
setups: readonly ProjectHostSetup[],
repo: Repo
): Pick<WorktreeMeta, 'projectId' | 'hostId' | 'projectHostSetupId'> {
const setup = getProjectHostSetupForRepo(setups, repo)
return {
projectId: setup.projectId,
hostId: setup.hostId,
projectHostSetupId: setup.id
}
}
@@ -2,11 +2,11 @@ import { describe, expect, it } from 'vitest'
import {
projectHostSetupProjectionFromRepos,
getProjectHostSetupsForProject,
getProjectHostSetupWorktreeMeta,
isGitHubBackedRepo,
getProjectIdForProviderIdentity,
isProjectRemoteIdentityPending
} from './project-host-setup-projection'
import { getProjectHostSetupWorktreeMeta } from './project-host-setup-lookup'
import type { Repo } from './repo-types'
function repo(overrides: Partial<Repo> & Pick<Repo, 'id' | 'path' | 'displayName'>): Repo {
@@ -664,3 +664,23 @@ describe('derived project identity stability', () => {
expect(projection.projects.map((project) => project.id)).toEqual(['github:acme/app'])
})
})
describe('getProjectHostSetupWorktreeMeta host selection', () => {
it("picks the setup for the repo's own execution host when a repoId spans two hosts", () => {
const sshRepo = repo({
id: 'repo-shared',
path: '/remote/orca',
displayName: 'orca',
connectionId: 'build-box'
})
// Local first in the array: a repoId-only match would stamp the wrong host durably.
const setups = [
...projectHostSetupProjectionFromRepos([
repo({ id: 'repo-shared', path: '/local/orca', displayName: 'orca' })
]).setups,
...projectHostSetupProjectionFromRepos([sshRepo]).setups
]
expect(getProjectHostSetupWorktreeMeta(setups, sshRepo).hostId).toBe('ssh:build-box')
})
})
@@ -7,7 +7,6 @@ import { normalizeGitHubRemoteHost } from './git-remote-host-alias'
import { githubRepoIdentityKey, isDefaultGitHubHost } from './github/repository-identity-key'
import type { Project, ProjectHostSetup, ProjectProviderIdentity } from './project-types'
import type { Repo } from './repo-types'
import type { WorktreeMeta } from './worktree/meta-types'
type ProjectAccumulator = {
project: Project
@@ -337,25 +336,3 @@ export function getProjectHostSetupsForProject(
): readonly ProjectHostSetup[] {
return setups.filter((setup) => setup.projectId === projectId)
}
export function getProjectHostSetupForRepo(
setups: readonly ProjectHostSetup[],
repo: Repo
): ProjectHostSetup {
return (
setups.find((setup) => setup.repoId === repo.id) ??
projectHostSetupProjectionFromRepos([repo]).setups[0]
)
}
export function getProjectHostSetupWorktreeMeta(
setups: readonly ProjectHostSetup[],
repo: Repo
): Pick<WorktreeMeta, 'projectId' | 'hostId' | 'projectHostSetupId'> {
const setup = getProjectHostSetupForRepo(setups, repo)
return {
projectId: setup.projectId,
hostId: setup.hostId,
projectHostSetupId: setup.id
}
}
+5 -1
View File
@@ -18,7 +18,11 @@ const IGNORED_DIRECTORIES = new Set(['node_modules', 'dist', 'out', 'build', '.g
export function isTestFile(relativePath: string): boolean {
return (
/\.(?:test|spec)\.tsx?$/.test(relativePath) ||
/(?:test-harness|test-utils|test-setup|test-fixture|repro)/.test(relativePath) ||
// `repro` must be a whole token: a bare substring exempted the shipped
// windows-terminal-capability-reprobe.ts from every guard using this walk.
/(?:test-harness|test-utils|test-setup|test-fixture|\brepro\b|reproduction)/.test(
relativePath
) ||
relativePath.includes('/__tests__/')
)
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { canonicalWorktreeIdentity, type WorktreeIdentityRef } from './identity'
describe('canonical worktree identity', () => {
const local: WorktreeIdentityRef = {
worktreeId: 'repo-1::/workspace/feature',
executionHostId: 'local',
instanceId: '11111111-1111-4111-8111-111111111111'
}
const remote: WorktreeIdentityRef = {
...local,
executionHostId: 'ssh:build-box'
}
it('separates same locator and instance across execution hosts', () => {
expect(canonicalWorktreeIdentity(local)).not.toBe(canonicalWorktreeIdentity(remote))
})
it('is stable when only the path locator changes', () => {
expect(
canonicalWorktreeIdentity({ ...local, worktreeId: 'repo-1::/workspace/renamed-feature' })
).toBe(canonicalWorktreeIdentity(local))
})
it('does not use the display name as identity input', () => {
expect(canonicalWorktreeIdentity(local)).toBe(canonicalWorktreeIdentity({ ...local }))
})
})
+29
View File
@@ -0,0 +1,29 @@
import type { ExecutionHostId } from '../execution-host'
/** Stable fields needed to identify one checkout occupant. */
export type WorktreeIdentityRef = {
/** Existing repo/path locator; mutable when the folder moves. */
worktreeId: string
/** Host that owns execution for this checkout. */
executionHostId: ExecutionHostId
/** Durable identity for this checkout occupant. */
instanceId: string
}
export type WorktreeIdentity = Omit<WorktreeIdentityRef, 'worktreeId'> & {
key: string
}
/**
* Canonical exact identity for one worktree occupant.
* The locator is deliberately excluded so a folder rename preserves identity.
*/
export function canonicalWorktreeIdentity(ref: WorktreeIdentityRef): string {
return `wt2:${encodeURIComponent(ref.executionHostId)}:${encodeURIComponent(ref.instanceId)}`
}
export function createWorktreeIdentity(ref: WorktreeIdentityRef): WorktreeIdentity {
return {
key: canonicalWorktreeIdentity(ref),
executionHostId: ref.executionHostId,
instanceId: ref.instanceId
}
}
+3
View File
@@ -5,6 +5,7 @@ import type { TuiAgent } from '../tui-agent'
import type { DiffComment, MobileDiffReviewState } from '../diff-comment-types'
import type { EphemeralVmCheckoutMode } from '../orca-yaml-hook-types'
import type { BuiltInWorktreeVisibilitySourceId } from '../repo-types'
import type { WorktreeIdentity } from './identity'
export type WorkspaceLinkedItem = {
provider: 'github' | 'gitlab' | 'linear' | 'jira'
@@ -60,6 +61,8 @@ export type WorkspaceStatusDefinition = {
export type Worktree = {
id: string // `${repoId}::${path}`
instanceId?: string
/** Immutable host/instance identity. Optional while legacy rows migrate. */
identity?: WorktreeIdentity
repoId: string
/** Durable project identity. Optional while legacy repo-only workspaces migrate. */
projectId?: string