mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
refactor(renderer-ui-store): clean up Phase 3-3.8 code (#26903)
Share one bounded generation map across SSH, runtime SSH, slug resolution and session-tab tracking; reuse the SSH target generation helpers; replace the removed-flag chain in SSH target cleanup with a patch accumulator; derive purge recency targets from the rows the drop already removed; share the add-repo stale guard and runtime target; share the host-setup availability rule; drop the dead sync session persister; reuse host-id constructors in the file explorer; trim remote browser stream comments. Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
+5
-26
@@ -8,21 +8,11 @@ import {
|
||||
remoteBrowserServiceUnavailableNotice
|
||||
} from './remote-browser-stream-status'
|
||||
|
||||
// Why: a runtime lacking browser.screencast.v1 will not grow it while this connection lives, so
|
||||
// retrying that failure is unbounded work with a visible error each round. Tagged rather than
|
||||
// message-matched so a reworded string cannot silently turn it back into an infinite retry.
|
||||
export const REMOTE_BROWSER_STREAM_UNSUPPORTED = 'remote_browser_stream_unsupported'
|
||||
// Why: a missing capability never appears mid-connection; tagged so a reworded message can't restore infinite retry.
|
||||
const REMOTE_BROWSER_STREAM_UNSUPPORTED = 'remote_browser_stream_unsupported'
|
||||
|
||||
// Why: the runtime answers with these when the thing the stream is anchored to is gone from the
|
||||
// host (worktree deleted, repo unregistered, capability absent). Retrying cannot bring it back, so
|
||||
// they are permanent for this connection exactly like the capability tag above. Codes come from
|
||||
// src/main/runtime/rpc/errors.ts rather than being invented here.
|
||||
// Why `selector_not_found` is NOT here: it means "I could not resolve this right now", which is
|
||||
// UNKNOWN, not proof the target is gone. Its producer is a live worktree scan behind a 1s-TTL cache,
|
||||
// and the connectionId-gated fallback that shields SSH repos from scan lag does not cover purely
|
||||
// local ones — so a slow scan can surface it transiently. Treating that as permanent would strand
|
||||
// the pane forever, which is the exact bug this file exists to prevent. The three below are
|
||||
// unambiguous: the host is telling us the thing itself no longer exists.
|
||||
// Why: host says the anchor itself is gone (codes from src/main/runtime/rpc/errors.ts); retrying can't restore it.
|
||||
// selector_not_found is deliberately excluded: a slow 1s-TTL worktree scan can emit it transiently.
|
||||
const REMOTE_BROWSER_STREAM_TARGET_GONE_CODES: ReadonlySet<string> = new Set([
|
||||
'worktree_not_found_on_server',
|
||||
'repo_not_found',
|
||||
@@ -80,18 +70,7 @@ export type RemoteBrowserStreamFailure = {
|
||||
logRawError: boolean
|
||||
}
|
||||
|
||||
// Why the message is decided here rather than at the call site: whether a failure is permanent and
|
||||
// what the user should be told are the same judgement. A permanent failure is one we classified and
|
||||
// understand, so its own message says something true and specific ("The selected runtime does not
|
||||
// support remote browser streaming."); flattening that to "Lost connection" would be vaguer and
|
||||
// wrong, since nothing was lost. Everything else carries a raw transport string written for logs,
|
||||
// so the pane speaks for itself there and the raw text is left to the caller to log.
|
||||
//
|
||||
// Note what this deliberately does NOT decide: whether the user is offered a way back. Stopping
|
||||
// automatic retries and removing the user's only recovery are different things, and conflating them
|
||||
// is what stranded the pane in the first place. `selector_not_found` already had to be walked back
|
||||
// out of the permanent set once (08260a54bf) — proof this classification can be wrong — so a
|
||||
// misjudgement here must stay recoverable by hand.
|
||||
// Why: a classified permanent failure keeps its own specific message; others get a pane-authored notice and the raw error is logged. Whether to offer reconnect is decided by status, not here.
|
||||
export function resolveRemoteBrowserStreamFailure(
|
||||
error: unknown,
|
||||
phase: 'opening' | 'restart' = 'restart'
|
||||
|
||||
+7
-19
@@ -108,9 +108,7 @@ export class RemoteBrowserStreamLifecycle {
|
||||
deps.closeMissingRemotePage(tokens.remotePage)
|
||||
return
|
||||
}
|
||||
// Why classified here too: the same condition (a host that cannot stream) reaches both this
|
||||
// path and the restart path, and it must not read as "unreachable" here and as its own
|
||||
// specific message there.
|
||||
// Why classified: a host that cannot stream must read the same here as on the restart path.
|
||||
const failure = resolveRemoteBrowserStreamFailure(error, 'opening')
|
||||
if (failure.logRawError) {
|
||||
console.warn('[browser-pane] remote browser failed to open:', error)
|
||||
@@ -177,9 +175,7 @@ export class RemoteBrowserStreamLifecycle {
|
||||
if (this.restartScheduler.isScheduled) {
|
||||
return
|
||||
}
|
||||
// Why classified rather than forwarded: this was the one failure path still putting raw
|
||||
// transport text in the UI ("Runtime environment pairing changed; refresh and try again"),
|
||||
// which is written for logs and names our internals. The other two paths already classify.
|
||||
// Why classified: raw transport text (e.g. "pairing changed") is for logs, not the pane.
|
||||
const failure = resolveRemoteBrowserStreamFailure(error)
|
||||
if (failure.logRawError) {
|
||||
console.warn('[browser-pane] remote stream resize failed:', error)
|
||||
@@ -294,19 +290,11 @@ export class RemoteBrowserStreamLifecycle {
|
||||
this.setStatus(remoteBrowserStreamStopped(message))
|
||||
this.handleStreamClosed(token, false)
|
||||
},
|
||||
// Why 'stopped' and not 'retrying': a transport error is NOT guaranteed to be followed by
|
||||
// a close. The web client's notifySubscriptionsError clears its subscription map and then
|
||||
// delivers onError only, so on that path no close ever arrives — and 'retrying' would
|
||||
// leave the pane busy forever with no way back, worse than the bug this PR fixes. When a
|
||||
// close does follow (the usual case) it publishes 'retrying', which replaces this within
|
||||
// the same tick. Landing in the actionable state and being corrected is safe; the reverse
|
||||
// is not.
|
||||
// Why the deadline is cancelled here: this is the one stream-ending path that keeps its
|
||||
// token, so the 'never said ready' deadline stays armed and its guard still passes. It
|
||||
// would then fire against a stream already declared stopped and republish 'retrying' —
|
||||
// withdrawing the reconnect control 30s after handing it over, with no user action, and
|
||||
// reopening the strand this whole feature exists to remove. Not clear(): a close that
|
||||
// does follow must still refill the budget for a stream that had been healthy.
|
||||
// Why 'stopped': notifySubscriptionsError delivers onError without a close, so 'retrying'
|
||||
// would leave the pane busy forever; a following close replaces this with 'retrying'.
|
||||
// Why cancel the deadline: this path keeps its token, so the armed ready-deadline would
|
||||
// later republish 'retrying' over the reconnect control. Not clear(): a following close
|
||||
// must still refill the budget for a stream that had been healthy.
|
||||
onTransportError: () => {
|
||||
this.liveness.stopWaitingForReady()
|
||||
this.setStatus(remoteBrowserStreamStopped(remoteBrowserStreamLostNotice()))
|
||||
|
||||
+3
-9
@@ -26,9 +26,7 @@ export type RemoteBrowserStreamRestartAttemptDeps = {
|
||||
adoptSubscription: (subscription: RemoteBrowserStreamSubscription) => void
|
||||
}
|
||||
|
||||
// One self-heal attempt for a dropped stream, as the scheduler wants it: resolves true to keep
|
||||
// retrying, false to stop. Split from the lifecycle because a single attempt needs none of the
|
||||
// lifecycle's own state — only the token it is retrying on behalf of.
|
||||
// One self-heal attempt for a dropped stream: resolves true to keep retrying, false to stop.
|
||||
export function createRemoteBrowserStreamRestartAttempt(
|
||||
token: RemoteBrowserStreamToken,
|
||||
deps: RemoteBrowserStreamRestartAttemptDeps
|
||||
@@ -65,14 +63,10 @@ export function createRemoteBrowserStreamRestartAttempt(
|
||||
}
|
||||
const failure = resolveRemoteBrowserStreamFailure(error)
|
||||
if (failure.logRawError) {
|
||||
// The raw text is transport-level and written for logs; keep it out of the UI but not out of
|
||||
// reach, since nothing else records it.
|
||||
// The raw transport text stays out of the UI, so this log is its only record.
|
||||
console.warn('[browser-pane] remote stream restart failed:', error)
|
||||
}
|
||||
// Why giving up publishes 'stopped': abandoning automatic retries is not the same as taking
|
||||
// away the user's last resort, and a classification we got wrong would otherwise strand the
|
||||
// pane exactly as it did before this work. While attempts remain it stays 'retrying', which
|
||||
// reports the failure without offering a control that competes with the next attempt.
|
||||
// Why: giving up still publishes 'stopped' so a misclassified failure stays manually recoverable.
|
||||
lastNotice = failure.message
|
||||
deps.setStatus(
|
||||
failure.shouldRetry
|
||||
|
||||
+3
-27
@@ -1,25 +1,13 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
// Why one value instead of the three booleans/strings this replaces (error, reconnectAvailable,
|
||||
// busy): they always described a single thing — what this stream is doing right now — but were
|
||||
// written independently from ~15 sites, so nothing stopped them disagreeing. Every defect three
|
||||
// review rounds found in this area was one such disagreement:
|
||||
//
|
||||
// stopped, but no affordance -> the pane stranded (the bug this work exists to fix)
|
||||
// stopped, but no message -> the control renders inside the message, so it could not render
|
||||
// retrying, but affordance shown -> offered a manual retry ~500ms before the automatic one
|
||||
// stopped, but still busy -> a spinner over a frozen frame, blocking the pane's own input
|
||||
//
|
||||
// Expressed as one tagged value, none of those four is a state you can write down. `busy`, the
|
||||
// message, and whether the reconnect control appears are all derived from it, so they cannot drift.
|
||||
// Why one tagged value: busy, message and reconnect availability are derived from it, so they cannot disagree.
|
||||
export type RemoteBrowserStreamStatus =
|
||||
| { kind: 'idle' }
|
||||
/** Establishing a stream: no stream yet, and no failure to report. */
|
||||
| { kind: 'opening' }
|
||||
/** A confirmed-live stream; the host has sent 'ready'. */
|
||||
| { kind: 'live' }
|
||||
// Automatic recovery is running. The notice is null until an attempt has actually failed, so a
|
||||
// blip the budget absorbs in 500ms stays invisible — which is the whole point of having a budget.
|
||||
// notice stays null until an attempt fails, so a blip absorbed by the budget stays invisible.
|
||||
| { kind: 'retrying'; notice: string | null }
|
||||
/** Automatic recovery is over. This is the only state that offers a reconnect. */
|
||||
| { kind: 'stopped'; notice: string }
|
||||
@@ -43,10 +31,7 @@ export function isRemoteBrowserStreamBusy(status: RemoteBrowserStreamStatus): bo
|
||||
|
||||
/** The stream's own message. Incidental notices (input failures, URL validation) are separate. */
|
||||
export function remoteBrowserStreamNotice(status: RemoteBrowserStreamStatus): string | null {
|
||||
if (status.kind === 'stopped') {
|
||||
return status.notice
|
||||
}
|
||||
return status.kind === 'retrying' ? status.notice : null
|
||||
return status.kind === 'stopped' || status.kind === 'retrying' ? status.notice : null
|
||||
}
|
||||
|
||||
// Why only 'stopped': while attempts remain, a manual control competes with the automatic recovery
|
||||
@@ -55,15 +40,6 @@ export function canReconnectRemoteBrowserStream(status: RemoteBrowserStreamStatu
|
||||
return status.kind === 'stopped'
|
||||
}
|
||||
|
||||
// Why the pane authors these rather than forwarding the failure's own message: those strings come
|
||||
// from the transport layer and are written for logs (e.g. "Runtime environment is manually
|
||||
// disconnected." — an RPC error with the code runtime_manually_disconnected). They name our
|
||||
// internals rather than the user's situation and none of them change what the user can do, which
|
||||
// the reconnect control already says. The raw error is still logged, so diagnosis keeps the detail
|
||||
// the UI drops. A failure we classified ourselves keeps its own message: it is specific and true,
|
||||
// and flattening "The selected runtime does not support remote browser streaming." into "lost
|
||||
// connection" would be both vaguer and wrong.
|
||||
|
||||
/** The stream was established and then died. */
|
||||
export function remoteBrowserStreamLostNotice(): string {
|
||||
return translate(
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
parseExecutionHostId,
|
||||
toRuntimeExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
@@ -16,6 +21,8 @@ import {
|
||||
} from '@/lib/worktree-operation-route'
|
||||
import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation'
|
||||
|
||||
const ownerUnresolved = (): Error => new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
|
||||
export type FileExplorerOperationRoute = {
|
||||
settings: { activeRuntimeEnvironmentId: string | null }
|
||||
connectionId?: string
|
||||
@@ -60,7 +67,7 @@ export function getFileExplorerOperationOwnerFromState(
|
||||
kind: 'runtime',
|
||||
environmentId: route.runtimeEnvironmentId,
|
||||
executionHostId:
|
||||
route.executionHostId ?? `runtime:${encodeURIComponent(route.runtimeEnvironmentId)}`
|
||||
route.executionHostId ?? toRuntimeExecutionHostId(route.runtimeEnvironmentId)
|
||||
}
|
||||
}
|
||||
if (route.executionHostId) {
|
||||
@@ -86,7 +93,7 @@ export function getFileExplorerOperationOwnerFromState(
|
||||
return {
|
||||
kind: 'runtime',
|
||||
environmentId: runtimeEnvironmentId,
|
||||
executionHostId: `runtime:${encodeURIComponent(runtimeEnvironmentId)}`
|
||||
executionHostId: toRuntimeExecutionHostId(runtimeEnvironmentId)
|
||||
}
|
||||
}
|
||||
if (connectionId === undefined) {
|
||||
@@ -114,7 +121,7 @@ export function getFileExplorerOperationRoute(
|
||||
return {
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
connectionId: owner.connectionId,
|
||||
expectedExecutionHostId: `ssh:${encodeURIComponent(owner.connectionId)}`
|
||||
expectedExecutionHostId: toSshExecutionHostId(owner.connectionId)
|
||||
}
|
||||
case 'runtime': {
|
||||
const host = parseExecutionHostId(owner.executionHostId)
|
||||
@@ -135,15 +142,15 @@ export function requireMatchingFileExplorerOperationRoute(
|
||||
expectedOwner: FileExplorerOperationOwner | undefined
|
||||
): FileExplorerOperationRoute {
|
||||
if (!expectedOwner || expectedOwner.kind === 'unresolved') {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
throw ownerUnresolved()
|
||||
}
|
||||
const currentOwner = getFileExplorerOperationOwner(worktreeId)
|
||||
if (JSON.stringify(currentOwner) !== JSON.stringify(expectedOwner)) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
throw ownerUnresolved()
|
||||
}
|
||||
const route = getFileExplorerOperationRoute(expectedOwner)
|
||||
if (!route) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
throw ownerUnresolved()
|
||||
}
|
||||
return route
|
||||
}
|
||||
@@ -153,18 +160,18 @@ export function captureFileExplorerOperationGuard(
|
||||
expectedOwner: FileExplorerOperationOwner | undefined
|
||||
): FileExplorerOperationGuard {
|
||||
if (!worktreeId) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
throw ownerUnresolved()
|
||||
}
|
||||
const route = requireMatchingFileExplorerOperationRoute(worktreeId, expectedOwner)
|
||||
const operationRoute = getFileExplorerGenerationRoute(expectedOwner)
|
||||
if (!operationRoute) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
throw ownerUnresolved()
|
||||
}
|
||||
const generationGuard = captureWorktreeOperationGenerationGuard(
|
||||
useAppStore.getState,
|
||||
worktreeId,
|
||||
operationRoute,
|
||||
() => new Error(getFileExplorerOwnerUnresolvedMessage()),
|
||||
ownerUnresolved,
|
||||
() => getFileExplorerGenerationRoute(getFileExplorerOperationOwner(worktreeId))
|
||||
)
|
||||
const expectedSshConnectionGeneration = getExpectedSshConnectionGeneration(
|
||||
@@ -173,15 +180,15 @@ export function captureFileExplorerOperationGuard(
|
||||
)
|
||||
const operationHost = parseExecutionHostId(operationRoute.executionHostId)
|
||||
if (!operationHost) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
throw ownerUnresolved()
|
||||
}
|
||||
if (operationHost?.kind === 'ssh' && expectedSshConnectionGeneration === undefined) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
if (operationHost.kind === 'ssh' && expectedSshConnectionGeneration === undefined) {
|
||||
throw ownerUnresolved()
|
||||
}
|
||||
const guardedRoute: FileExplorerOperationRoute = {
|
||||
...route,
|
||||
expectedExecutionHostId: operationHost.kind === 'ssh' ? operationHost.id : 'local',
|
||||
...(operationHost?.kind === 'ssh' ? { expectedSshTargetId: operationHost.targetId } : {}),
|
||||
...(operationHost.kind === 'ssh' ? { expectedSshTargetId: operationHost.targetId } : {}),
|
||||
...(expectedSshConnectionGeneration === undefined ? {} : { expectedSshConnectionGeneration })
|
||||
}
|
||||
return {
|
||||
@@ -192,7 +199,7 @@ export function captureFileExplorerOperationGuard(
|
||||
getExpectedSshConnectionGeneration(useAppStore.getState(), operationRoute) !==
|
||||
expectedSshConnectionGeneration
|
||||
) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
throw ownerUnresolved()
|
||||
}
|
||||
return guardedRoute
|
||||
}
|
||||
@@ -222,7 +229,7 @@ function getFileExplorerGenerationRoute(
|
||||
return { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
case 'ssh':
|
||||
return {
|
||||
executionHostId: `ssh:${encodeURIComponent(owner.connectionId)}`,
|
||||
executionHostId: toSshExecutionHostId(owner.connectionId),
|
||||
runtimeEnvironmentId: null
|
||||
}
|
||||
case 'runtime':
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { getExecutionHostLabel, type ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { ExecutionHostRegistryEntry } from '../../../../shared/execution-host-registry'
|
||||
import {
|
||||
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
|
||||
WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import type { ProjectHostSetup, ProjectHostSetupState } from '../../../../shared/project-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getHostSetupUnavailableDetail } from '@/lib/project-host-setup-options'
|
||||
import { pickerExecutionHosts } from '../../../../shared/managed-orcad-execution-host'
|
||||
|
||||
export type SetupHostOption = {
|
||||
@@ -56,7 +53,7 @@ export function buildSetupHostOptions({
|
||||
!host.aliasHostIds?.some((aliasHostId) => setupHostIds.has(aliasHostId))
|
||||
)
|
||||
.map((host) => {
|
||||
const availability = getHostSetupAvailability(host)
|
||||
const unavailableDetail = getHostSetupUnavailableDetail(host)
|
||||
// Why: import and clone require live remote providers, while an offline
|
||||
// host can still be recorded as a placeholder for later setup.
|
||||
const canUsePathActions = host.health === 'local' || host.health === 'available'
|
||||
@@ -64,57 +61,15 @@ export function buildSetupHostOptions({
|
||||
id: host.id,
|
||||
label: host.label || getExecutionHostLabel(host.id),
|
||||
detail:
|
||||
availability.isAvailable && !canUsePathActions
|
||||
? translate(
|
||||
unavailableDetail ??
|
||||
(canUsePathActions
|
||||
? host.detail
|
||||
: translate(
|
||||
'auto.components.settings.RepositoryPane.hostSetupConnectionRequired',
|
||||
'Connect this host before importing or cloning the project'
|
||||
)
|
||||
: availability.detail,
|
||||
isAvailable: availability.isAvailable,
|
||||
)),
|
||||
isAvailable: unavailableDetail === null,
|
||||
canUsePathActions
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getHostSetupAvailability(host: ExecutionHostRegistryEntry): {
|
||||
isAvailable: boolean
|
||||
detail: string
|
||||
} {
|
||||
if (host.health === 'blocked') {
|
||||
return {
|
||||
isAvailable: false,
|
||||
detail: translate(
|
||||
'auto.components.settings.RepositoryPane.hostSetupBlockedVersion',
|
||||
'Orca server version is incompatible'
|
||||
)
|
||||
}
|
||||
}
|
||||
if (host.kind === 'runtime') {
|
||||
const capabilities = host.capabilities
|
||||
if (!capabilities) {
|
||||
return {
|
||||
isAvailable: false,
|
||||
detail: translate(
|
||||
'auto.components.settings.RepositoryPane.hostSetupCheckingCapability',
|
||||
'Checking host capabilities'
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
!capabilities.includes(PROJECT_HOST_SETUP_RUNTIME_CAPABILITY) ||
|
||||
!capabilities.includes(WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY)
|
||||
) {
|
||||
return {
|
||||
isAvailable: false,
|
||||
detail: translate(
|
||||
'auto.components.settings.RepositoryPane.hostSetupMissingCapability',
|
||||
'Update Orca on this host to set up projects'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
isAvailable: true,
|
||||
detail: host.detail
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
|
||||
export type CapturedRuntimeOwner = string | null | undefined
|
||||
|
||||
@@ -33,3 +34,13 @@ export function worktreeRefreshOptions(
|
||||
...(executionHostId ? { executionHostId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAddRepoRuntimeTarget(
|
||||
runtimeEnvironmentId: string | null | undefined,
|
||||
settings: Parameters<typeof getActiveRuntimeTarget>[0]
|
||||
): RuntimeClientTarget {
|
||||
const environmentId = runtimeEnvironmentId?.trim()
|
||||
return environmentId
|
||||
? { kind: 'environment', environmentId }
|
||||
: getActiveRuntimeTarget({ ...settings, activeRuntimeEnvironmentId: null })
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { WorkspaceHostScope } from '../../../../shared/ui-chrome-types'
|
||||
import {
|
||||
ALL_EXECUTION_HOSTS_SCOPE,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
toRuntimeExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import {
|
||||
@@ -69,7 +70,7 @@ export function buildSidebarHostOptions(args: {
|
||||
}
|
||||
}
|
||||
const activeRuntimeHostId = args.settings?.activeRuntimeEnvironmentId?.trim()
|
||||
? (`runtime:${encodeURIComponent(args.settings.activeRuntimeEnvironmentId.trim())}` as const)
|
||||
? toRuntimeExecutionHostId(args.settings.activeRuntimeEnvironmentId.trim())
|
||||
: null
|
||||
return buildExecutionHostRegistry({
|
||||
repos: args.repos,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import { getCloneDestinationAutoFill } from './clone-defaults'
|
||||
@@ -9,7 +9,7 @@ import type { AddRepoDialogStep } from './add-repo-dialog-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { extractIpcErrorMessage } from '@/lib/ipc-error'
|
||||
import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert'
|
||||
import { worktreeRefreshOptions } from './add-repo-runtime-owner'
|
||||
import { resolveAddRepoRuntimeTarget, worktreeRefreshOptions } from './add-repo-runtime-owner'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
export function useAddRepoCloneFlow({
|
||||
@@ -126,16 +126,16 @@ export function useAddRepoCloneFlow({
|
||||
}
|
||||
const requestHostToken = hostTokenRef.current
|
||||
const gen = ++cloneGenRef.current
|
||||
const isCurrent = (): boolean =>
|
||||
gen === cloneGenRef.current && requestHostToken === hostTokenRef.current
|
||||
setIsCloning(true)
|
||||
setCloneError(null)
|
||||
setCloneProgress(null)
|
||||
try {
|
||||
const target = activeRuntimeEnvironmentId?.trim()
|
||||
? { kind: 'environment' as const, environmentId: activeRuntimeEnvironmentId.trim() }
|
||||
: getActiveRuntimeTarget({
|
||||
...useAppStore.getState().settings,
|
||||
activeRuntimeEnvironmentId: null
|
||||
})
|
||||
const target = resolveAddRepoRuntimeTarget(
|
||||
activeRuntimeEnvironmentId,
|
||||
useAppStore.getState().settings
|
||||
)
|
||||
const repo = sshTargetId?.trim()
|
||||
? await window.api.repos.cloneRemote({
|
||||
connectionId: sshTargetId.trim(),
|
||||
@@ -158,7 +158,7 @@ export function useAddRepoCloneFlow({
|
||||
url: trimmedUrl,
|
||||
destination: cloneDestination.trim()
|
||||
})) as Repo)
|
||||
if (gen !== cloneGenRef.current || requestHostToken !== hostTokenRef.current) {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
const { repo: ownedRepo } = upsertAddedRepoWithProjectHostSetup(repo, {
|
||||
@@ -173,18 +173,18 @@ export function useAddRepoCloneFlow({
|
||||
// should fall through to project reveal instead of leaving the add flow open.
|
||||
const ownerOptions = worktreeRefreshOptions(activeRuntimeEnvironmentId, sshTargetId)
|
||||
await fetchWorktrees(ownedRepo.id, ownerOptions)
|
||||
if (gen !== cloneGenRef.current || requestHostToken !== hostTokenRef.current) {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
await onGitRepoReady(ownedRepo.id, 'clone_url', ownerOptions.executionHostId)
|
||||
} catch (err) {
|
||||
if (gen !== cloneGenRef.current || requestHostToken !== hostTokenRef.current) {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
const message = extractIpcErrorMessage(err, String(err))
|
||||
setCloneError(message)
|
||||
} finally {
|
||||
if (gen === cloneGenRef.current && requestHostToken === hostTokenRef.current) {
|
||||
if (isCurrent()) {
|
||||
setIsCloning(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ describe('useCreateRepo default-checkout handoff', () => {
|
||||
expect(mocks.storeState.projectHostSetups).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ repoId: repo.id, path: repo.path })])
|
||||
)
|
||||
expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id)
|
||||
expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, undefined)
|
||||
})
|
||||
|
||||
it('returns the selected parent directory after the local picker applies it', async () => {
|
||||
@@ -187,7 +187,7 @@ describe('useCreateRepo default-checkout handoff', () => {
|
||||
expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, {
|
||||
requireAuthoritative: true
|
||||
})
|
||||
expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id)
|
||||
expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, undefined)
|
||||
expect(mocks.stateSetters[STATE_ERROR_MESSAGE]).not.toHaveBeenCalledWith(
|
||||
'Could not refresh project worktrees. Try again.'
|
||||
)
|
||||
|
||||
@@ -6,13 +6,13 @@ import { useAppStore } from '@/store'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { extractIpcErrorMessage } from '@/lib/ipc-error'
|
||||
import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert'
|
||||
import { worktreeRefreshOptions } from './add-repo-runtime-owner'
|
||||
import { resolveAddRepoRuntimeTarget, worktreeRefreshOptions } from './add-repo-runtime-owner'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
export function useCreateRepo(
|
||||
@@ -93,15 +93,17 @@ export function useCreateRepo(
|
||||
}
|
||||
const requestHostToken = hostTokenRef.current
|
||||
const gen = ++createGenRef.current
|
||||
const isCurrent = (): boolean =>
|
||||
gen === createGenRef.current &&
|
||||
requestHostToken === hostTokenRef.current &&
|
||||
mountedRef.current
|
||||
setIsCreating(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
const target = options.runtimeEnvironmentId?.trim()
|
||||
? { kind: 'environment' as const, environmentId: options.runtimeEnvironmentId.trim() }
|
||||
: getActiveRuntimeTarget({
|
||||
...useAppStore.getState().settings,
|
||||
activeRuntimeEnvironmentId: null
|
||||
})
|
||||
const target = resolveAddRepoRuntimeTarget(
|
||||
options.runtimeEnvironmentId,
|
||||
useAppStore.getState().settings
|
||||
)
|
||||
// Why: Create Project is intentionally Git-only; non-Git folders use the
|
||||
// existing add-folder flows instead of this path.
|
||||
const createKind = 'git' as const
|
||||
@@ -130,11 +132,7 @@ export function useCreateRepo(
|
||||
})
|
||||
// Why: if the user closed the dialog or clicked Back mid-create,
|
||||
// createGenRef was bumped by resetCreateState. Ignore stale results.
|
||||
if (
|
||||
gen !== createGenRef.current ||
|
||||
requestHostToken !== hostTokenRef.current ||
|
||||
!mountedRef.current
|
||||
) {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
if ('error' in result) {
|
||||
@@ -170,40 +168,23 @@ export function useCreateRepo(
|
||||
}
|
||||
)
|
||||
}
|
||||
const ownerOptions = worktreeRefreshOptions(options.runtimeEnvironmentId, options.sshTargetId)
|
||||
if (isGitRepoKind(repo)) {
|
||||
// Why: Git repos use the shared default-checkout completion path.
|
||||
// Why: if refresh is temporarily non-authoritative, the shared opener
|
||||
// still reveals the project so the user is not left in a completed add flow.
|
||||
const ownerOptions = worktreeRefreshOptions(
|
||||
options.runtimeEnvironmentId,
|
||||
options.sshTargetId
|
||||
)
|
||||
await fetchWorktrees(repo.id, ownerOptions)
|
||||
if (
|
||||
gen !== createGenRef.current ||
|
||||
requestHostToken !== hostTokenRef.current ||
|
||||
!mountedRef.current
|
||||
) {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
await (ownerOptions.executionHostId
|
||||
? onGitRepoReady?.(repo.id, ownerOptions.executionHostId)
|
||||
: onGitRepoReady?.(repo.id))
|
||||
await onGitRepoReady?.(repo.id, ownerOptions.executionHostId)
|
||||
} else {
|
||||
// Why: folder repos skip the Git default-checkout handoff, so activate the synthetic
|
||||
// root workspace before closing. Matches addNonGitFolder's behavior.
|
||||
const ownerOptions = worktreeRefreshOptions(
|
||||
options.runtimeEnvironmentId,
|
||||
options.sshTargetId
|
||||
)
|
||||
await (ownerOptions.executionHostId
|
||||
? fetchWorktrees(repo.id, { executionHostId: ownerOptions.executionHostId })
|
||||
: fetchWorktrees(repo.id))
|
||||
if (
|
||||
gen !== createGenRef.current ||
|
||||
requestHostToken !== hostTokenRef.current ||
|
||||
!mountedRef.current
|
||||
) {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
const folderWorktree = useAppStore
|
||||
@@ -225,22 +206,14 @@ export function useCreateRepo(
|
||||
closeModal()
|
||||
}
|
||||
} catch (err) {
|
||||
if (
|
||||
gen !== createGenRef.current ||
|
||||
requestHostToken !== hostTokenRef.current ||
|
||||
!mountedRef.current
|
||||
) {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
setCreateError(extractIpcErrorMessage(err, String(err)))
|
||||
} finally {
|
||||
// Why: only clear the loading state if this invocation is still current;
|
||||
// a superseded create must not flip the flag back off for a new flow.
|
||||
if (
|
||||
gen === createGenRef.current &&
|
||||
requestHostToken === hostTokenRef.current &&
|
||||
mountedRef.current
|
||||
) {
|
||||
if (isCurrent()) {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createBoundedGenerationMap } from './bounded-generation-map'
|
||||
|
||||
describe('createBoundedGenerationMap', () => {
|
||||
it('advances a key to a strictly larger generation', () => {
|
||||
const map = createBoundedGenerationMap(4)
|
||||
expect(map.get('a')).toBe(0)
|
||||
map.advance('a')
|
||||
const first = map.get('a')
|
||||
map.advance('a')
|
||||
expect(map.get('a')).toBeGreaterThan(first)
|
||||
})
|
||||
|
||||
it('keeps insertion order for eviction when a key is re-advanced', () => {
|
||||
const map = createBoundedGenerationMap(2)
|
||||
map.advance('a')
|
||||
map.advance('b')
|
||||
map.advance('a')
|
||||
map.advance('c')
|
||||
// 'a' keeps its first-inserted slot, so it is evicted despite being re-advanced.
|
||||
expect(map.get('b')).toBe(2)
|
||||
expect(map.get('a')).toBe(3)
|
||||
expect(map.get('c')).toBe(4)
|
||||
})
|
||||
|
||||
it('reads an evicted key as the highest evicted generation', () => {
|
||||
const map = createBoundedGenerationMap(1)
|
||||
map.advance('a')
|
||||
map.advance('a')
|
||||
map.advance('b')
|
||||
expect(map.get('a')).toBe(2)
|
||||
expect(map.get('never-seen')).toBe(2)
|
||||
})
|
||||
|
||||
it('reset clears generations, sequence and the evicted floor', () => {
|
||||
const map = createBoundedGenerationMap(1)
|
||||
map.advance('a')
|
||||
map.advance('b')
|
||||
map.reset()
|
||||
expect(map.get('a')).toBe(0)
|
||||
expect(map.get('b')).toBe(0)
|
||||
map.advance('a')
|
||||
expect(map.get('a')).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
export type BoundedGenerationMap = {
|
||||
get: (key: string) => number
|
||||
advance: (key: string) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
// Why: evicted keys read the highest evicted generation, so a stale snapshot never matches a fresh one.
|
||||
export function createBoundedGenerationMap(max: number): BoundedGenerationMap {
|
||||
const generations = new Map<string, number>()
|
||||
let sequence = 0
|
||||
let evicted = 0
|
||||
return {
|
||||
get: (key) => generations.get(key) ?? evicted,
|
||||
advance: (key) => {
|
||||
generations.set(key, ++sequence)
|
||||
while (generations.size > max) {
|
||||
const oldest = generations.keys().next()
|
||||
if (oldest.done) {
|
||||
break
|
||||
}
|
||||
evicted = Math.max(evicted, generations.get(oldest.value) ?? 0)
|
||||
generations.delete(oldest.value)
|
||||
}
|
||||
},
|
||||
reset: () => {
|
||||
generations.clear()
|
||||
sequence = 0
|
||||
evicted = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../../../shared/protocol-version'
|
||||
import type { ProjectHostSetup } from '../../../shared/project-types'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type ProjectHostSetupOption =
|
||||
| {
|
||||
@@ -180,7 +181,11 @@ function buildNeedsSetupOptions({
|
||||
)
|
||||
.map((host) => {
|
||||
const pendingSetup = pendingSetupByHost.get(host.id)
|
||||
const availability = getHostSetupAvailability(host)
|
||||
// Why: disconnected hosts cannot confirm project setup or runtime capabilities,
|
||||
// so connection state needs to win over setup guidance.
|
||||
const unavailableDetail =
|
||||
getHostHealthUnavailableDetail(host.health) ?? getHostSetupUnavailableDetail(host)
|
||||
const isAvailable = unavailableDetail === null
|
||||
const connectAction = getHostConnectAction(host)
|
||||
return {
|
||||
id: `needs-setup:${host.id}`,
|
||||
@@ -188,14 +193,12 @@ function buildNeedsSetupOptions({
|
||||
projectId,
|
||||
hostId: host.id,
|
||||
label: host.label || getExecutionHostLabel(host.id),
|
||||
detail: availability.isAvailable
|
||||
? pendingSetup
|
||||
? getPendingSetupDetail(pendingSetup)
|
||||
: 'Project location not set'
|
||||
: availability.detail,
|
||||
isAvailable: availability.isAvailable,
|
||||
detail:
|
||||
unavailableDetail ??
|
||||
(pendingSetup ? getPendingSetupDetail(pendingSetup) : 'Project location not set'),
|
||||
isAvailable,
|
||||
attention: host.health === 'error',
|
||||
canSetLocation: canSetProjectLocation(projectId, availability.isAvailable, pendingSetup),
|
||||
canSetLocation: canSetProjectLocation(projectId, isAvailable, pendingSetup),
|
||||
...(connectAction ? { connectAction } : {})
|
||||
}
|
||||
})
|
||||
@@ -213,46 +216,32 @@ function isRuntimeOwnedSshSetupHost(hostId: ExecutionHostId): boolean {
|
||||
return parsed?.kind === 'ssh' && isRuntimeOwnedSshTargetId(parsed.targetId)
|
||||
}
|
||||
|
||||
function getHostSetupAvailability(host: ExecutionHostRegistryEntry): {
|
||||
isAvailable: boolean
|
||||
detail: string
|
||||
} {
|
||||
export function getHostSetupUnavailableDetail(host: ExecutionHostRegistryEntry): string | null {
|
||||
if (host.health === 'blocked') {
|
||||
return {
|
||||
isAvailable: false,
|
||||
detail: 'Orca server version is incompatible'
|
||||
}
|
||||
return translate(
|
||||
'auto.components.settings.RepositoryPane.hostSetupBlockedVersion',
|
||||
'Orca server version is incompatible'
|
||||
)
|
||||
}
|
||||
// Why: disconnected hosts cannot confirm project setup or runtime capabilities,
|
||||
// so connection state needs to win over setup guidance.
|
||||
const healthUnavailableDetail = getHostHealthUnavailableDetail(host.health)
|
||||
if (healthUnavailableDetail) {
|
||||
return {
|
||||
isAvailable: false,
|
||||
detail: healthUnavailableDetail
|
||||
}
|
||||
if (host.kind !== 'runtime') {
|
||||
return null
|
||||
}
|
||||
if (host.kind === 'runtime') {
|
||||
if (!host.capabilities) {
|
||||
return {
|
||||
isAvailable: false,
|
||||
detail: 'Checking host capabilities'
|
||||
}
|
||||
}
|
||||
if (
|
||||
!host.capabilities.includes(PROJECT_HOST_SETUP_RUNTIME_CAPABILITY) ||
|
||||
!host.capabilities.includes(WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY)
|
||||
) {
|
||||
return {
|
||||
isAvailable: false,
|
||||
detail: 'Update Orca on this host to set up projects'
|
||||
}
|
||||
}
|
||||
if (!host.capabilities) {
|
||||
return translate(
|
||||
'auto.components.settings.RepositoryPane.hostSetupCheckingCapability',
|
||||
'Checking host capabilities'
|
||||
)
|
||||
}
|
||||
return {
|
||||
isAvailable: true,
|
||||
detail: ''
|
||||
if (
|
||||
!host.capabilities.includes(PROJECT_HOST_SETUP_RUNTIME_CAPABILITY) ||
|
||||
!host.capabilities.includes(WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY)
|
||||
) {
|
||||
return translate(
|
||||
'auto.components.settings.RepositoryPane.hostSetupMissingCapability',
|
||||
'Update Orca on this host to set up projects'
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getHostHealthUnavailableDetail(
|
||||
|
||||
@@ -31,11 +31,11 @@ import {
|
||||
type SlugIndex
|
||||
} from './repo-slug-cache'
|
||||
import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key'
|
||||
import { createBoundedGenerationMap } from './bounded-generation-map'
|
||||
|
||||
export { lookupReposBySlugFromCache } from './repo-slug-cache'
|
||||
|
||||
const slugResolutionInFlight = new Map<string, Promise<string | null>>()
|
||||
const MAX_SLUG_RESOLUTION_GENERATIONS = 1024
|
||||
|
||||
// Why: an invalidation (repo removed, remote changed) can land while a
|
||||
// resolution is in-flight — before it ever wrote to `slugByRepoId`. Deleting
|
||||
@@ -43,24 +43,11 @@ const MAX_SLUG_RESOLUTION_GENERATIONS = 1024
|
||||
// stale slug would repopulate the cache after invalidation. Bump the key's
|
||||
// generation on every invalidation and commit a result only if the generation
|
||||
// it started with is still current.
|
||||
const slugResolutionGeneration = new Map<string, number>()
|
||||
let slugResolutionGenerationSequence = 0
|
||||
let evictedSlugResolutionGeneration = 0
|
||||
const slugResolutionGenerations = createBoundedGenerationMap(1024)
|
||||
|
||||
function invalidateSlugResolution(cacheKey: string): void {
|
||||
slugResolutionInFlight.delete(cacheKey)
|
||||
slugResolutionGeneration.set(cacheKey, ++slugResolutionGenerationSequence)
|
||||
while (slugResolutionGeneration.size > MAX_SLUG_RESOLUTION_GENERATIONS) {
|
||||
const oldest = slugResolutionGeneration.keys().next()
|
||||
if (oldest.done) {
|
||||
return
|
||||
}
|
||||
evictedSlugResolutionGeneration = Math.max(
|
||||
evictedSlugResolutionGeneration,
|
||||
slugResolutionGeneration.get(oldest.value) ?? 0
|
||||
)
|
||||
slugResolutionGeneration.delete(oldest.value)
|
||||
}
|
||||
slugResolutionGenerations.advance(cacheKey)
|
||||
}
|
||||
|
||||
// Why: clear after remove/remote-change so the next index build re-resolves.
|
||||
@@ -98,14 +85,14 @@ async function resolveRepoSlug(
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
const generation = slugResolutionGeneration.get(cacheKey) ?? evictedSlugResolutionGeneration
|
||||
const generation = slugResolutionGenerations.get(cacheKey)
|
||||
const resolution = (async () => {
|
||||
// Why: only write the resolved value if this key wasn't invalidated
|
||||
// mid-flight; otherwise a stale slug would repopulate the cache.
|
||||
const commit = (value: string | null): string | null => {
|
||||
if (
|
||||
slugResolutionInFlight.get(cacheKey) === resolution &&
|
||||
(slugResolutionGeneration.get(cacheKey) ?? evictedSlugResolutionGeneration) === generation
|
||||
slugResolutionGenerations.get(cacheKey) === generation
|
||||
) {
|
||||
rememberRepoSlug(cacheKey, value)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
buildWorkspaceSessionHostSnapshots,
|
||||
patchWorkspaceSessionByHost,
|
||||
persistWorkspaceSessionByHost,
|
||||
persistWorkspaceSessionByHostSync,
|
||||
type HostPersistenceState
|
||||
} from './workspace-session-host-persistence'
|
||||
import {
|
||||
@@ -423,7 +422,7 @@ describe('fetchWorkspaceSessionFromHosts', () => {
|
||||
expect(owner(worktreeWorkspaceKey(worktreeId))).toBe('runtime:env-1')
|
||||
})
|
||||
|
||||
it('builds local-first host snapshots reused by synchronous persistence', () => {
|
||||
it('builds local-first host snapshots', () => {
|
||||
const localWorktreeId = 'local-repo::C:\\src\\local'
|
||||
const remoteWorktreeId = 'remote-repo::/srv/remote'
|
||||
const makeTab = (id: string, worktreeId: string) => ({
|
||||
@@ -473,13 +472,6 @@ describe('fetchWorkspaceSessionFromHosts', () => {
|
||||
expect(snapshots[1].state.tabsByWorktree).toEqual({
|
||||
[remoteWorktreeId]: [expect.objectContaining({ id: 'remote-tab' })]
|
||||
})
|
||||
|
||||
const setSync = vi.fn()
|
||||
persistWorkspaceSessionByHostSync({ get: vi.fn(), patch: vi.fn(), setSync }, payload, state)
|
||||
|
||||
expect(setSync.mock.calls).toEqual(
|
||||
snapshots.map((snapshot) => [snapshot.state, snapshot.hostId])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -704,7 +696,8 @@ describe('persistWorkspaceSessionByHost', () => {
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tabsByWorktree: { [localWorktreeId]: expect.any(Array) }
|
||||
})
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -93,13 +93,7 @@ function getFolderWorkspacePartitionHostId(
|
||||
: null
|
||||
const parsed = parseExecutionHostId(workspace?.executionHostId ?? group?.executionHostId)
|
||||
if (parsed) {
|
||||
// Every non-local kind owns its own partition — the same answer main's
|
||||
// RuntimeWorkspaceSessionController.getPreferredHostId gives for this key. Answering 'local'
|
||||
// for an ssh host left the renderer and the runtime writing one folder workspace into two
|
||||
// stores, which is #12723 unfixed for folder workspaces; and once the renderer began writing
|
||||
// `ssh:<targetId>` at all, a save's field-level patch erased the folder rows main had put
|
||||
// there. Boot reads these partitions from persistence's own census, not the repo catalog, so
|
||||
// a target whose only workspace is a folder is no longer unenumerated.
|
||||
// Why: every non-local kind owns its own partition, matching main's getPreferredHostId (#12723).
|
||||
return parsed.id
|
||||
}
|
||||
if (workspace && group) {
|
||||
@@ -135,14 +129,6 @@ function buildRepoHostById(
|
||||
return repoHostById
|
||||
}
|
||||
|
||||
/** Map a worktree to the host partition it persists under, plus the host claims behind it.
|
||||
*
|
||||
* Why every non-local host and not just `runtime:*`: an SSH worktree's session is already
|
||||
* read-modify-written into `ssh:<targetId>` by the main-process runtime, so answering 'local'
|
||||
* here double-owned the data and left whichever half the readers skipped round-tripping as
|
||||
* absence (#12721, #12723). The one exception is an id two hosts both publish: it gets a
|
||||
* deterministic primary so the co-claimant's rows can be parked in the shadow instead of sharing
|
||||
* one bucket with it. */
|
||||
/** True only when the catalog positively says `hostId` no longer holds the workspace. An id the
|
||||
* catalog cannot speak for yet keeps its restored partition — the same rule the shadow uses. */
|
||||
function catalogReattributedAwayFrom(
|
||||
@@ -154,6 +140,14 @@ function catalogReattributedAwayFrom(
|
||||
return Boolean(claimed) && !contestedPartitionHosts(claimed ?? []).includes(hostId)
|
||||
}
|
||||
|
||||
/** Map a worktree to the host partition it persists under, plus the host claims behind it.
|
||||
*
|
||||
* Why every non-local host and not just `runtime:*`: an SSH worktree's session is already
|
||||
* read-modify-written into `ssh:<targetId>` by the main-process runtime, so answering 'local'
|
||||
* here double-owned the data and left whichever half the readers skipped round-tripping as
|
||||
* absence (#12721, #12723). The one exception is an id two hosts both publish: it gets a
|
||||
* deterministic primary so the co-claimant's rows can be parked in the shadow instead of sharing
|
||||
* one bucket with it. */
|
||||
export function buildHostSessionRouting(state: HostPersistenceState): HostSessionRouting {
|
||||
const repoHostById = buildRepoHostById(state.repos)
|
||||
const claims = indexWorktreeHostClaims(state.worktreesByRepo, repoHostById)
|
||||
@@ -268,14 +262,9 @@ export async function persistWorkspaceSessionByHost(
|
||||
payload: WorkspaceSessionState,
|
||||
state: HostPersistenceState
|
||||
): Promise<void> {
|
||||
// Why 'replace': api.set swaps the whole partition, so parked rows must ride along even for
|
||||
// fields nothing else routed to this host.
|
||||
const slices = splitWorkspaceSessionForWrite(payload, state, 'replace')
|
||||
const writes: Promise<void>[] = [api.set(slices[LOCAL_EXECUTION_HOST_ID] ?? payload)]
|
||||
for (const [hostId, slice] of nonLocalHostSessionEntries(slices)) {
|
||||
writes.push(api.set(slice, hostId))
|
||||
}
|
||||
await Promise.all(writes)
|
||||
await Promise.all(
|
||||
buildWorkspaceSessionHostSnapshots(payload, state).map((s) => api.set(s.state, s.hostId))
|
||||
)
|
||||
await api.flush()
|
||||
}
|
||||
|
||||
@@ -294,14 +283,3 @@ export function buildWorkspaceSessionHostSnapshots(
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
/** Synchronous full-session split for the beforeunload / quit paths. */
|
||||
export function persistWorkspaceSessionByHostSync(
|
||||
api: SessionApi,
|
||||
payload: WorkspaceSessionState,
|
||||
state: HostPersistenceState
|
||||
): void {
|
||||
for (const snapshot of buildWorkspaceSessionHostSnapshots(payload, state)) {
|
||||
api.setSync(snapshot.state, snapshot.hostId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,6 @@ export const latestReceivedSessionTabsInventoryFrameByEnvironment = new Map<stri
|
||||
export const sessionTabsRemovalWatermarkByWorktree = new Map<string, number>()
|
||||
export const trackedSessionTabsWorktreeIdsByEnvironment = new Map<string, Set<string>>()
|
||||
export const sessionTabsEnvironmentsByWorktree = new Map<string, Set<string>>()
|
||||
export const sessionTabsTrackingGenerationByEnvironment = new Map<string, number>()
|
||||
export const lastHostTerminalTabCountByWorktree = new Map<string, number>()
|
||||
export const MAX_TRACKED_SESSION_TABS_INVENTORY_OMISSIONS = 512
|
||||
export type SessionTabsInventoryOmissionObservation = {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
sessionTabsRuntimeHistoryByEnvironment,
|
||||
trackedSessionTabsWorktreeIdsByEnvironment,
|
||||
sessionTabsEnvironmentsByWorktree,
|
||||
sessionTabsTrackingGenerationByEnvironment,
|
||||
lastHostTerminalTabCountByWorktree,
|
||||
sessionTabsInventoryOmissionsByWorktree,
|
||||
hostSessionTabIdByLocalKey,
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
} from '../web-session-terminal-placement'
|
||||
import { clearHostSessionMirrorHydration } from '../host-session-mirror-hydration'
|
||||
import { clearHostMirrorHandleGapVerdictsForEnvironment } from '@/lib/host-mirror-handle-gap-wait'
|
||||
import { createBoundedGenerationMap } from '@/lib/bounded-generation-map'
|
||||
import { clearHostSessionTabIdMappings } from './tracking-mappings'
|
||||
import {
|
||||
sessionTabsFreshnessKey,
|
||||
@@ -49,26 +49,7 @@ import {
|
||||
removeWebSessionTabsEnvironment
|
||||
} from './tracking'
|
||||
|
||||
const MAX_SESSION_TABS_TRACKING_GENERATIONS = 512
|
||||
let sessionTabsTrackingGenerationSequence = 0
|
||||
let evictedSessionTabsTrackingGeneration = 0
|
||||
|
||||
function advanceSessionTabsTrackingGeneration(environmentId: string): void {
|
||||
const next = ++sessionTabsTrackingGenerationSequence
|
||||
sessionTabsTrackingGenerationByEnvironment.set(environmentId, next)
|
||||
while (sessionTabsTrackingGenerationByEnvironment.size > MAX_SESSION_TABS_TRACKING_GENERATIONS) {
|
||||
const oldest = sessionTabsTrackingGenerationByEnvironment.keys().next()
|
||||
if (oldest.done) {
|
||||
break
|
||||
}
|
||||
const oldestEnvironmentId = oldest.value
|
||||
evictedSessionTabsTrackingGeneration = Math.max(
|
||||
evictedSessionTabsTrackingGeneration,
|
||||
sessionTabsTrackingGenerationByEnvironment.get(oldestEnvironmentId) ?? 0
|
||||
)
|
||||
sessionTabsTrackingGenerationByEnvironment.delete(oldestEnvironmentId)
|
||||
}
|
||||
}
|
||||
const sessionTabsTrackingGenerations = createBoundedGenerationMap(512)
|
||||
|
||||
export function getLastKnownHostTerminalTabCount(
|
||||
environmentId: string,
|
||||
@@ -118,9 +99,7 @@ export function resetWebSessionTabsSnapshotFreshnessForTests(): void {
|
||||
hostSessionTabIdByLocalKey.clear()
|
||||
hostSessionTabMappingKeysByEnvironmentAndWorktree.clear()
|
||||
hostWorkingClientBoundaryByPaneKey.clear()
|
||||
sessionTabsTrackingGenerationByEnvironment.clear()
|
||||
sessionTabsTrackingGenerationSequence = 0
|
||||
evictedSessionTabsTrackingGeneration = 0
|
||||
sessionTabsTrackingGenerations.reset()
|
||||
resetWebSessionBrowserPlacementsForTests()
|
||||
}
|
||||
|
||||
@@ -181,7 +160,7 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string)
|
||||
return
|
||||
}
|
||||
const keyPrefix = `${trimmedEnvironmentId}:`
|
||||
advanceSessionTabsTrackingGeneration(trimmedEnvironmentId)
|
||||
sessionTabsTrackingGenerations.advance(trimmedEnvironmentId)
|
||||
for (const key of latestSessionTabsSnapshotByWorktree.keys()) {
|
||||
if (key.startsWith(keyPrefix)) {
|
||||
latestSessionTabsSnapshotByWorktree.delete(key)
|
||||
@@ -245,5 +224,5 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string)
|
||||
|
||||
export function getWebSessionTabsTrackingGeneration(environmentId: string): number {
|
||||
const key = environmentId.trim()
|
||||
return sessionTabsTrackingGenerationByEnvironment.get(key) ?? evictedSessionTabsTrackingGeneration
|
||||
return sessionTabsTrackingGenerations.get(key)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { SshConnectionState, SshTargetSummary } from '../../../../shared/ssh-types'
|
||||
import { sanitizeSshTargetGeneration } from '../../../../shared/ssh-target-generation'
|
||||
import { sshConnectionStatesEqual } from './ssh-connection-state-equality'
|
||||
import { sshTargetLabelsEqual } from './ssh-target-cleanup'
|
||||
import {
|
||||
collectSshTargetGenerations,
|
||||
sshTargetGenerationsEqual,
|
||||
sshTargetLabelsEqual
|
||||
} from './ssh-target-cleanup'
|
||||
import { createBoundedGenerationMap } from '../../lib/bounded-generation-map'
|
||||
export {
|
||||
selectRuntimeAwareSshConnectionGeneration,
|
||||
selectRuntimeAwareSshError,
|
||||
@@ -75,32 +79,8 @@ const EMPTY_BUCKET: RuntimeEnvironmentSshBucket = {
|
||||
targetsHydrated: false
|
||||
}
|
||||
|
||||
function collectTargetGenerations(targets: SshTargetSummary[]): Map<string, number> {
|
||||
const generations = new Map<string, number>()
|
||||
for (const target of targets) {
|
||||
const generation = sanitizeSshTargetGeneration(target.generation)
|
||||
if (generation !== undefined) {
|
||||
generations.set(target.id, generation)
|
||||
}
|
||||
}
|
||||
return generations
|
||||
}
|
||||
|
||||
function targetGenerationsEqual(current: Map<string, number>, next: Map<string, number>): boolean {
|
||||
return (
|
||||
current.size === next.size &&
|
||||
[...next].every(([targetId, generation]) => current.get(targetId) === generation)
|
||||
)
|
||||
}
|
||||
|
||||
const stateGenerationByEnvironment = new Map<string, number>()
|
||||
const targetConnectionGenerationByEnvironment = new Map<string, number>()
|
||||
const MAX_SSH_STATE_GENERATIONS = 512
|
||||
const MAX_SSH_TARGET_GENERATIONS = 4096
|
||||
let targetConnectionGenerationSequence = 0
|
||||
let evictedTargetConnectionGeneration = 0
|
||||
let stateGenerationSequence = 0
|
||||
let evictedStateGeneration = 0
|
||||
const targetConnectionGenerations = createBoundedGenerationMap(4096)
|
||||
const stateGenerations = createBoundedGenerationMap(512)
|
||||
|
||||
function targetGenerationKey(environmentId: string, targetId: string): string {
|
||||
return `${environmentId}\0${targetId}`
|
||||
@@ -110,48 +90,18 @@ export function getEnvironmentSshTargetConnectionGeneration(
|
||||
environmentId: string,
|
||||
targetId: string
|
||||
): number {
|
||||
return (
|
||||
targetConnectionGenerationByEnvironment.get(targetGenerationKey(environmentId, targetId)) ??
|
||||
evictedTargetConnectionGeneration
|
||||
)
|
||||
return targetConnectionGenerations.get(targetGenerationKey(environmentId, targetId))
|
||||
}
|
||||
|
||||
function advanceEnvironmentSshTargetConnectionGeneration(
|
||||
environmentId: string,
|
||||
targetId: string
|
||||
): void {
|
||||
const key = targetGenerationKey(environmentId, targetId)
|
||||
targetConnectionGenerationByEnvironment.set(key, ++targetConnectionGenerationSequence)
|
||||
while (targetConnectionGenerationByEnvironment.size > MAX_SSH_TARGET_GENERATIONS) {
|
||||
const oldest = targetConnectionGenerationByEnvironment.keys().next()
|
||||
if (oldest.done) {
|
||||
break
|
||||
}
|
||||
evictedTargetConnectionGeneration = Math.max(
|
||||
evictedTargetConnectionGeneration,
|
||||
targetConnectionGenerationByEnvironment.get(oldest.value) ?? 0
|
||||
)
|
||||
targetConnectionGenerationByEnvironment.delete(oldest.value)
|
||||
}
|
||||
targetConnectionGenerations.advance(targetGenerationKey(environmentId, targetId))
|
||||
}
|
||||
|
||||
export function getEnvironmentSshStateGeneration(environmentId: string): number {
|
||||
return stateGenerationByEnvironment.get(environmentId) ?? evictedStateGeneration
|
||||
}
|
||||
|
||||
function advanceEnvironmentSshStateGeneration(environmentId: string): void {
|
||||
stateGenerationByEnvironment.set(environmentId, ++stateGenerationSequence)
|
||||
while (stateGenerationByEnvironment.size > MAX_SSH_STATE_GENERATIONS) {
|
||||
const oldest = stateGenerationByEnvironment.keys().next()
|
||||
if (oldest.done) {
|
||||
break
|
||||
}
|
||||
evictedStateGeneration = Math.max(
|
||||
evictedStateGeneration,
|
||||
stateGenerationByEnvironment.get(oldest.value) ?? 0
|
||||
)
|
||||
stateGenerationByEnvironment.delete(oldest.value)
|
||||
}
|
||||
return stateGenerations.get(environmentId)
|
||||
}
|
||||
|
||||
function generationIsCurrent(environmentId: string, generation: number | undefined): boolean {
|
||||
@@ -225,10 +175,10 @@ export const createRuntimeEnvironmentSshSlice: StateCreator<
|
||||
const connectionStates = new Map(
|
||||
Array.from(bucket.connectionStates).filter(([targetId]) => targetIds.has(targetId))
|
||||
)
|
||||
const targetGenerations = collectTargetGenerations(targets)
|
||||
const targetGenerations = collectSshTargetGenerations(targets)
|
||||
if (
|
||||
sshTargetLabelsEqual(bucket.targetLabels, targets) &&
|
||||
targetGenerationsEqual(bucket.targetGenerations, targetGenerations)
|
||||
sshTargetGenerationsEqual(bucket.targetGenerations, targetGenerations)
|
||||
) {
|
||||
// Why: an unchanged (even empty) list is still a successful load — the
|
||||
// hydration flag must flip on the first fetch of an empty target set.
|
||||
@@ -262,7 +212,7 @@ export const createRuntimeEnvironmentSshSlice: StateCreator<
|
||||
|
||||
markEnvironmentSshStateStale: (environmentId) =>
|
||||
set((s) => {
|
||||
advanceEnvironmentSshStateGeneration(environmentId)
|
||||
stateGenerations.advance(environmentId)
|
||||
const bucket = s.sshStateByEnvironment.get(environmentId)
|
||||
if (
|
||||
!bucket ||
|
||||
@@ -285,7 +235,7 @@ export const createRuntimeEnvironmentSshSlice: StateCreator<
|
||||
|
||||
removeEnvironmentSshState: (environmentId) =>
|
||||
set((s) => {
|
||||
advanceEnvironmentSshStateGeneration(environmentId)
|
||||
stateGenerations.advance(environmentId)
|
||||
if (!s.sshStateByEnvironment.has(environmentId)) {
|
||||
return s
|
||||
}
|
||||
@@ -301,7 +251,7 @@ export const createRuntimeEnvironmentSshSlice: StateCreator<
|
||||
const next = new Map(s.sshStateByEnvironment)
|
||||
for (const id of next.keys()) {
|
||||
if (!keep.has(id)) {
|
||||
advanceEnvironmentSshStateGeneration(id)
|
||||
stateGenerations.advance(id)
|
||||
next.delete(id)
|
||||
changed = true
|
||||
}
|
||||
|
||||
@@ -83,34 +83,59 @@ function isRemovedSshTargetTabSession(
|
||||
return targetTabIds.has(tabId) || isSshTargetSessionId(sessionId, targetId)
|
||||
}
|
||||
|
||||
// Why: each returns undefined when nothing was removed, so the patch only carries changed fields.
|
||||
function filterRecordIfChanged<T>(
|
||||
record: Record<string, T>,
|
||||
keep: (key: string, value: T) => boolean
|
||||
): Record<string, T> | undefined {
|
||||
const entries = Object.entries(record)
|
||||
const kept = entries.filter(([key, value]) => keep(key, value))
|
||||
return kept.length === entries.length ? undefined : Object.fromEntries(kept)
|
||||
}
|
||||
|
||||
function omitKey<T>(record: Record<string, T>, key: string): Record<string, T> | undefined {
|
||||
if (!Object.hasOwn(record, key)) {
|
||||
return undefined
|
||||
}
|
||||
const { [key]: _removed, ...next } = record
|
||||
return next
|
||||
}
|
||||
|
||||
function withoutMapKey<V>(map: Map<string, V>, key: string): Map<string, V> | undefined {
|
||||
const next = new Map(map)
|
||||
return next.delete(key) ? next : undefined
|
||||
}
|
||||
|
||||
function withoutSetKey(set: Set<string>, key: string): Set<string> | undefined {
|
||||
const next = new Set(set)
|
||||
return next.delete(key) ? next : undefined
|
||||
}
|
||||
|
||||
function filterIfChanged<T>(items: T[], keep: (item: T) => boolean): T[] | undefined {
|
||||
const next = items.filter(keep)
|
||||
return next.length === items.length ? undefined : next
|
||||
}
|
||||
|
||||
function omitRemovedSshTargetTabSessions(
|
||||
sessions: Record<string, string>,
|
||||
targetId: string,
|
||||
targetTabIds: Set<string>
|
||||
): { next: Record<string, string>; removed: boolean } {
|
||||
const next: Record<string, string> = {}
|
||||
let removed = false
|
||||
for (const [tabId, sessionId] of Object.entries(sessions)) {
|
||||
if (isRemovedSshTargetTabSession(tabId, sessionId, targetId, targetTabIds)) {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
next[tabId] = sessionId
|
||||
}
|
||||
return { next, removed }
|
||||
): Record<string, string> | undefined {
|
||||
return filterRecordIfChanged(
|
||||
sessions,
|
||||
(tabId, sessionId) => !isRemovedSshTargetTabSession(tabId, sessionId, targetId, targetTabIds)
|
||||
)
|
||||
}
|
||||
|
||||
function omitRemovedSshTargetRecovery<T extends { authority: { targetId: string } }>(
|
||||
entries: Record<string, T>,
|
||||
targetId: string,
|
||||
targetTabIds: ReadonlySet<string>
|
||||
): { next: Record<string, T>; removed: boolean } {
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(entries).filter(
|
||||
([tabId, entry]) => !targetTabIds.has(tabId) && entry.authority.targetId !== targetId
|
||||
)
|
||||
): Record<string, T> | undefined {
|
||||
return filterRecordIfChanged(
|
||||
entries,
|
||||
(tabId, entry) => !targetTabIds.has(tabId) && entry.authority.targetId !== targetId
|
||||
)
|
||||
return { next, removed: Object.keys(next).length !== Object.keys(entries).length }
|
||||
}
|
||||
|
||||
function clearSshTargetTabPtyState(
|
||||
@@ -127,8 +152,12 @@ function clearSshTargetTabPtyState(
|
||||
> & { changed: boolean } {
|
||||
let nextTabsByWorktree = state.tabsByWorktree
|
||||
const nextPtyIdsByTabId = { ...state.ptyIdsByTabId }
|
||||
const nextLastKnownRelayPtyIdByTabId = { ...state.lastKnownRelayPtyIdByTabId }
|
||||
const nextPendingCodexPaneRestartIds = { ...state.pendingCodexPaneRestartIds }
|
||||
const nextLastKnownRelayPtyIdByTabId = {
|
||||
...state.lastKnownRelayPtyIdByTabId
|
||||
}
|
||||
const nextPendingCodexPaneRestartIds = {
|
||||
...state.pendingCodexPaneRestartIds
|
||||
}
|
||||
const nextCodexRestartNoticeByPtyId = { ...state.codexRestartNoticeByPtyId }
|
||||
let changed = false
|
||||
|
||||
@@ -188,118 +217,73 @@ export function buildRemovedSshTargetCleanupPatch(
|
||||
targetId: string
|
||||
): Partial<AppState> | null {
|
||||
const targetTabIds = collectSshTargetTerminalTabIds(state, targetId)
|
||||
const tabPtyState = clearSshTargetTabPtyState(state, targetId, targetTabIds)
|
||||
const { next: nextDeferredSessions, removed: removedDeferredSession } =
|
||||
const { changed: tabPtyChanged, ...tabPtyPatch } = clearSshTargetTabPtyState(
|
||||
state,
|
||||
targetId,
|
||||
targetTabIds
|
||||
)
|
||||
const patch: Partial<AppState> = tabPtyChanged ? tabPtyPatch : {}
|
||||
const assign = <K extends keyof AppState>(key: K, value: AppState[K] | undefined): void => {
|
||||
if (value !== undefined) {
|
||||
patch[key] = value
|
||||
}
|
||||
}
|
||||
assign(
|
||||
'deferredSshSessionIdsByTabId',
|
||||
omitRemovedSshTargetTabSessions(state.deferredSshSessionIdsByTabId, targetId, targetTabIds)
|
||||
)
|
||||
// Why: pending-reconnect holds each tab's pre-restart session until reconnect
|
||||
// drains it; if the target is removed first the entry is dead but the orphan
|
||||
// sweep now reads it as liveness, so clear it here too (#9911).
|
||||
const { next: nextPendingReconnect, removed: removedPendingReconnect } =
|
||||
assign(
|
||||
'pendingReconnectPtyIdByTabId',
|
||||
omitRemovedSshTargetTabSessions(state.pendingReconnectPtyIdByTabId, targetId, targetTabIds)
|
||||
const { next: nextPaneRetries, removed: removedPaneRetries } = omitRemovedSshTargetRecovery(
|
||||
state.directSshPaneRetryByTabId,
|
||||
targetId,
|
||||
targetTabIds
|
||||
)
|
||||
const { next: nextLiveBindings, removed: removedLiveBindings } = omitRemovedSshTargetRecovery(
|
||||
state.directSshLivePtyBindingByTabId,
|
||||
targetId,
|
||||
targetTabIds
|
||||
assign(
|
||||
'directSshPaneRetryByTabId',
|
||||
omitRemovedSshTargetRecovery(state.directSshPaneRetryByTabId, targetId, targetTabIds)
|
||||
)
|
||||
const { next: nextRetryHistory, removed: removedRetryHistory } = omitRemovedSshTargetRecovery(
|
||||
state.directSshPaneRetryHistoryByTabId,
|
||||
targetId,
|
||||
targetTabIds
|
||||
assign(
|
||||
'directSshLivePtyBindingByTabId',
|
||||
omitRemovedSshTargetRecovery(state.directSshLivePtyBindingByTabId, targetId, targetTabIds)
|
||||
)
|
||||
const nextPendingLayoutEdits = Object.fromEntries(
|
||||
Object.entries(state.pendingDirectSshLayoutEditsByTabId ?? {}).filter(
|
||||
([, entry]) => entry.targetId !== targetId
|
||||
assign(
|
||||
'directSshPaneRetryHistoryByTabId',
|
||||
omitRemovedSshTargetRecovery(state.directSshPaneRetryHistoryByTabId, targetId, targetTabIds)
|
||||
)
|
||||
assign(
|
||||
'pendingDirectSshLayoutEditsByTabId',
|
||||
filterRecordIfChanged(
|
||||
state.pendingDirectSshLayoutEditsByTabId ?? {},
|
||||
(_tabId, entry) => entry.targetId !== targetId
|
||||
)
|
||||
)
|
||||
const removedPendingLayoutEdits =
|
||||
Object.keys(nextPendingLayoutEdits).length !==
|
||||
Object.keys(state.pendingDirectSshLayoutEditsByTabId ?? {}).length
|
||||
|
||||
const nextDeferredTargets = state.deferredSshReconnectTargets.filter((id) => id !== targetId)
|
||||
const nextTransientClearedConnections = {
|
||||
...state.transientClearedAgentStatusConnectionIds
|
||||
}
|
||||
const removedTransientClearBlock = Object.hasOwn(nextTransientClearedConnections, targetId)
|
||||
delete nextTransientClearedConnections[targetId]
|
||||
const nextConnectionStates = new Map(state.sshConnectionStates)
|
||||
const removedConnectionState = nextConnectionStates.delete(targetId)
|
||||
const nextLabels = new Map(state.sshTargetLabels)
|
||||
const removedLabel = nextLabels.delete(targetId)
|
||||
assign(
|
||||
'deferredSshReconnectTargets',
|
||||
filterIfChanged(state.deferredSshReconnectTargets, (id) => id !== targetId)
|
||||
)
|
||||
assign(
|
||||
'sshCredentialQueue',
|
||||
filterIfChanged(state.sshCredentialQueue, (req) => req.targetId !== targetId)
|
||||
)
|
||||
assign(
|
||||
'transientClearedAgentStatusConnectionIds',
|
||||
omitKey(state.transientClearedAgentStatusConnectionIds, targetId)
|
||||
)
|
||||
assign(
|
||||
'remoteWorkspaceSyncStatusByTargetId',
|
||||
omitKey(state.remoteWorkspaceSyncStatusByTargetId, targetId)
|
||||
)
|
||||
assign('portForwardsByConnection', omitKey(state.portForwardsByConnection, targetId))
|
||||
assign('detectedPortsByConnection', omitKey(state.detectedPortsByConnection, targetId))
|
||||
assign('sshConnectionStates', withoutMapKey(state.sshConnectionStates, targetId))
|
||||
assign('sshTargetLabels', withoutMapKey(state.sshTargetLabels, targetId))
|
||||
// Why: a lingering generation would keep a deleted registration fenceable, and
|
||||
// the id is reissued fresh on re-add, so the old value can never become right.
|
||||
const nextGenerations = new Map(state.sshTargetGenerations)
|
||||
const removedGeneration = nextGenerations.delete(targetId)
|
||||
const nextHydrated = new Set(state.remoteWorkspaceHydratedTargetIds)
|
||||
const removedHydrated = nextHydrated.delete(targetId)
|
||||
const removedSyncStatus = Object.hasOwn(state.remoteWorkspaceSyncStatusByTargetId, targetId)
|
||||
const removedPortForwards = Object.hasOwn(state.portForwardsByConnection, targetId)
|
||||
const removedDetectedPorts = Object.hasOwn(state.detectedPortsByConnection, targetId)
|
||||
const nextSyncStatus = { ...state.remoteWorkspaceSyncStatusByTargetId }
|
||||
delete nextSyncStatus[targetId]
|
||||
const nextPortForwards = { ...state.portForwardsByConnection }
|
||||
delete nextPortForwards[targetId]
|
||||
const nextDetectedPorts = { ...state.detectedPortsByConnection }
|
||||
delete nextDetectedPorts[targetId]
|
||||
const nextCredentialQueue = state.sshCredentialQueue.filter((req) => req.targetId !== targetId)
|
||||
const removedCredentialRequest = nextCredentialQueue.length !== state.sshCredentialQueue.length
|
||||
const removedDeferredTarget =
|
||||
nextDeferredTargets.length !== state.deferredSshReconnectTargets.length
|
||||
const changed =
|
||||
removedTransientClearBlock ||
|
||||
removedConnectionState ||
|
||||
removedLabel ||
|
||||
removedGeneration ||
|
||||
removedHydrated ||
|
||||
removedSyncStatus ||
|
||||
removedPortForwards ||
|
||||
removedDetectedPorts ||
|
||||
tabPtyState.changed ||
|
||||
removedCredentialRequest ||
|
||||
removedDeferredTarget ||
|
||||
removedDeferredSession ||
|
||||
removedPendingReconnect ||
|
||||
removedPaneRetries ||
|
||||
removedLiveBindings ||
|
||||
removedRetryHistory ||
|
||||
removedPendingLayoutEdits
|
||||
if (!changed) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...(removedTransientClearBlock
|
||||
? { transientClearedAgentStatusConnectionIds: nextTransientClearedConnections }
|
||||
: {}),
|
||||
...(removedConnectionState ? { sshConnectionStates: nextConnectionStates } : {}),
|
||||
...(removedLabel ? { sshTargetLabels: nextLabels } : {}),
|
||||
...(removedGeneration ? { sshTargetGenerations: nextGenerations } : {}),
|
||||
...(removedHydrated ? { remoteWorkspaceHydratedTargetIds: nextHydrated } : {}),
|
||||
...(removedSyncStatus ? { remoteWorkspaceSyncStatusByTargetId: nextSyncStatus } : {}),
|
||||
...(removedPortForwards ? { portForwardsByConnection: nextPortForwards } : {}),
|
||||
...(removedDetectedPorts ? { detectedPortsByConnection: nextDetectedPorts } : {}),
|
||||
...(tabPtyState.changed
|
||||
? {
|
||||
tabsByWorktree: tabPtyState.tabsByWorktree,
|
||||
ptyIdsByTabId: tabPtyState.ptyIdsByTabId,
|
||||
lastKnownRelayPtyIdByTabId: tabPtyState.lastKnownRelayPtyIdByTabId,
|
||||
pendingCodexPaneRestartIds: tabPtyState.pendingCodexPaneRestartIds,
|
||||
codexRestartNoticeByPtyId: tabPtyState.codexRestartNoticeByPtyId
|
||||
}
|
||||
: {}),
|
||||
...(removedCredentialRequest ? { sshCredentialQueue: nextCredentialQueue } : {}),
|
||||
...(removedDeferredTarget ? { deferredSshReconnectTargets: nextDeferredTargets } : {}),
|
||||
...(removedDeferredSession ? { deferredSshSessionIdsByTabId: nextDeferredSessions } : {}),
|
||||
...(removedPendingReconnect ? { pendingReconnectPtyIdByTabId: nextPendingReconnect } : {}),
|
||||
...(removedPaneRetries ? { directSshPaneRetryByTabId: nextPaneRetries } : {}),
|
||||
...(removedLiveBindings ? { directSshLivePtyBindingByTabId: nextLiveBindings } : {}),
|
||||
...(removedRetryHistory ? { directSshPaneRetryHistoryByTabId: nextRetryHistory } : {}),
|
||||
...(removedPendingLayoutEdits
|
||||
? { pendingDirectSshLayoutEditsByTabId: nextPendingLayoutEdits }
|
||||
: {})
|
||||
}
|
||||
assign('sshTargetGenerations', withoutMapKey(state.sshTargetGenerations, targetId))
|
||||
assign(
|
||||
'remoteWorkspaceHydratedTargetIds',
|
||||
withoutSetKey(state.remoteWorkspaceHydratedTargetIds, targetId)
|
||||
)
|
||||
return Object.keys(patch).length > 0 ? patch : null
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
sshTargetLabelsEqual
|
||||
} from './ssh-target-cleanup'
|
||||
import { sshConnectionStatesEqual } from './ssh-connection-state-equality'
|
||||
import { createBoundedGenerationMap } from '../../lib/bounded-generation-map'
|
||||
|
||||
export type RemoteWorkspaceSyncStatus = {
|
||||
phase: 'idle' | 'pulling' | 'pushing' | 'synced' | 'conflict' | 'error' | 'offline'
|
||||
@@ -91,28 +92,10 @@ export type SshSlice = {
|
||||
setDetectedPorts: (targetId: string, ports: EnrichedDetectedPort[]) => void
|
||||
}
|
||||
|
||||
const targetConnectionGeneration = new Map<string, number>()
|
||||
const MAX_LOCAL_SSH_TARGET_GENERATIONS = 4096
|
||||
let targetConnectionGenerationSequence = 0
|
||||
let evictedTargetConnectionGeneration = 0
|
||||
const localTargetConnectionGenerations = createBoundedGenerationMap(4096)
|
||||
|
||||
export function getLocalSshTargetConnectionGeneration(targetId: string): number {
|
||||
return targetConnectionGeneration.get(targetId) ?? evictedTargetConnectionGeneration
|
||||
}
|
||||
|
||||
function advanceLocalSshTargetConnectionGeneration(targetId: string): void {
|
||||
targetConnectionGeneration.set(targetId, ++targetConnectionGenerationSequence)
|
||||
while (targetConnectionGeneration.size > MAX_LOCAL_SSH_TARGET_GENERATIONS) {
|
||||
const oldest = targetConnectionGeneration.keys().next()
|
||||
if (oldest.done) {
|
||||
break
|
||||
}
|
||||
evictedTargetConnectionGeneration = Math.max(
|
||||
evictedTargetConnectionGeneration,
|
||||
targetConnectionGeneration.get(oldest.value) ?? 0
|
||||
)
|
||||
targetConnectionGeneration.delete(oldest.value)
|
||||
}
|
||||
return localTargetConnectionGenerations.get(targetId)
|
||||
}
|
||||
|
||||
export const createSshSlice: StateCreator<AppState, [], [], SshSlice> = (set) => ({
|
||||
@@ -135,7 +118,7 @@ export const createSshSlice: StateCreator<AppState, [], [], SshSlice> = (set) =>
|
||||
if (sshConnectionStatesEqual(previous, state)) {
|
||||
return s
|
||||
}
|
||||
advanceLocalSshTargetConnectionGeneration(targetId)
|
||||
localTargetConnectionGenerations.advance(targetId)
|
||||
next.set(targetId, state)
|
||||
const didReconnect = previous?.status !== 'connected' && state.status === 'connected'
|
||||
let blockedConnections = s.transientClearedAgentStatusConnectionIds
|
||||
|
||||
@@ -59,21 +59,21 @@ describe('dropWorktreeRowsForRemovedRuntimeEnvironments', () => {
|
||||
}
|
||||
const result = dropWorktreeRowsForRemovedRuntimeEnvironments(rowsByRepo, new Set(['env-a']))
|
||||
expect(result.rowsByRepo.repo1).toEqual([row('w-local', 'local')])
|
||||
expect(result.removedWorktreeIds).toEqual(['w-a'])
|
||||
expect(result.removedRows.map((r) => r.id)).toEqual(['w-a'])
|
||||
})
|
||||
|
||||
it('returns the SAME reference with empty ids when nothing matches', () => {
|
||||
const rowsByRepo = { repo1: [row('w-local', 'local'), row('w-b', runtimeB)] }
|
||||
const result = dropWorktreeRowsForRemovedRuntimeEnvironments(rowsByRepo, new Set(['env-a']))
|
||||
expect(result.rowsByRepo).toBe(rowsByRepo)
|
||||
expect(result.removedWorktreeIds).toEqual([])
|
||||
expect(result.removedRows.map((r) => r.id)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns the SAME reference with empty ids when the removed set is empty', () => {
|
||||
const rowsByRepo = { repo1: [row('w-a', runtimeA)] }
|
||||
const result = dropWorktreeRowsForRemovedRuntimeEnvironments(rowsByRepo, new Set())
|
||||
expect(result.rowsByRepo).toBe(rowsByRepo)
|
||||
expect(result.removedWorktreeIds).toEqual([])
|
||||
expect(result.removedRows.map((r) => r.id)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the repo key even when all of its rows drop', () => {
|
||||
@@ -81,7 +81,7 @@ describe('dropWorktreeRowsForRemovedRuntimeEnvironments', () => {
|
||||
const result = dropWorktreeRowsForRemovedRuntimeEnvironments(rowsByRepo, new Set(['env-a']))
|
||||
expect(Object.keys(result.rowsByRepo)).toEqual(['repoA'])
|
||||
expect(result.rowsByRepo.repoA).toEqual([])
|
||||
expect(result.removedWorktreeIds).toEqual(['w-a1', 'w-a2'])
|
||||
expect(result.removedRows.map((r) => r.id)).toEqual(['w-a1', 'w-a2'])
|
||||
})
|
||||
|
||||
it('gives only the changed repo a new array; unchanged repos keep their reference', () => {
|
||||
@@ -111,6 +111,6 @@ describe('dropWorktreeRowsForRemovedRuntimeEnvironments', () => {
|
||||
|
||||
expect(result.rowsByRepo.removedRepo).toEqual([])
|
||||
expect(result.rowsByRepo.ambiguousRepo).toEqual([row('w-ambiguous')])
|
||||
expect(result.removedWorktreeIds).toEqual(['w-legacy'])
|
||||
expect(result.removedRows.map((r) => r.id)).toEqual(['w-legacy'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ export function isRemovedRuntimeHostId(
|
||||
|
||||
export type DropRuntimeRowsResult<T> = {
|
||||
rowsByRepo: Record<string, T[]>
|
||||
removedWorktreeIds: string[]
|
||||
removedRows: T[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,10 +35,10 @@ export function dropWorktreeRowsForRemovedRuntimeEnvironments<
|
||||
repoIdsWithoutSurvivingOwners?: ReadonlySet<string>
|
||||
): DropRuntimeRowsResult<T> {
|
||||
if (removedEnvironmentIds.size === 0) {
|
||||
return { rowsByRepo, removedWorktreeIds: [] }
|
||||
return { rowsByRepo, removedRows: [] }
|
||||
}
|
||||
let changed = false
|
||||
const removedWorktreeIds: string[] = []
|
||||
const removedRows: T[] = []
|
||||
const next: Record<string, T[]> = {}
|
||||
for (const [repoId, rows] of Object.entries(rowsByRepo)) {
|
||||
const survivors = rows.filter((row) => {
|
||||
@@ -48,7 +48,7 @@ export function dropWorktreeRowsForRemovedRuntimeEnvironments<
|
||||
isRemovedRuntimeHostId(row.hostId, removedEnvironmentIds) ||
|
||||
(row.hostId === undefined && repoIdsWithoutSurvivingOwners?.has(repoId) === true)
|
||||
) {
|
||||
removedWorktreeIds.push(row.id)
|
||||
removedRows.push(row)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -58,7 +58,7 @@ export function dropWorktreeRowsForRemovedRuntimeEnvironments<
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? { rowsByRepo: next, removedWorktreeIds } : { rowsByRepo, removedWorktreeIds: [] }
|
||||
return changed ? { rowsByRepo: next, removedRows } : { rowsByRepo, removedRows: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+17
-45
@@ -144,41 +144,6 @@ export function createPurgeStaleRuntimeHostState(
|
||||
seenRemovedWorktreeTargets.add(identity)
|
||||
removedWorktreeTargets.push(hostId ? { id: worktreeId, hostId } : { id: worktreeId })
|
||||
}
|
||||
const recordRemovedRowTargets = (
|
||||
rowsByRepo: Record<
|
||||
string,
|
||||
readonly {
|
||||
id: string
|
||||
hostId?: ExecutionHostId
|
||||
runtimeOwnerEnvironmentId?: string
|
||||
}[]
|
||||
>
|
||||
): void => {
|
||||
for (const [repoId, rows] of Object.entries(rowsByRepo)) {
|
||||
for (const row of rows) {
|
||||
const removedOwner =
|
||||
(row.runtimeOwnerEnvironmentId !== undefined &&
|
||||
removed.has(row.runtimeOwnerEnvironmentId)) ||
|
||||
isRemovedRuntimeHostId(row.hostId, removed) ||
|
||||
(row.hostId === undefined && repoIdsWithoutSurvivingOwners.has(repoId))
|
||||
if (!removedOwner) {
|
||||
continue
|
||||
}
|
||||
// A row's execution host is the recency owner. Runtime ownership
|
||||
// is the only host evidence available for legacy rows without it.
|
||||
addRemovedWorktreeTarget(
|
||||
row.id,
|
||||
row.hostId ??
|
||||
(row.runtimeOwnerEnvironmentId
|
||||
? toRuntimeExecutionHostId(row.runtimeOwnerEnvironmentId)
|
||||
: undefined)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
recordRemovedRowTargets(s.worktreesByRepo)
|
||||
recordRemovedRowTargets(detectedRows)
|
||||
|
||||
const worktreeDrop = dropWorktreeRowsForRemovedRuntimeEnvironments(
|
||||
s.worktreesByRepo,
|
||||
removed,
|
||||
@@ -193,9 +158,20 @@ export function createPurgeStaleRuntimeHostState(
|
||||
const worktreesChanged = worktreeDrop.rowsByRepo !== s.worktreesByRepo
|
||||
const detectedChanged = detectedDrop.rowsByRepo !== detectedRows
|
||||
|
||||
const removedRows = [...worktreeDrop.removedRows, ...detectedDrop.removedRows]
|
||||
for (const row of removedRows) {
|
||||
// A row's execution host is the recency owner. Runtime ownership
|
||||
// is the only host evidence available for legacy rows without it.
|
||||
addRemovedWorktreeTarget(
|
||||
row.id,
|
||||
row.hostId ??
|
||||
(row.runtimeOwnerEnvironmentId
|
||||
? toRuntimeExecutionHostId(row.runtimeOwnerEnvironmentId)
|
||||
: undefined)
|
||||
)
|
||||
}
|
||||
const removedWorktreeIds = new Set([
|
||||
...worktreeDrop.removedWorktreeIds,
|
||||
...detectedDrop.removedWorktreeIds,
|
||||
...removedRows.map((row) => row.id),
|
||||
...sessionWorktreeIdsOwnedByRemovedHosts
|
||||
])
|
||||
// Why: terminal tabs hydrate before worktree metadata, so session-only ids for owner-less repos still need purging.
|
||||
@@ -212,14 +188,10 @@ export function createPurgeStaleRuntimeHostState(
|
||||
}
|
||||
}
|
||||
// Why: bare-id state follows an exact survivor unless the restored-session partition proves it belonged to the removed host.
|
||||
for (const rows of Object.values(worktreeDrop.rowsByRepo)) {
|
||||
for (const row of rows) {
|
||||
if (!sessionWorktreeIdsOwnedByRemovedHosts.has(row.id)) {
|
||||
removedWorktreeIds.delete(row.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const rows of Object.values(detectedDrop.rowsByRepo)) {
|
||||
for (const rows of [
|
||||
...Object.values(worktreeDrop.rowsByRepo),
|
||||
...Object.values(detectedDrop.rowsByRepo)
|
||||
]) {
|
||||
for (const row of rows) {
|
||||
if (!sessionWorktreeIdsOwnedByRemovedHosts.has(row.id)) {
|
||||
removedWorktreeIds.delete(row.id)
|
||||
|
||||
Reference in New Issue
Block a user