Files
orca/src/preload/api/worktree-api.ts
T
Brennan Benson fd20d34362 Save the issue title on GitHub issue links made without one (#40853)
* fix(github): title GitHub issue links that were saved without one

A workspace linked to a GitHub issue from the CLI (`--issue N`), a phone, the paired web
client or a typed number kept only the number, so search could match its issue title only
while a client happened to hold that issue. The host that owns the workspace now reads each
newly linked untitled issue once, after the write returns, the way its card would, and saves
the title on the link. It keeps the current review selection and never resurrects a removed link.

* fix(github): bound and publish issue-title fills like other metadata writes

Skip writes that leave links untouched before normalizing, check a queued link is still wanted
before reading it, publish filled titles once per repo per 2 s window, refresh the resolved rows
remote clients read, never wake a stopped WSL distro for a background read, keep a selected
review's PR suppression, and keep a failing write observer from failing a saved write.

* fix(github): keep link-title fills off unrelated writes and out of per-title refreshes

Ignore linkedWorkItem in the unchanged-links check (it is rebuilt on every write and always
titled), publish filled titles at once on a quiet host and at most once per repo per 30 s
during a burst on a timer, and only lift a PR suppression when the selected PR changes, so
restating the current PR (as a title fill does) keeps a hidden branch PR hidden.

* perf(github): publish filled link titles as addressed workspace metadata

Instead of a whole-repo worktreesChanged refresh per burst window, the host publishes one
addressed workspaceMetadata set record {hostId, epoch, seq, id, row} for the workspace whose
link was titled: to its desktop window over IPC and to paired clients as a runtime client event.
Clients patch that one row (Cmd-J reads it) and drop replayed records; older clients ignore the
new event and see the title on their next listing.

* fix(github): agreed workspaceMetadata record shape, per-id seqs and late-listing refill

Records name their kind and domain, clients keep the last applied seq per workspace for each
producer epoch (dropped when the epoch changes), and a listing that read metadata before the
title write cannot drop the title: recorded titles refill rows that still hold the same untitled
link for 60 s, never restoring a removed link. The IPC channel is marked as interim transport.

* fix(github): title-only, host-addressed application of workspaceMetadata records

Clients fill the record's issue titles into the named row's still-untitled links and never take
its links over the row's current ones, so a record that arrives after an unlink cannot restore
the issue. A paired host's records reach the row of their own execution host (an SSH twin is a
different row), and go through the bridge's connection-generation check like other host events.

* test(github): type the metadata-record test helper as AppState

* fix(github): keep recorded titles until expiry, refill only changed repos, retire with the pairing

A record that beats the listing adding its link keeps its titles for the full window; the refill
runs only for repos whose listing changed, indexes that repo's rows once and skips rows already
checked; per-workspace seqs live in the expiring entries; a re-pair or reconnect of the producing
environment retires its titles.

* fix(github): read the listing through the workspace record selector

* fix(github): keep link titles until their producer changes; fall back to a listing on eviction

Recorded issue titles no longer expire on a timer (a slow listing could outlast it): they live until
the producer's epoch, pairing or connection changes, refilling only present untitled links. When
more than the cap are in flight the oldest is evicted and its repo is listed afresh, which carries
the saved title. The refill runs in updater form so it never merges a stale store snapshot.

* fix(github): sequenced listings replace the retained-title refill

Per the re-plan ruling: the store keeps one workspaceMetadata cursor per process; a title record takes
the next seq in the same turn as its write, and each listing records the cursor when it reads metadata.
Clients note each record's seq per producer and repo; a listing at or past it ends the obligation, an
older one keeps titles the rows already show and lists the repo afresh (a new invocation, never joining
the stale one), a record whose row or link isn't listed yet asks for a fresh listing, and a seq gap
lists every repo of that producer. The retained-title table, its watcher, TTL and cap are gone.

* test(github): stamp capture, gap, restart and recovery coverage for workspace metadata listings

Also retire an environment's earlier coverage bookkeeping when it is re-paired or reconnects.

* fix(space): keep issue-title coverage per repo and host until a newer listing lands

Coverage now keeps each projection's newest record seq after its covering listing settles, so an older listing that finishes later still keeps shown titles. Projections are keyed by repo and host, paired producers keep their stream cursor, retired pairings and unlisted projections are dropped, every listing served by this host carries its metadata stamp, and a record with any unlisted titled link asks for a listing.


* fix(space): re-list after any stale listing and drop retired metadata producers

A stale listing now always asks for one fresh listing, so a link it dropped comes back and a failed scan can no longer end the wait. Removed environments drop their producer state, the producer's repo list is built only on a gap or a first-seen projection, and recovery listings skip the lineage refresh.


* perf(space): filter current rows only for listings that keep titles


* fix(space): forget removed repos on a metadata gap; keep titles for another producer run's listing


* fix(palette): search a linked issue by its saved title before its card renders

The issue's search entry took its title only from the issue cache, which fills when the card renders, and it shadowed the saved link's titled entry under the same id. It now falls back to the link's saved title.


