mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* fix(worktree): block removal when the archive hook fails A repo's orca.yaml archive hook is the user's last chance to save work off a checkout Orca is about to delete. A failed hook was logged as advisory and stepped over, so the removal went ahead with nothing archived — and the caller could still be told it succeeded. The hook is now a blocking precondition, evaluated while the checkout, its Git registration, its agents and Orca's ownership evidence are all still intact: it sits ahead of the registration re-read, the lock/dirty preflights, stopPtys() and removeWorktree in every orchestrator that runs it. Failure is typed (worktree_archive_hook_failed) and carries the worktree path, outcome, exit code where one was observed, and the hook's output. unverifiable stays distinct from exited, so loss of contact is never read as a pass. The waiver rides its own field at every layer and is never implied by --force, which already carries the PTY-stop waiver; when used, the waived failure comes back on result.archiveHookOverride rather than being swallowed. worktree.archive-failure-blocking.v1 is advertised so an integration can tell "accepts --run-hooks" from "safely propagates a failing hook" without risking the data loss to find out. The runtime's SSH path cannot run a hook at all, so rather than delete with the archive step silently skipped it refuses — waivable like every other refusal here. #18563 retires that gate by making the path run the hook for real. Stacked on #20559, which makes a timed-out hook report honestly; without it a hook that traps SIGTERM and exits 0 would defeat this gate. Fixes #19334 * fix(worktree): close the skip-confirm dead end and the client/hook timeout gap Four review findings on the gate. A retry from the failure toast could fail for a DIFFERENT reason than the one the user had just answered, and that second failure got a bare toast with no buttons. With skipDeleteWorktreeConfirm set, the delete helpers pass no force, so waiving a failed archive hook on a dirty checkout landed on the dirty preflight and stopped there. Retry failures now re-enter the same failure toast, so every retry stays as actionable as the first attempt. Third instance of this class. The renderer gave worktree.rm a 60s budget while an archive hook may run for 120s. A hook that took 90s and succeeded timed the client out and reported failure while the host went on to delete — telling the user their delete failed and their checkout was gone. The budget is now derived from the hook's, and only when a hook can run. The SSH fail-open is logged rather than silent, and the capability's doc comment scopes what it claims: a hook that RUNS and fails cannot delete the checkout; it is not a promise the hook was found. The SSH owner-resolution test now reads a real remote orca.yaml through a stubbed provider and asserts the returned script is the remote one. It previously stopped at the lookup key, which is the coverage that let this path break twice. It fails against the row-only resolution. * fix(worktree): name a signalled hook exit, and state why prunable cleanup skips the gate Two things the rebase onto #20617 and #20576 surfaced, both found by rerunning the real-repo harness rather than by reading the diff. - #20617 added a registration-cleanup branch that returns before the archive gate. That ordering is correct — both of its arms describe a row with no checkout behind it, so there is nothing to archive and running the hook would fail on the missing cwd — but the gate's ordering invariant is documented, so the exception should be too. - A signalled hook reported `Command failed with exit code null.`, which reads as a reporting glitch rather than the `unverifiable` verdict it is about to produce. It now says the command was terminated without reporting an exit code. Introduced by #20576; the withheld `exitCode` itself was always right. Fixes #19334
197 lines
7.6 KiB
TypeScript
197 lines
7.6 KiB
TypeScript
import type {
|
|
ForgetRemovedWorktreesForExecutionHostArgs,
|
|
ForgetRemovedWorktreesForExecutionHostResult,
|
|
HostQualifiedDetectedWorktreeResult,
|
|
HostQualifiedKnownWorktreeResult,
|
|
LegacyDetectedWorktreeRequest,
|
|
ListDetectedWorktreesArgs,
|
|
ListKnownWorktreesForExecutionHostArgs,
|
|
ProviderRequestId
|
|
} from '../../shared/detected-worktree-provider-contract'
|
|
import type { ExecutionHostId } from '../../shared/execution-host'
|
|
import type { RetiredNameRegistry } from '../../shared/worktree/retired-name-registry'
|
|
import type {
|
|
FolderWorkspacePathStatus,
|
|
FolderWorkspacePathStatusRequest
|
|
} from '../../shared/folder-workspace-path-status'
|
|
import type {
|
|
HostLineageSnapshot,
|
|
ListDesktopLineageForHostArgs
|
|
} from '../../shared/host-lineage-contract'
|
|
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
|
|
import type {
|
|
WorktreeBaseStatusEvent,
|
|
WorktreeRemoteBranchConflictEvent
|
|
} from '../../shared/worktree/base-ref-drift-types'
|
|
import type {
|
|
AdoptProvisionedRootArgs,
|
|
CreateWorktreeArgs,
|
|
CreateWorktreeResult,
|
|
ForceDeleteWorktreeBranchResult,
|
|
RemoveWorktreeResult,
|
|
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,
|
|
Worktree,
|
|
WorktreeHeadIdentity
|
|
} from '../../shared/worktree/types'
|
|
|
|
export type WorktreeApi = {
|
|
list: (args: { repoId: string }) => Promise<Worktree[]>
|
|
/** Generated names already spent in this repo, including deleted workspaces. Name suggestions
|
|
* exclude these so a recreated workspace never lands on a prior occupant's path. Compacted: a
|
|
* fully spent tier is reported as the watermark rather than as its 552 names. */
|
|
listRetiredNames: (args: { repoId: string }) => Promise<RetiredNameRegistry>
|
|
listDetected: {
|
|
(
|
|
args: ListDetectedWorktreesArgs
|
|
): Promise<HostQualifiedDetectedWorktreeResult | DetectedWorktreeListResult>
|
|
(args: LegacyDetectedWorktreeRequest): Promise<DetectedWorktreeListResult>
|
|
}
|
|
listKnownForExecutionHost?: (
|
|
args: ListKnownWorktreesForExecutionHostArgs
|
|
) => Promise<HostQualifiedKnownWorktreeResult>
|
|
/** Retires the persisted metadata an authoritative scan proved gone, so it stops feeding the read above. */
|
|
forgetRemovedForExecutionHost?: (
|
|
args: ForgetRemovedWorktreesForExecutionHostArgs
|
|
) => Promise<ForgetRemovedWorktreesForExecutionHostResult>
|
|
cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise<void>
|
|
listAll: () => Promise<Worktree[]>
|
|
create: (args: CreateWorktreeArgs) => Promise<CreateWorktreeResult>
|
|
adoptProvisionedRoot: (args: AdoptProvisionedRootArgs) => Promise<CreateWorktreeResult>
|
|
/** Two-phase progress for a background `create`, correlated by `creationId`. The remote/runtime
|
|
* create path emits nothing, so the surface falls back to an indeterminate spinner. */
|
|
onCreateProgress: (
|
|
callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void
|
|
) => () => void
|
|
prefetchCreateBase: (args: { repoId: string; baseBranch?: string }) => Promise<void>
|
|
resolvePrBase: (args: {
|
|
repoId: string
|
|
prNumber: number
|
|
headRefName?: string
|
|
baseRefName?: string
|
|
isCrossRepository?: boolean
|
|
}) => Promise<GitHubPrStartPoint | { error: string }>
|
|
/** GitLab parallel of resolvePrBase. For same-project MRs returns
|
|
* `<remote>/<source_branch>`; for fork MRs fetches
|
|
* refs/merge-requests/<iid>/head and returns the SHA. */
|
|
resolveMrBase: (args: {
|
|
repoId: string
|
|
mrIid: number
|
|
sourceBranch?: string
|
|
targetBranch?: string
|
|
isCrossRepository?: boolean
|
|
}) => Promise<
|
|
{ baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } | { error: string }
|
|
>
|
|
remove: (args: {
|
|
worktreeId: string
|
|
hostId?: ExecutionHostId
|
|
force?: boolean
|
|
// Why (#11960): distinct from `force`, which the plain Delete confirmation
|
|
// already sets to skip the dirty-file prompt. Only an explicit Force Delete
|
|
// may waive the proof that every PTY stopped.
|
|
allowUnverifiedPtyStop?: boolean
|
|
skipArchive?: boolean
|
|
// Why (#19334): distinct from `skipArchive` (never runs the hook) and never implied by
|
|
// `force` — this waives a hook that ran and FAILED.
|
|
allowFailedArchiveHook?: boolean
|
|
snapshotPruneBatchId?: string
|
|
}) => Promise<RemoveWorktreeResult>
|
|
// Forget a workspace from Orca only (no remote Git/FS work) — for workspaces pinned to a removed/disconnected SSH host.
|
|
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>
|
|
listLineage: () => Promise<{
|
|
lineage: Record<string, WorktreeLineage>
|
|
workspaceLineage?: Record<string, WorkspaceLineage>
|
|
}>
|
|
listLineageForHost?: (args: ListDesktopLineageForHostArgs) => Promise<HostLineageSnapshot>
|
|
updateLineage: (args: {
|
|
worktreeId: string
|
|
parentWorktreeId?: string
|
|
noParent?: boolean
|
|
}) => Promise<WorktreeLineage | null>
|
|
persistSortOrder: (args: { orderedIds: string[] }) => Promise<void>
|
|
/** Full CLI output of the last branch auto-rename generation failure, held
|
|
* in main memory only — null after a restart or once the failure clears. */
|
|
getBranchRenameFailureOutput: (args: { worktreeId: string }) => Promise<string | null>
|
|
onChanged: (callback: (data: { repoId: string }) => void) => () => void
|
|
onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void) => () => void
|
|
onHeadIdentitiesChanged: (
|
|
callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => 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>
|
|
remove: (args: { repoId: string; presetId: string }) => Promise<void>
|
|
onChanged: (callback: (data: { repoId: string }) => void) => () => void
|
|
}
|