Files
orca/src/main/runtime/runtime-managed-worktree-metadata.ts
T
BingZandm4air b4aec8a896 fix(sidebar): stop a removed folder workspace from leaving a blank row (#26376)
* fix(sidebar): stop a removed folder workspace from leaving a blank row

A folder-project workspace exists only as its worktreeMeta row. When it is
deleted while a terminal is still bound, the renderer keeps the row until its
teardown runs, and a PTY reattach in that window stamps lastActivityAt through
worktrees:updateMeta. setWorktreeMeta then minted a default row with an empty
name, which the sidebar listed under the project name and which came back after
every delete. The metadata IPC now refuses to create a row for a folder
workspace instance that no longer exists.

Fixes #22712

Claude-Session: https://claude.ai/code/session_01XZXEdbC4RKp288k2gBzLUW

* fix(sidebar): also refuse runtime writes to a removed folder workspace

Move the existence check into a shared predicate that matches any same-id
folder owner, so an owner at a different path on another host cannot bypass
it, and apply it to the runtime's worktree.set path, where a resolution
snapshot taken before the removal still returns the workspace.

Claude-Session: https://claude.ai/code/session_01XZXEdbC4RKp288k2gBzLUW

* test: type the removed-folder-workspace fixture meta for the anti-slop lint

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 05:39:31 -07:00

145 lines
5.4 KiB
TypeScript

import type { GitPushTarget, Worktree } from '../../shared/worktree/types'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import { worktreeWorkspaceKey } from '../../shared/workspace-scope'
import { splitWorktreeId } from '../../shared/worktree/id'
import { planWorktreeSortOrderUpdates } from '../../shared/worktree/sort-order-update'
import { stripOrcaProvenanceMetaUpdates } from '../worktree-removal-safety'
import { isRemovedFolderWorkspaceInstance } from '../folder-workspace-meta-presence'
import type { RuntimeStore } from './runtime-store-contract'
import { RuntimeLineageError } from './runtime-worktree-lineage-resolution'
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
type Updates = Omit<Partial<WorktreeMeta>, 'pushTarget'> & {
pushTarget?: GitPushTarget | null
lineage?: { parentWorktree?: string; noParent?: boolean }
}
type Ports = {
resolveWorktree: (selector: string) => Promise<ResolvedWorktree>
validateParent: (worktree: ResolvedWorktree, parent: ResolvedWorktree) => void
invalidateResolved: () => void
invalidateScan: (repoId: string) => void
notifyChanged: (repoId: string) => void
showWorktree: (selector: string) => Promise<Worktree>
}
export async function updateRuntimeManagedWorktreeMetadata(args: {
selector: string
updates: Updates
store: RuntimeStore
ports: Ports
}): Promise<Worktree> {
const worktree = await args.ports.resolveWorktree(args.selector)
const { lineage, ...metaUpdates } = args.updates
if (lineage?.parentWorktree) {
args.ports.invalidateResolved()
args.ports.invalidateScan(worktree.repoId)
}
const clearPushTarget =
Object.hasOwn(metaUpdates, 'pushTarget') && metaUpdates.pushTarget === null
const normalized: Partial<WorktreeMeta> = clearPushTarget
? { ...metaUpdates, pushTarget: undefined }
: (metaUpdates as Partial<WorktreeMeta>)
const persisted: Partial<WorktreeMeta> = omitUndefinedProperties(
normalized.displayName !== undefined
? {
...normalized,
pendingFirstAgentMessageRename: false,
firstAgentMessageRenameError: null
}
: normalized
)
if (clearPushTarget) {
persisted.pushTarget = undefined
}
if (lineage?.noParent === true) {
args.store.removeWorktreeLineage?.(worktree.id)
args.store.removeWorkspaceLineage?.(worktreeWorkspaceKey(worktree.id))
} else if (lineage?.parentWorktree) {
const parent = await args.ports.resolveWorktree(lineage.parentWorktree)
args.ports.validateParent(worktree, parent)
if (!worktree.instanceId || !parent.instanceId) {
throw new RuntimeLineageError(
'LINEAGE_PARENT_CONTEXT_MISSING',
'Worktree instance identity was unavailable.'
)
}
if (!args.store.setWorktreeLineage) {
throw new RuntimeLineageError(
'LINEAGE_PARENT_CONTEXT_MISSING',
'Worktree lineage storage was unavailable.'
)
}
const createdAt = Date.now()
args.store.setWorktreeLineage(worktree.id, {
worktreeId: worktree.id,
worktreeInstanceId: worktree.instanceId,
parentWorktreeId: parent.id,
parentWorktreeInstanceId: parent.instanceId,
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt
})
args.store.setWorkspaceLineage?.({
childWorkspaceKey: worktreeWorkspaceKey(worktree.id),
childInstanceId: worktree.instanceId,
parentWorkspaceKey: worktreeWorkspaceKey(parent.id),
parentInstanceId: parent.instanceId,
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt
})
}
const metadataUpdates = stripOrcaProvenanceMetaUpdates(persisted)
const executionHostId = worktree.identity?.executionHostId ?? worktree.hostId
// Why: a resolution snapshot taken before a removal still returns the workspace (#22712).
if (isRemovedFolderWorkspaceInstance(args.store, worktree.id, executionHostId)) {
throw new Error('selector_not_found')
}
if (executionHostId && args.store.setWorktreeMetaForHost) {
args.store.setWorktreeMetaForHost(worktree.id, executionHostId, metadataUpdates)
} else {
args.store.setWorktreeMeta(worktree.id, metadataUpdates)
}
// Why: CLI callers need an explicit push for metadata changed outside the renderer's optimistic update path.
args.ports.invalidateResolved()
args.ports.notifyChanged(worktree.repoId)
return args.ports.showWorktree(`id:${worktree.id}`)
}
export function persistRuntimeManagedWorktreeSortOrder(args: {
orderedIds: string[]
store: RuntimeStore
invalidateResolved: () => void
notifyChanged: (repoId: string) => void
}): { updated: number } {
const updates = planWorktreeSortOrderUpdates(
args.orderedIds,
(worktreeId) => args.store.getWorktreeMeta(worktreeId),
Date.now()
)
for (const update of updates) {
args.store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder })
}
if (updates.length === 0) {
return { updated: 0 }
}
args.invalidateResolved()
const repoIds = new Set(
updates.flatMap(({ worktreeId }) => {
const parsed = splitWorktreeId(worktreeId)
return parsed ? [parsed.repoId] : []
})
)
for (const repoId of repoIds) {
args.notifyChanged(repoId)
}
return { updated: updates.length }
}
function omitUndefinedProperties<T extends Record<string, unknown>>(value: T): Partial<T> {
return Object.fromEntries(
Object.entries(value).filter(([, entry]) => entry !== undefined)
) as Partial<T>
}