* perf(palette): read a row's links once per palette document


* fix(palette): give each saved link its own search unit

Saved-link evidence was meant to carry an `attachment:` id but the override set an unused field, so a link shared the `issue:N` unit id with the legacy issue field and with same-number links from other repos, and only the first (often untitled) survived. The id now applies to the unit and its fields, so every saved link's title is searchable. Replaces the cache-title fallback.


* fix(space): keep the title queue draining past a failed link; encode environment ids in coverage keys


---------
2026-11-10 16:38:44 -08:00

217 lines
8.8 KiB
TypeScript

import type { WorkspaceMetadataSet } from '../../shared/worktree/workspace-metadata-set'
import type { SparsePresetFailure } from '../../shared/worktree/sparse-preset-failure'
import type {
ForgetRemovedWorktreesForExecutionHostArgs,
ForgetRemovedWorktreesForExecutionHostResult,
HostQualifiedDetectedWorktreeResult,
HostQualifiedKnownWorktreeResult,
LegacyDetectedWorktreeRequest,
ListDetectedWorktreesArgs,
ListKnownWorktreesForExecutionHostArgs,
ProviderRequestId
} from '../../shared/detected-worktree-provider-contract'
import type { NestedWorktreeRemovalApproval } from '../../shared/worktree/nested-removal'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { WorkspaceSleepOutcome, WorkspaceSleepRequest } from '../../shared/workspace-sleep'
import type { RetiredNameRegistry } from '../../shared/worktree/retired-name-registry'
import type {
FolderWorkspacePathStatus,
FolderWorkspacePathStatusRequest
} from '../../shared/folder-workspaces/folder-workspace-path-status'
import type { FolderWorkspace } from '../../shared/folder-workspaces/folder-workspace-types'
import type {
WorktreeBaseStatusEvent,
WorktreeRemoteBranchConflictEvent
} from '../../shared/worktree/base-ref-drift-types'
import type {
AdoptProvisionedRootArgs,
CreateWorktreeArgs,
CreateWorktreeProgressEvent,
CreateWorktreeResult,
ForceDeleteWorktreeBranchResult,
RemoveWorktreeResult,
RunApprovedWorktreeSetupArgs,
RunApprovedWorktreeSetupResult,
SparsePreset
} from '../../shared/worktree/create-types'
import type { WorkspaceLineage, WorktreeLineage } from '../../shared/worktree/lineage-types'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type {
DetectedWorktreeListResult,
GitHubPrStartPoint,
GitPushTarget,
GitStatusMetadataChangedEvent,
GitWorktreeInfo,
Worktree,
WorktreeHeadIdentity
} from '../../shared/worktree/types'
export type WorktreeApi = {
/** Retired names prevent path reuse; fully spent tiers are compacted into a watermark. */
listRetiredNames: (args: { repoId: string }) => Promise<RetiredNameRegistry>
listDetected: {
(
args: ListDetectedWorktreesArgs
): Promise<HostQualifiedDetectedWorktreeResult | DetectedWorktreeListResult>
(args: LegacyDetectedWorktreeRequest): Promise<DetectedWorktreeListResult>
}
listKnownForExecutionHost?: (
args: ListKnownWorktreesForExecutionHostArgs
) => Promise<HostQualifiedKnownWorktreeResult>
/** Retire metadata only after an authoritative scan proves the workspace is gone. */
forgetRemovedForExecutionHost?: (
args: ForgetRemovedWorktreesForExecutionHostArgs
) => Promise<ForgetRemovedWorktreesForExecutionHostResult>
cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise<void>
/** User act: scan a local repo inside its WSL distro's user-act scope, which may start it. */
scanStartingWslDistro?: (args: { repoId: string }) => Promise<void>
create: (args: CreateWorktreeArgs) => Promise<CreateWorktreeResult>
adoptProvisionedRoot: (args: AdoptProvisionedRootArgs) => Promise<CreateWorktreeResult>
/** Absent on clients whose host cannot run an existing worktree's approved setup. */
runApprovedSetup?: (args: RunApprovedWorktreeSetupArgs) => Promise<RunApprovedWorktreeSetupResult>
/** Local creates report progress by creationId; remote/runtime creates emit nothing. */
onCreateProgress: (callback: (data: CreateWorktreeProgressEvent) => void) => () => void
prefetchCreateBase: (args: { repoId: string; baseBranch?: string }) => Promise<void>
resolvePrBase: (args: {
repoId: string
prNumber: number
headRefName?: string
baseRefName?: string
isCrossRepository?: boolean
/** The user creating or launching from the PR: it may start a stopped WSL distro. */
wakeWslDistro?: boolean
}) => Promise<GitHubPrStartPoint | { error: string }>
/** Same-project MRs return a remote branch; forks fetch the MR head ref and return its SHA. */
resolveMrBase: (args: {
repoId: string
mrIid: number
sourceBranch?: string
targetBranch?: string
isCrossRepository?: boolean
/** The user creating from the MR: it may start a stopped WSL distro. */
wakeWslDistro?: boolean
}) => Promise<
{ baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } | { error: string }
>
previewNestedRemoval?: (args: {
worktreeId: string
hostId?: ExecutionHostId
}) => Promise<GitWorktreeInfo[]>
sleep: (request: WorkspaceSleepRequest) => Promise<WorkspaceSleepOutcome[]>
sleepReopened: (worktreeId: string) => Promise<void>
/** True once running, so the renderer's subsequent spawn reattaches. */
resumeSleptPane: (
pane: { worktreeId: string; tabId: string; leafId: string },
automatic?: boolean
) => Promise<boolean>
/** Drops main's workspace-sleep record only while no tab holds its pane. */
retireStrandedSleepRecord: (args: { worktreeId: string; paneKey: string }) => Promise<void>
remove: (args: {
worktreeId: string
hostId?: ExecutionHostId
force?: boolean
approvedNestedWorktrees?: NestedWorktreeRemovalApproval[]
// Only explicit Force Delete waives PTY-stop proof; force merely skips dirty-file prompts.
allowUnverifiedPtyStop?: boolean
skipArchive?: boolean
// Waives a failed hook, unlike skipArchive; force does not imply this approval.
allowFailedArchiveHook?: boolean
snapshotPruneBatchId?: string
}) => Promise<RemoveWorktreeResult>
// Forget local metadata without remote Git/FS work, even when the SSH host is unavailable.
forgetLocal: (args: {
worktreeId: string
hostId?: ExecutionHostId
snapshotPruneBatchId?: string
}) => Promise<RemoveWorktreeResult>
forceDeletePreservedBranch: (args: {
worktreeId: string
branchName: string
expectedHead: string
hostId?: ExecutionHostId
}) => Promise<ForceDeleteWorktreeBranchResult>
updateMeta: (args: {
worktreeId: string
executionHostId?: ExecutionHostId
updates: Partial<WorktreeMeta>
}) => Promise<Worktree | null>
listLineage: () => Promise<{
lineage: Record<string, WorktreeLineage>
workspaceLineage?: Record<string, WorkspaceLineage>
/** Read through validCrossStoreLineage; older hosts omit it. */
crossStoreLineage?: Record<string, unknown>
/** This store's identity, which other stores' records name; absent when unknown. */
installationId?: string
}>
updateLineage: (args: {
worktreeId: string
parentWorktreeId?: string
noParent?: boolean
}) => Promise<{ lineage: WorktreeLineage | null; workspaceLineage?: WorkspaceLineage | null }>
persistSortOrder: (args: { orderedIds: string[]; hostIds?: string[] }) => Promise<void>
/** Last failure output; memory-only, cleared on restart or when the failure clears. */
getBranchRenameFailureOutput: (args: { worktreeId: string }) => Promise<string | null>
onChanged: (callback: (data: { repoId: string }) => void) => () => void
onGitStatusMetadataChanged: (
callback: (data: GitStatusMetadataChangedEvent) => void
) => () => void
onHeadIdentitiesChanged: (
callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void
) => () => void
onMetadataSet?: (callback: (set: WorkspaceMetadataSet) => void) => () => void
onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void) => () => void
onRemoteBranchConflict: (
callback: (data: WorktreeRemoteBranchConflictEvent) => void
) => () => void
}
export type FolderWorkspacesApi = {
list: () => Promise<FolderWorkspace[]>
getPathStatus: (args: FolderWorkspacePathStatusRequest) => Promise<FolderWorkspacePathStatus>
create: (args: {
projectGroupId: string
name?: string
folderPath?: string | null
connectionId?: string | null
linkedTask?: FolderWorkspace['linkedTask']
createdWithAgent?: FolderWorkspace['createdWithAgent']
pendingFirstAgentMessageRename?: boolean
}) => Promise<FolderWorkspace>
update: (args: {
folderWorkspaceId: string
updates: Partial<
Pick<
FolderWorkspace,
| 'name'
| 'folderPath'
| 'linkedTask'
| 'comment'
| 'isArchived'
| 'isUnread'
| 'isPinned'
| 'sortOrder'
| 'manualOrder'
| 'workspaceStatus'
| 'createdWithAgent'
| 'pendingFirstAgentMessageRename'
| 'firstAgentMessageRenameError'
| 'lastActivityAt'
| 'diffComments'
>
>
}) => Promise<FolderWorkspace | null>
delete: (args: { folderWorkspaceId: string }) => Promise<boolean>
}
export type SparsePresetsApi = {
list: (args: { repoId: string }) => Promise<SparsePreset[]>
save: (args: {
repoId: string
id?: string
name: string
directories: string[]
}) => Promise<SparsePreset | SparsePresetFailure>
remove: (args: { repoId: string; presetId: string }) => Promise<void>
onChanged: (callback: (data: { repoId: string }) => void) => () => void
}