mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/structured-chat-restore-settle-2
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
import { translateResult } from './agent-browser-bridge-result'
|
||||
import { AgentBrowserBridgeTabs } from './agent-browser-bridge-tabs'
|
||||
import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep'
|
||||
import { canSkipAgentBrowserSessionReset } from './agent-browser-session-reset'
|
||||
import {
|
||||
STALE_SESSION_CLOSE_TIMEOUT_MS,
|
||||
type AgentBrowserExecOptions,
|
||||
@@ -173,6 +174,15 @@ export abstract class AgentBrowserBridgeExecution extends AgentBrowserBridgeTabs
|
||||
}
|
||||
|
||||
protected closeStaleAgentBrowserSession(sessionName: string): Promise<void> {
|
||||
if (
|
||||
canSkipAgentBrowserSessionReset({
|
||||
ownsSocketDirectory: this.ownsAgentBrowserSocketDirectory,
|
||||
socketDirectory: this.agentBrowserEnv.AGENT_BROWSER_SOCKET_DIR,
|
||||
sessionName
|
||||
})
|
||||
) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let child: ReturnType<typeof execFile> | null = null
|
||||
let settled = false
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { execFileMock, webContentsFromIdMock, existsSyncMock, readFileSyncMock, stdinWrites } =
|
||||
vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
existsSyncMock: vi.fn(() => false),
|
||||
readFileSyncMock: vi.fn(() => Buffer.from('')),
|
||||
stdinWrites: [] as string[]
|
||||
}))
|
||||
const {
|
||||
execFileMock,
|
||||
webContentsFromIdMock,
|
||||
existsSyncMock,
|
||||
readFileSyncMock,
|
||||
lstatSyncMock,
|
||||
stdinWrites
|
||||
} = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
existsSyncMock: vi.fn(() => false),
|
||||
readFileSyncMock: vi.fn(() => Buffer.from('')),
|
||||
lstatSyncMock: vi.fn(),
|
||||
stdinWrites: [] as string[]
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({ execFile: execFileMock }))
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: existsSyncMock,
|
||||
readFileSync: readFileSyncMock,
|
||||
lstatSync: lstatSyncMock,
|
||||
accessSync: vi.fn(),
|
||||
chmodSync: vi.fn(),
|
||||
constants: { X_OK: 1 }
|
||||
@@ -73,6 +81,14 @@ function closeCallCount(): number {
|
||||
describe('AgentBrowserBridge', () => {
|
||||
let bridge: AgentBrowserBridge
|
||||
|
||||
// The mocked fs has no mkdirSync, so the constructor never claims a socket directory itself.
|
||||
function ownSocketDirectory(): void {
|
||||
Object.assign(bridge, {
|
||||
ownsAgentBrowserSocketDirectory: true,
|
||||
agentBrowserEnv: { AGENT_BROWSER_SOCKET_DIR: '/tmp/orca-ab-test' }
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAgentBrowserBridgeMocks({
|
||||
webContentsFromIdMock,
|
||||
@@ -81,11 +97,29 @@ describe('AgentBrowserBridge', () => {
|
||||
stdinWrites,
|
||||
cdpWsProxyInstances: CdpWsProxyMock.instances
|
||||
})
|
||||
// Default to a socket that exists so an unprepared test still takes the reset path.
|
||||
lstatSyncMock.mockReset()
|
||||
lstatSyncMock.mockReturnValue({})
|
||||
bridge = new AgentBrowserBridge(mockBrowserManager())
|
||||
bridge.setActiveTab(100)
|
||||
})
|
||||
|
||||
it('snapshots a fresh owned session without launching a helper just to close it', async () => {
|
||||
ownSocketDirectory()
|
||||
lstatSyncMock.mockImplementation(() => {
|
||||
throw Object.assign(new Error('No socket'), { code: 'ENOENT' })
|
||||
})
|
||||
webContentsFromIdMock.mockReturnValue(mockWebContents(100))
|
||||
succeedWith({ snapshot: 'ready' })
|
||||
|
||||
expect(await bridge.snapshot()).toMatchObject({ snapshot: 'ready' })
|
||||
expect(closeCallCount()).toBe(0)
|
||||
expect(lstatSyncMock).toHaveBeenCalledWith('/tmp/orca-ab-test/orca-tab-tab-1.sock')
|
||||
})
|
||||
|
||||
it('fails closed when stale agent-browser session ownership cannot be reset', async () => {
|
||||
ownSocketDirectory()
|
||||
lstatSyncMock.mockReturnValue({})
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const closeKill = vi.fn()
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const { lstatSync } = vi.hoisted(() => ({ lstatSync: vi.fn() }))
|
||||
vi.mock('node:fs', () => ({ lstatSync }))
|
||||
import { canSkipAgentBrowserSessionReset } from './agent-browser-session-reset'
|
||||
|
||||
const owned = {
|
||||
ownsSocketDirectory: true,
|
||||
socketDirectory: '/tmp/orca-ab-profile',
|
||||
sessionName: 'orca-tab-page'
|
||||
}
|
||||
const socketPath = join(owned.socketDirectory, 'orca-tab-page.sock')
|
||||
|
||||
beforeEach(() => {
|
||||
lstatSync.mockReset()
|
||||
})
|
||||
|
||||
it('skips an absent owned socket', () => {
|
||||
lstatSync.mockImplementation(() => {
|
||||
throw Object.assign(new Error('No socket'), { code: 'ENOENT' })
|
||||
})
|
||||
expect(canSkipAgentBrowserSessionReset(owned)).toBe(true)
|
||||
expect(lstatSync).toHaveBeenCalledWith(socketPath)
|
||||
})
|
||||
|
||||
it('requires reset when a socket or symlink exists', () => {
|
||||
lstatSync.mockReturnValue({})
|
||||
expect(canSkipAgentBrowserSessionReset(owned)).toBe(false)
|
||||
expect(lstatSync).toHaveBeenCalledWith(socketPath)
|
||||
})
|
||||
|
||||
it.each(['EACCES', 'EIO', 'ENOTDIR'])('requires reset for %s', (code) => {
|
||||
lstatSync.mockImplementation(() => {
|
||||
throw Object.assign(new Error('Socket inspection failed'), { code })
|
||||
})
|
||||
expect(canSkipAgentBrowserSessionReset(owned)).toBe(false)
|
||||
expect(lstatSync).toHaveBeenCalledWith(socketPath)
|
||||
})
|
||||
|
||||
// Windows and inherited socket directories both arrive as ownsSocketDirectory: false.
|
||||
it.each([
|
||||
{ ownsSocketDirectory: false },
|
||||
{ socketDirectory: undefined },
|
||||
{ sessionName: '../other' },
|
||||
{ sessionName: 'has space' },
|
||||
{ sessionName: '' }
|
||||
])('requires reset without an owned Unix socket address: %j', (override) => {
|
||||
expect(canSkipAgentBrowserSessionReset({ ...owned, ...override })).toBe(false)
|
||||
expect(lstatSync).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { lstatSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// agent-browser's own session-name rule; doubles as a traversal fence for the `join` below.
|
||||
const SAFE_SESSION_NAME = /^[A-Za-z0-9_-]+$/
|
||||
|
||||
/**
|
||||
* True when no daemon can be holding `sessionName`, so closing it would only start one.
|
||||
*
|
||||
* Only an Orca-derived socket directory proves that (`ownsSocketDirectory`): it is a
|
||||
* private per-profile `/tmp` directory, never an inherited one shared with a second
|
||||
* profile, and never Windows, which uses named pipes and leaves no socket to inspect.
|
||||
*/
|
||||
export function canSkipAgentBrowserSessionReset(options: {
|
||||
ownsSocketDirectory: boolean
|
||||
socketDirectory: string | undefined
|
||||
sessionName: string
|
||||
}): boolean {
|
||||
const { socketDirectory, sessionName } = options
|
||||
if (!options.ownsSocketDirectory || !socketDirectory || !SAFE_SESSION_NAME.test(sessionName)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
lstatSync(join(socketDirectory, `${sessionName}.sock`))
|
||||
return false
|
||||
} catch (error) {
|
||||
// Only a proven-absent socket is safe to skip; permission and other failures prove nothing.
|
||||
return (error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProcessTableRow } from '../../shared/process-table-snapshot'
|
||||
import type * as SnapshotReader from '../../shared/process-table-snapshot-reader'
|
||||
import { inspectTerminalHostProcess } from './terminal-host-process-inspection'
|
||||
import type { Session } from './session'
|
||||
|
||||
const { readSnapshot } = vi.hoisted(() => ({ readSnapshot: vi.fn() }))
|
||||
vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof SnapshotReader>()),
|
||||
getStrictProcessTableSnapshotWithAge: readSnapshot
|
||||
}))
|
||||
|
||||
function table(command: string | null): ProcessTableRow[] {
|
||||
const foregroundPgid = command === null ? 100 : 101
|
||||
const shell: ProcessTableRow = {
|
||||
pid: 100,
|
||||
ppid: 1,
|
||||
pgid: 100,
|
||||
tpgid: foregroundPgid,
|
||||
tty: 'pts/1',
|
||||
startTime: 'shell-start',
|
||||
stat: command === null ? 'Ss+' : 'Ss',
|
||||
command: '/bin/bash'
|
||||
}
|
||||
return command === null
|
||||
? [shell]
|
||||
: [
|
||||
shell,
|
||||
{
|
||||
...shell,
|
||||
pid: 101,
|
||||
ppid: 100,
|
||||
pgid: 101,
|
||||
stat: 'S+',
|
||||
startTime: 'command-start',
|
||||
command
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async function inspect(rawName: string, command: string | null) {
|
||||
readSnapshot.mockResolvedValue({ rows: table(command), capturedAgeMs: 0 })
|
||||
return inspectTerminalHostProcess({
|
||||
sessionId: 'busy-tab',
|
||||
session: {
|
||||
pid: 100,
|
||||
incarnationId: 'incarnation-1',
|
||||
isAlive: true,
|
||||
getForegroundProcess: () => rawName
|
||||
} as unknown as Session,
|
||||
authorityGeneration: 'generation-1',
|
||||
nextObservationEpoch: () => 1
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
readSnapshot.mockClear()
|
||||
})
|
||||
|
||||
describe.each(['linux', 'darwin'] as const)('daemon ordinary foreground on %s', (platform) => {
|
||||
it.each(['sleep', 'vim', 'node'])(
|
||||
'retains the running %s name alongside agent-only evidence',
|
||||
async (name) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
const result = await inspect(name, `${name} 300`)
|
||||
expect(result).toMatchObject({
|
||||
foregroundProcess: name,
|
||||
hasChildProcesses: true,
|
||||
foregroundProcessEvidence: { verdict: 'live', processName: null }
|
||||
})
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('still clears a stale recognized agent after its process exits', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
expect(await inspect('claude', null)).toMatchObject({
|
||||
foregroundProcess: null,
|
||||
foregroundProcessEvidence: { verdict: 'live', processName: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('still reports no foreground command for an idle shell', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
expect(await inspect('bash', null)).toMatchObject({
|
||||
foregroundProcess: null,
|
||||
hasChildProcesses: false,
|
||||
foregroundProcessEvidence: { verdict: 'live', processName: null }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isShellProcess } from '../../shared/agent-detection'
|
||||
import { recognizeAgentProcess } from '../../shared/agent-process-recognition'
|
||||
import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence'
|
||||
import { getCheapProcessTableSnapshot } from '../../shared/cheap-process-table-snapshot-reader'
|
||||
import { getStrictProcessTableSnapshotWithAge } from '../../shared/process-table-snapshot-reader'
|
||||
@@ -100,9 +101,16 @@ export async function inspectTerminalHostProcess(args: {
|
||||
clearSteadyStateAnchor(session)
|
||||
}
|
||||
}
|
||||
const nonShellForeground = foregroundProcess !== null && !isShellProcess(foregroundProcess)
|
||||
// Evidence names recognized agents only, so its null must not erase an ordinary command (#18078).
|
||||
const ordinaryForeground =
|
||||
nonShellForeground && !recognizeAgentProcess(foregroundProcess) ? foregroundProcess : null
|
||||
return {
|
||||
foregroundProcess: evidence.verdict === 'live' ? evidence.processName : foregroundProcess,
|
||||
hasChildProcesses: foregroundProcess !== null && !isShellProcess(foregroundProcess),
|
||||
foregroundProcess:
|
||||
evidence.verdict === 'live'
|
||||
? (evidence.processName ?? ordinaryForeground)
|
||||
: foregroundProcess,
|
||||
hasChildProcesses: nonShellForeground,
|
||||
foregroundProcessEvidence: evidence
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Why this file exists: deregistering a project used to strand every row it owned. No sweeper could
|
||||
// reach them -- the missing-directory prune is gated on the repo still being registered, and a
|
||||
// paired client's mirror of a remote host's rows is keyed by ids that client never registers, so the
|
||||
// owning host's removal never reached it (#17776).
|
||||
// reach them because the missing-directory prune is gated on the repo still being registered.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { rmSync, mkdtempSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
@@ -103,7 +101,7 @@ describe('deregistered repo residue', () => {
|
||||
expect(session.sleepingAgentSessionsByPaneKey ?? {}).toEqual({})
|
||||
})
|
||||
|
||||
it("sweeps a remote host's session partition the owning host's removal can never reach", async () => {
|
||||
it('keeps a remote session whose repo is not registered on the desktop', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })],
|
||||
@@ -117,8 +115,10 @@ describe('deregistered repo residue', () => {
|
||||
store.flush()
|
||||
|
||||
const partition = store.getWorkspaceSession(RUNTIME_HOST)
|
||||
expect(partition.tabsByWorktree).toEqual({})
|
||||
expect(partition.activeTabTypeByWorktree).toEqual({})
|
||||
expect(partition.tabsByWorktree[GONE_WORKTREE]).toHaveLength(1)
|
||||
expect(partition.activeTabTypeByWorktree).toEqual(
|
||||
sessionFor(GONE_WORKTREE).activeTabTypeByWorktree
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps rows for every registered repo, on any execution host', async () => {
|
||||
@@ -204,15 +204,13 @@ describe('deregistered repo residue', () => {
|
||||
schemaVersion: 1,
|
||||
repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })],
|
||||
worktreeMeta: {},
|
||||
workspaceSessionsByHostId: {
|
||||
[RUNTIME_HOST]: { ...getDefaultWorkspaceSession(), ...session }
|
||||
}
|
||||
workspaceSession: { ...getDefaultWorkspaceSession(), ...session }
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
store.flush()
|
||||
|
||||
const partition = store.getWorkspaceSession(RUNTIME_HOST)
|
||||
const partition = store.getWorkspaceSession()
|
||||
expect(partition.activeWorktreeId ?? null).toBeNull()
|
||||
expect(partition.activeWorkspaceKey ?? null).toBeNull()
|
||||
expect(partition.activeWorktreeIdsOnShutdown ?? []).toEqual([])
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { getDefaultWorkspaceSession } from '../shared/constants'
|
||||
import type { BrowserPage, BrowserWorkspace } from '../shared/browser-workspace-types'
|
||||
import { createStore, makeRepo, testState } from './persistence-test-harness'
|
||||
|
||||
vi.mock('./ssh/ssh-config-parser', () => ({
|
||||
loadUserSshConfig: vi.fn(),
|
||||
sshConfigHostsToTargets: vi.fn()
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => testState.dir },
|
||||
safeStorage: { isEncryptionAvailable: () => false }
|
||||
}))
|
||||
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) }))
|
||||
|
||||
const HOST = 'runtime:paired-host'
|
||||
const REPO = 'remote-repo'
|
||||
const WORKTREE = `${REPO}::/remote/project`
|
||||
const PAGE: BrowserPage = {
|
||||
id: 'page-1',
|
||||
workspaceId: 'browser-1',
|
||||
worktreeId: WORKTREE,
|
||||
url: 'https://example.test/moved',
|
||||
title: 'Moved page',
|
||||
loading: false,
|
||||
canGoBack: true,
|
||||
canGoForward: false,
|
||||
faviconUrl: null,
|
||||
loadError: null,
|
||||
createdAt: 1,
|
||||
browserRuntimeEnvironmentId: 'paired-host',
|
||||
remoteBrowserPageId: 'remote-page-1',
|
||||
remoteBrowserPageClientHosted: true
|
||||
}
|
||||
const BROWSER: BrowserWorkspace = {
|
||||
id: PAGE.workspaceId,
|
||||
worktreeId: WORKTREE,
|
||||
sessionProfileId: null,
|
||||
activePageId: PAGE.id,
|
||||
pageIds: [PAGE.id],
|
||||
url: PAGE.url,
|
||||
title: PAGE.title,
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: true,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
|
||||
function browserSession() {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
browserTabsByWorktree: { [WORKTREE]: [BROWSER] },
|
||||
browserPagesByWorkspace: { [BROWSER.id]: [PAGE] },
|
||||
activeBrowserTabIdByWorktree: { [WORKTREE]: BROWSER.id }
|
||||
}
|
||||
}
|
||||
|
||||
describe('remote session startup ownership', () => {
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'orca-remote-session-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('keeps a paired browser row and its hosting identity across two Store reloads', () => {
|
||||
const seed = createStore()
|
||||
seed.addRepo(makeRepo({ id: 'local-repo', path: join(testState.dir, 'local') }))
|
||||
seed.setWorkspaceSession(browserSession(), HOST)
|
||||
seed.flush()
|
||||
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
const reloaded = createStore()
|
||||
expect(reloaded.getWorkspaceSession(HOST).browserPagesByWorkspace).toEqual({
|
||||
[BROWSER.id]: [PAGE]
|
||||
})
|
||||
expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([])
|
||||
reloaded.flush()
|
||||
}
|
||||
})
|
||||
|
||||
it('retains remote metadata when no session or local catalog row names its repo', () => {
|
||||
const seed = createStore()
|
||||
seed.setWorktreeMetaForHost(WORKTREE, HOST, { displayName: 'Remote work' })
|
||||
seed.flush()
|
||||
const reloaded = createStore()
|
||||
expect(reloaded.getWorktreeMeta(WORKTREE)).toMatchObject({ displayName: 'Remote work' })
|
||||
expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([])
|
||||
reloaded.flush()
|
||||
})
|
||||
|
||||
it('still applies an explicit remote project removal', () => {
|
||||
const seed = createStore()
|
||||
seed.setWorkspaceSession(browserSession(), HOST)
|
||||
seed.flush()
|
||||
const reloaded = createStore()
|
||||
// Assert the row survived load first, or an empty partition below would prove nothing.
|
||||
expect(reloaded.getWorkspaceSession(HOST).browserPagesByWorkspace).not.toEqual({})
|
||||
reloaded.removeProjectForHost(REPO, HOST)
|
||||
reloaded.flush()
|
||||
expect(createStore().getWorkspaceSession(HOST).browserPagesByWorkspace).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -136,11 +136,9 @@ export class RepoLifecycleOperations {
|
||||
/**
|
||||
* Drop every persisted row owned by a repo id that is no longer registered.
|
||||
*
|
||||
* Runs at load because no removal path can: `removeProject` only fires while the repo is still in
|
||||
* `state.repos`, and a paired client's mirror of a remote host's rows is keyed by ids that client
|
||||
* never registers, so the owning host's removal never reaches it (#17776). An orphan has no owner
|
||||
* that could object, so this ignores the session-ownership and local-execution-host gates the
|
||||
* missing-directory sweeper needs.
|
||||
* Runs at load to reach leftover local rows after deregistration. Rows owned by a `runtime:*`
|
||||
* host are exempt: this runs before pairing, so their absence from the local catalog cannot
|
||||
* establish deletion. Only an explicit `removeProjectForHost` retires them.
|
||||
*/
|
||||
sweepDeregisteredRepoResidue(): string[] {
|
||||
const state = this[repoLifecycleOperationsContext].runtime.state
|
||||
|
||||
@@ -209,7 +209,7 @@ export function collectWorkspaceSessionWorktreeOwners(
|
||||
return owners
|
||||
}
|
||||
|
||||
function addWorkspaceSessionWorktreeOwners(
|
||||
export function addWorkspaceSessionWorktreeOwners(
|
||||
session: WorkspaceSessionState,
|
||||
collector: WorktreeOwnerCandidateCollector
|
||||
): void {
|
||||
|
||||
@@ -1,19 +1,61 @@
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import { getWorktreeIdFromHostIdentity } from '../../../shared/worktree/host-qualified-identity'
|
||||
import {
|
||||
getExecutionHostIdFromWorktreeHostIdentity,
|
||||
getWorktreeIdFromHostIdentity
|
||||
} from '../../../shared/worktree/host-qualified-identity'
|
||||
import { parseExecutionHostId } from '../../../shared/execution-host'
|
||||
import { addWorkspaceSessionWorktreeOwners } from '../restoring-sessions/session-worktree-ownership'
|
||||
import { splitWorktreeId } from '../../../shared/worktree/id'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition'
|
||||
import { ownerKeyWorktreeIds } from '../../orca-profiles/profile-project-worktree-identity'
|
||||
|
||||
/** A `runtime:*` host addresses a paired Orca desktop's rows, whose catalog lives on that host. */
|
||||
const isPairedHost = (hostId: string | null | undefined): boolean =>
|
||||
parseExecutionHostId(hostId)?.kind === 'runtime'
|
||||
|
||||
/** Repo ids an owner key can name, across both readings (see `ownerKeyWorktreeIds`). */
|
||||
function ownerKeyRepoIds(ownerKey: string | null | undefined): string[] {
|
||||
return ownerKey
|
||||
? ownerKeyWorktreeIds(ownerKey).flatMap((worktreeId) => {
|
||||
const repoId = splitWorktreeId(worktreeId)?.repoId
|
||||
return repoId ? [repoId] : []
|
||||
})
|
||||
: []
|
||||
}
|
||||
|
||||
/**
|
||||
* Repo ids that still own persisted rows but no longer appear in `state.repos`.
|
||||
*
|
||||
* Why nothing else finds them: every other sweeper is gated on the repo still being registered, so
|
||||
* deregistering a project stranded the rows it owned permanently — including a paired client's
|
||||
* mirror of a remote host's session partition, which no local repo removal can reach (#17776).
|
||||
* Rows owned by a `runtime:*` host are held live instead of swept: a paired client mirrors that
|
||||
* host's sessions without ever registering its repos, and this runs in the Store constructor,
|
||||
* before pairing, so catalog absence there proves nothing (#17776 read it as proof and deleted
|
||||
* live sessions). The cost is that residue outliving a removal is no longer swept for those hosts.
|
||||
*/
|
||||
export function collectDeregisteredRepoIds(state: PersistedState): Set<string> {
|
||||
const liveRepoIds = new Set(state.repos.map((repo) => repo.id))
|
||||
const retainOwner = (ownerKey: string | null | undefined): void => {
|
||||
for (const repoId of ownerKeyRepoIds(ownerKey)) {
|
||||
liveRepoIds.add(repoId)
|
||||
}
|
||||
}
|
||||
// `owners` goes unread: the walker only ever calls `addOwner`.
|
||||
const retainCollector = { owners: new Set<string>(), addOwner: retainOwner }
|
||||
for (const [hostId, session] of Object.entries(state.workspaceSessionsByHostId ?? {})) {
|
||||
if (session && isPairedHost(hostId)) {
|
||||
addWorkspaceSessionWorktreeOwners(session, retainCollector)
|
||||
}
|
||||
}
|
||||
for (const [worktreeId, meta] of Object.entries(state.worktreeMeta)) {
|
||||
if (isPairedHost(meta.hostId)) {
|
||||
retainOwner(worktreeId)
|
||||
}
|
||||
}
|
||||
for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) {
|
||||
if (isPairedHost(getExecutionHostIdFromWorktreeHostIdentity(alias))) {
|
||||
retainOwner(getWorktreeIdFromHostIdentity(alias))
|
||||
}
|
||||
}
|
||||
const orphanRepoIds = new Set<string>()
|
||||
// Only a full `<repoId>::<path>` locator seeds the set. A bare key -- a folder workspace id, a
|
||||
// repo-keyed topology revision, a test-shaped locator -- cannot be told apart from a repo id, and
|
||||
@@ -30,10 +72,7 @@ export function collectDeregisteredRepoIds(state: PersistedState): Set<string> {
|
||||
* other reading would hand the removal pass -- which accepts either -- a live row to delete.
|
||||
*/
|
||||
const addOwnerKey = (ownerKey: string): void => {
|
||||
const repoIds = ownerKeyWorktreeIds(ownerKey).flatMap((worktreeId) => {
|
||||
const repoId = splitWorktreeId(worktreeId)?.repoId
|
||||
return repoId ? [repoId] : []
|
||||
})
|
||||
const repoIds = ownerKeyRepoIds(ownerKey)
|
||||
if (repoIds.length > 0 && repoIds.every((repoId) => !liveRepoIds.has(repoId))) {
|
||||
for (const repoId of repoIds) {
|
||||
orphanRepoIds.add(repoId)
|
||||
|
||||
@@ -221,13 +221,13 @@ describe('resolveAgentForegroundProcess', () => {
|
||||
})
|
||||
|
||||
it('confirms a quoted login shell only when its fresh PTY tree contains shells', async () => {
|
||||
mockPs(['100 99 Ss+ "/bin/zsh" -l', '101 100 S+ /bin/bash'].join('\n'))
|
||||
mockPs(['100 99 100 100 Ss+ "/bin/zsh" -l', '101 100 101 100 S+ /bin/bash'].join('\n'))
|
||||
|
||||
await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('uses spawned-shell identity instead of a lagging foreground child label', async () => {
|
||||
mockPs(['100 99 Ss+ /bin/zsh -l'].join('\n'))
|
||||
mockPs(['100 99 100 100 Ss+ /bin/zsh -l'].join('\n'))
|
||||
|
||||
await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true)
|
||||
})
|
||||
@@ -235,11 +235,11 @@ describe('resolveAgentForegroundProcess', () => {
|
||||
it('confirms the spawned shell behind a login wrapper while prompt hooks run', async () => {
|
||||
mockPs(
|
||||
[
|
||||
'100 99 Ss /usr/bin/login -pfl developer /bin/zsh',
|
||||
'101 100 S+ -zsh',
|
||||
'102 101 S+ (zsh)',
|
||||
'103 102 S+ (sed)',
|
||||
'104 102 R+ (git)'
|
||||
'100 99 100 101 Ss /usr/bin/login -pfl developer /bin/zsh',
|
||||
'101 100 101 101 S+ -zsh',
|
||||
'102 101 101 101 S+ (zsh)',
|
||||
'103 102 101 101 S+ (sed)',
|
||||
'104 102 101 101 R+ (git)'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
@@ -249,10 +249,10 @@ describe('resolveAgentForegroundProcess', () => {
|
||||
it('rejects a foreground nested shell while the spawned shell remains suspended', async () => {
|
||||
mockPs(
|
||||
[
|
||||
'100 99 Ss /usr/bin/login -pfl developer /bin/zsh',
|
||||
'101 100 S -zsh',
|
||||
'102 101 S+ agent-tui',
|
||||
'103 102 S+ /bin/zsh -i'
|
||||
'100 99 100 102 Ss /usr/bin/login -pfl developer /bin/zsh',
|
||||
'101 100 101 102 S -zsh',
|
||||
'102 101 102 102 S+ agent-tui',
|
||||
'103 102 102 102 S+ /bin/zsh -i'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
@@ -262,9 +262,9 @@ describe('resolveAgentForegroundProcess', () => {
|
||||
it('rejects shell ownership while a TUI and its nested shell remain in the PTY tree', async () => {
|
||||
mockPs(
|
||||
[
|
||||
'100 99 Ss /bin/zsh -l',
|
||||
'101 100 S+ /usr/local/bin/agent-tui',
|
||||
'102 101 S+ /bin/bash -i'
|
||||
'100 99 100 101 Ss /bin/zsh -l',
|
||||
'101 100 101 101 S+ /usr/local/bin/agent-tui',
|
||||
'102 101 101 101 S+ /bin/bash -i'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
@@ -272,7 +272,7 @@ describe('resolveAgentForegroundProcess', () => {
|
||||
})
|
||||
|
||||
it('rejects shell ownership while a stopped TUI remains resumable', async () => {
|
||||
mockPs(['100 99 Ss+ /bin/zsh -l', '101 100 T /usr/local/bin/agent-tui'].join('\n'))
|
||||
mockPs(['100 99 100 100 Ss+ /bin/zsh -l', '101 100 101 100 T agent-tui'].join('\n'))
|
||||
|
||||
await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wr
|
||||
import type { ProcessTableRow } from '../../shared/process-table-snapshot'
|
||||
import {
|
||||
getFreshProcessTableSnapshot,
|
||||
getFreshShellForegroundSnapshot,
|
||||
getProcessTableSnapshot
|
||||
} from '../../shared/process-table-snapshot-reader'
|
||||
import { collectDescendantsFromIndex, getProcessTableIndex } from '../../shared/process-table-index'
|
||||
@@ -76,7 +77,7 @@ export async function confirmShellForegroundProcess(
|
||||
}
|
||||
}
|
||||
try {
|
||||
const index = getProcessTableIndex(await getFreshProcessTableSnapshot())
|
||||
const index = getProcessTableIndex(await getFreshShellForegroundSnapshot())
|
||||
const root = index.byPid.get(shellPid)
|
||||
if (!root) {
|
||||
return false
|
||||
|
||||
@@ -2,8 +2,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
|
||||
import { applyBrowserSessionTabSelection } from './browser-session-tab-selection-snapshot'
|
||||
|
||||
const EPOCH = 'headless:test'
|
||||
|
||||
function makeSnapshot(): RuntimeMobileSessionTabsSnapshot {
|
||||
return {
|
||||
worktree: 'wt-1',
|
||||
@@ -46,8 +44,7 @@ function select(overrides: { focusesHost: boolean; targetGroupId?: string }) {
|
||||
snapshot: makeSnapshot(),
|
||||
tabId: 'page-new',
|
||||
focusesHost: overrides.focusesHost,
|
||||
...(overrides.targetGroupId ? { targetGroupId: overrides.targetGroupId } : {}),
|
||||
publicationEpoch: EPOCH
|
||||
...(overrides.targetGroupId ? { targetGroupId: overrides.targetGroupId } : {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,10 +112,11 @@ describe('applyBrowserSessionTabSelection', () => {
|
||||
expect(snapshot.activeGroupId).toBe('group-left')
|
||||
})
|
||||
|
||||
it('republishes under a fresh epoch and a newer version either way', () => {
|
||||
// Rotating the epoch here retires the renderer's own publication client-side.
|
||||
it('keeps the publication epoch and advances the version either way', () => {
|
||||
for (const focusesHost of [true, false]) {
|
||||
const { snapshot } = select({ focusesHost })
|
||||
expect(snapshot.publicationEpoch).toBe(EPOCH)
|
||||
expect(snapshot.publicationEpoch).toBe('headless:before')
|
||||
expect(snapshot.snapshotVersion).toBe(5)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,7 +22,6 @@ export function applyBrowserSessionTabSelection(args: {
|
||||
tabId: string
|
||||
targetGroupId?: string
|
||||
focusesHost: boolean
|
||||
publicationEpoch: string
|
||||
}): BrowserSessionTabSelectionResult {
|
||||
const { snapshot, tabId, targetGroupId, focusesHost } = args
|
||||
const groups = snapshot.tabGroups ?? []
|
||||
@@ -56,7 +55,6 @@ export function applyBrowserSessionTabSelection(args: {
|
||||
placedInTargetGroup,
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
publicationEpoch: args.publicationEpoch,
|
||||
snapshotVersion: snapshot.snapshotVersion + 1,
|
||||
...(placedInTargetGroup && focusesHost ? { activeGroupId: targetGroupId } : {}),
|
||||
...(focusesHost
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createRuntime, syncSinglePty } from './orca-runtime-test-fixtures.spec'
|
||||
|
||||
describe('hidden-output recovery after provider reattach', () => {
|
||||
it('uses retained provider modes instead of the pre-attach redraw suffix', async () => {
|
||||
const runtime = createRuntime()
|
||||
const serializeProviderBuffer = vi.fn(async () => ({
|
||||
data: '\x1b[?1049hRetained TUI',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
seq: 1000,
|
||||
source: 'headless' as const,
|
||||
alternateScreen: true
|
||||
}))
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer
|
||||
})
|
||||
syncSinglePty(runtime, 'pty-1')
|
||||
runtime.onPtyData('pty-1', '\x1b[HRedraw without the original alternate-screen entry', 60)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider('pty-1', {
|
||||
value: 1000,
|
||||
generation: 'continued'
|
||||
})
|
||||
|
||||
const snapshot = await runtime.serializeHiddenOutputRecoveryBuffer('pty-1', {
|
||||
scrollbackRows: 5000
|
||||
})
|
||||
|
||||
expect(snapshot).toMatchObject({ data: '\x1b[?1049hRetained TUI', alternateScreen: true })
|
||||
expect(serializeProviderBuffer).toHaveBeenCalledWith('pty-1', { scrollbackRows: 5000 })
|
||||
})
|
||||
|
||||
it('keeps the renderer fallback for providers without retained snapshots', async () => {
|
||||
const runtime = createRuntime()
|
||||
runtime.onPtyData('pty-1', 'partial redraw', 14)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider('pty-1', {
|
||||
value: 1000,
|
||||
generation: 'continued'
|
||||
})
|
||||
const serializeBuffer = vi.fn(async () => ({
|
||||
data: '\x1b[?1049hRenderer TUI',
|
||||
cols: 100,
|
||||
rows: 30
|
||||
}))
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
hasRendererSerializer: () => true,
|
||||
serializeBuffer
|
||||
})
|
||||
|
||||
await expect(runtime.serializeHiddenOutputRecoveryBuffer('pty-1')).resolves.toMatchObject({
|
||||
data: '\x1b[?1049hRenderer TUI',
|
||||
source: 'renderer'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an authoritative main model without polling the provider', async () => {
|
||||
const runtime = createRuntime()
|
||||
const serializeProviderBuffer = vi.fn(async () => null)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer
|
||||
})
|
||||
runtime.onPtyData('pty-1', '\x1b[?1049hLive TUI', 20)
|
||||
|
||||
await expect(runtime.serializeHiddenOutputRecoveryBuffer('pty-1')).resolves.toMatchObject({
|
||||
alternateScreen: true,
|
||||
source: 'headless'
|
||||
})
|
||||
expect(serializeProviderBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -206,8 +206,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi
|
||||
snapshot,
|
||||
tabId: tab.id,
|
||||
...(targetGroupId !== undefined ? { targetGroupId } : {}),
|
||||
focusesHost,
|
||||
publicationEpoch: `headless:${Date.now().toString(36)}`
|
||||
focusesHost
|
||||
})
|
||||
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
|
||||
// Why: browser group membership is otherwise live-only; persist it so a
|
||||
|
||||
@@ -120,7 +120,8 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca
|
||||
}
|
||||
const next: RuntimeMobileSessionTabsSnapshot = {
|
||||
worktree: worktreeId,
|
||||
publicationEpoch: `headless:${Date.now().toString(36)}`,
|
||||
// Why: a fresh epoch retires the current publisher, so clients drop its later tab updates.
|
||||
publicationEpoch: existing?.publicationEpoch ?? `headless:${Date.now().toString(36)}`,
|
||||
snapshotVersion: (existing?.snapshotVersion ?? 0) + 1,
|
||||
// Why: activating the new tab also focuses its group, so a "+" targeting a specific split group makes that group active too.
|
||||
activeGroupId:
|
||||
|
||||
@@ -25,11 +25,28 @@ export class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs extends Or
|
||||
worktreeId: string,
|
||||
existing: RuntimeMobileSessionTabsSnapshot
|
||||
): void {
|
||||
const liveBrowserTabs = this.buildHeadlessMobileSessionBrowserTabs(worktreeId)
|
||||
const liveIds = liveBrowserTabs.map((tab) => tab.id)
|
||||
const existingBrowserTabs = existing.tabs.filter(
|
||||
(tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser'
|
||||
)
|
||||
const publishedBrowserTabs = this.buildHeadlessMobileSessionBrowserTabs(worktreeId)
|
||||
// An attached renderer owns its browser rows; the client-page registry cannot retire them.
|
||||
const rendererBrowserTabs =
|
||||
this.getAvailableAuthoritativeWindow() && !this.offscreenBrowserBackend
|
||||
? existingBrowserTabs.filter((tab) => tab.placement?.kind !== 'client')
|
||||
: []
|
||||
// Keyed by id so no row can publish twice whatever the two sources overlap on; a freshly
|
||||
// built row wins over the retained one it replaces.
|
||||
const liveById = new Map(
|
||||
[...rendererBrowserTabs, ...publishedBrowserTabs].map((tab) => [tab.id, tab])
|
||||
)
|
||||
// Emit in the order the snapshot already had, because the equality check below compares by
|
||||
// index: rebuilding renderer-first would read a pure reordering as a change and republish.
|
||||
const retainedInOrder = existingBrowserTabs.flatMap((tab) => {
|
||||
const live = liveById.get(tab.id)
|
||||
return live && liveById.delete(tab.id) ? [live] : []
|
||||
})
|
||||
const liveBrowserTabs = [...retainedInOrder, ...liveById.values()]
|
||||
const liveIds = liveBrowserTabs.map((tab) => tab.id)
|
||||
const existingBrowserIds = existingBrowserTabs.map((tab) => tab.id)
|
||||
if (headlessBrowserTabsUnchanged(liveBrowserTabs, existingBrowserTabs)) {
|
||||
return
|
||||
@@ -53,7 +70,6 @@ export class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs extends Or
|
||||
: (nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null)
|
||||
this.storeMobileSessionSnapshot(worktreeId, {
|
||||
...existing,
|
||||
publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`,
|
||||
snapshotVersion: existing.snapshotVersion + 1,
|
||||
...(activeStillPresent
|
||||
? {}
|
||||
|
||||
@@ -47,6 +47,10 @@ export class OrcaRuntimeWithSerializeMainTerminalBuffer extends OrcaRuntimeWithA
|
||||
pendingEscapeTailAnsi?: string
|
||||
terminalOwner?: 'shell'
|
||||
} | null> {
|
||||
const restoredSnapshot = await this.serializePreferredRestoredTerminalBuffer(ptyId, opts)
|
||||
if (restoredSnapshot) {
|
||||
return restoredSnapshot
|
||||
}
|
||||
const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, {
|
||||
...opts,
|
||||
includeEmpty: true
|
||||
|
||||
@@ -23,18 +23,9 @@ export class OrcaRuntimeWithSerializeTerminalBufferFromAvailableState extends Or
|
||||
kittyKeyboardFlags?: number
|
||||
terminalOwner?: 'shell'
|
||||
} | null> {
|
||||
if (this.providerSnapshotPreferredPtys.has(ptyId)) {
|
||||
// Why: pre-attach stream bytes only form a suffix of restored state. A
|
||||
// sequenced provider snapshot safely reconciles live bytes; renderer is
|
||||
// the fallback when an older provider cannot expose that boundary.
|
||||
const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts)
|
||||
if (providerSnapshot) {
|
||||
return providerSnapshot
|
||||
}
|
||||
const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts)
|
||||
if (rendererSnapshot) {
|
||||
return rendererSnapshot
|
||||
}
|
||||
const restoredSnapshot = await this.serializePreferredRestoredTerminalBuffer(ptyId, opts)
|
||||
if (restoredSnapshot) {
|
||||
return restoredSnapshot
|
||||
}
|
||||
const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts)
|
||||
if (headlessSnapshot) {
|
||||
@@ -58,6 +49,20 @@ export class OrcaRuntimeWithSerializeTerminalBufferFromAvailableState extends Or
|
||||
: rendererSnapshot
|
||||
}
|
||||
|
||||
protected async serializePreferredRestoredTerminalBuffer(
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number } = {}
|
||||
) {
|
||||
if (!this.providerSnapshotPreferredPtys.has(ptyId)) {
|
||||
return null
|
||||
}
|
||||
// Pre-attach bytes are only a suffix; older providers can fall back to the renderer.
|
||||
return (
|
||||
(await this.serializeProviderTerminalBuffer(ptyId, opts)) ??
|
||||
(await this.serializeRendererTerminalBuffer(ptyId, opts))
|
||||
)
|
||||
}
|
||||
|
||||
async serializeRendererTerminalBuffer(
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number } = {}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
RuntimeMobileSessionBrowserTab,
|
||||
RuntimeMobileSessionTabsSnapshot
|
||||
} from '../../shared/runtime-types'
|
||||
import { OrcaRuntimeWithCloseStructuredAgentSessionTab } from './orca-runtime-close-structured-agent-session-tab'
|
||||
import { OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs } from './orca-runtime-reconcile-headless-mobile-session-browser-tabs'
|
||||
|
||||
const rendererPage: RuntimeMobileSessionBrowserTab = {
|
||||
type: 'browser',
|
||||
id: 'renderer-tab',
|
||||
browserWorkspaceId: 'renderer-workspace',
|
||||
browserPageId: 'renderer-page',
|
||||
title: 'Server page',
|
||||
url: 'https://example.com/server',
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isActive: false
|
||||
}
|
||||
const clientPage: RuntimeMobileSessionBrowserTab = {
|
||||
...rendererPage,
|
||||
id: 'client',
|
||||
browserWorkspaceId: 'client',
|
||||
browserPageId: 'client',
|
||||
placement: {
|
||||
kind: 'client',
|
||||
browserHostClientId: 'host',
|
||||
browserHostGeneration: 1,
|
||||
pageHostGeneration: 1
|
||||
}
|
||||
}
|
||||
const snapshot: RuntimeMobileSessionTabsSnapshot = {
|
||||
worktree: 'wt',
|
||||
publicationEpoch: 'renderer:1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group',
|
||||
activeTabId: 'renderer-tab',
|
||||
activeTabType: 'browser',
|
||||
tabs: [rendererPage],
|
||||
tabGroups: [{ id: 'group', activeTabId: 'renderer-tab', tabOrder: ['renderer-tab'] }]
|
||||
}
|
||||
|
||||
/** Drives the reconcile against a stub host and returns the published snapshot, if any. */
|
||||
function reconcile(
|
||||
host: {
|
||||
live?: RuntimeMobileSessionBrowserTab[]
|
||||
attached?: boolean
|
||||
offscreen?: boolean
|
||||
},
|
||||
existing: RuntimeMobileSessionTabsSnapshot = snapshot
|
||||
): RuntimeMobileSessionTabsSnapshot | undefined {
|
||||
const storeMobileSessionSnapshot = vi.fn()
|
||||
const runtime = OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs.prototype as unknown as {
|
||||
reconcileHeadlessMobileSessionBrowserTabs(
|
||||
worktreeId: string,
|
||||
existing: RuntimeMobileSessionTabsSnapshot
|
||||
): void
|
||||
}
|
||||
runtime.reconcileHeadlessMobileSessionBrowserTabs.call(
|
||||
{
|
||||
buildHeadlessMobileSessionBrowserTabs: () => host.live ?? [],
|
||||
getAvailableAuthoritativeWindow: () => (host.attached === false ? null : {}),
|
||||
offscreenBrowserBackend: host.offscreen === true ? {} : null,
|
||||
storeMobileSessionSnapshot
|
||||
},
|
||||
'wt',
|
||||
existing
|
||||
)
|
||||
return storeMobileSessionSnapshot.mock.calls[0]?.[1]
|
||||
}
|
||||
|
||||
it('keeps renderer-owned browser pages when refreshing client-hosted pages on an attached desktop', () => {
|
||||
const published = reconcile({}) ?? snapshot
|
||||
|
||||
expect(published.tabs).toContainEqual(rendererPage)
|
||||
expect(published.tabGroups?.[0].tabOrder).toContain('renderer-tab')
|
||||
})
|
||||
|
||||
it.each([false, true])('retires absent offscreen pages when attached=%s', (attached) => {
|
||||
expect(reconcile({ attached, offscreen: true })?.tabs).toEqual([])
|
||||
})
|
||||
|
||||
it('removes retired client pages and publishes live ones while retaining renderer rows and group order', () => {
|
||||
const livePage = { ...clientPage, id: 'live', browserWorkspaceId: 'live', browserPageId: 'live' }
|
||||
|
||||
const published = reconcile(
|
||||
{ live: [livePage] },
|
||||
{
|
||||
...snapshot,
|
||||
tabs: [rendererPage, clientPage],
|
||||
tabGroups: [
|
||||
{ id: 'group', activeTabId: 'renderer-tab', tabOrder: ['renderer-tab', 'client'] }
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
expect(published?.tabs).toEqual([rendererPage, livePage])
|
||||
expect(published?.tabGroups?.[0].tabOrder).toEqual(['renderer-tab', 'live'])
|
||||
expect(published?.activeTabId).toBe('renderer-tab')
|
||||
expect(published?.publicationEpoch).toBe(snapshot.publicationEpoch)
|
||||
expect(published?.snapshotVersion).toBe(snapshot.snapshotVersion + 1)
|
||||
})
|
||||
|
||||
it('never publishes a row twice when the live build reclaims a renderer-owned id', () => {
|
||||
const reclaimed = {
|
||||
...clientPage,
|
||||
id: rendererPage.id,
|
||||
browserPageId: rendererPage.browserPageId
|
||||
}
|
||||
|
||||
const published = reconcile({ live: [reclaimed] })
|
||||
|
||||
expect(published?.tabs).toEqual([reclaimed])
|
||||
expect(published?.tabGroups?.[0].tabOrder).toEqual([rendererPage.id])
|
||||
})
|
||||
|
||||
it('does not republish when a client row merely sits before a renderer row', () => {
|
||||
const interleaved = {
|
||||
...snapshot,
|
||||
tabs: [clientPage, rendererPage],
|
||||
tabGroups: [{ id: 'group', activeTabId: 'renderer-tab', tabOrder: ['client', 'renderer-tab'] }]
|
||||
}
|
||||
|
||||
expect(reconcile({ live: [clientPage] }, interleaved)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the renderer publication epoch when selecting a client-hosted browser tab', () => {
|
||||
const storeMobileSessionSnapshot = vi.fn()
|
||||
const runtime = OrcaRuntimeWithCloseStructuredAgentSessionTab.prototype as unknown as {
|
||||
markHeadlessBrowserSessionTabActive(
|
||||
worktreeId: string,
|
||||
browserPageId: string,
|
||||
options: { focusesHost: boolean }
|
||||
): void
|
||||
}
|
||||
|
||||
runtime.markHeadlessBrowserSessionTabActive.call(
|
||||
{
|
||||
offscreenBrowserBackend: {},
|
||||
hydrateHeadlessMobileSessionTabsFromWorkspaceSession: () => undefined,
|
||||
mobileSessionTabsByWorktree: new Map([['wt', snapshot]]),
|
||||
storeMobileSessionSnapshot,
|
||||
emitMobileSessionTabsSnapshot: vi.fn()
|
||||
},
|
||||
'wt',
|
||||
'renderer-page',
|
||||
{ focusesHost: false }
|
||||
)
|
||||
|
||||
expect(storeMobileSessionSnapshot.mock.calls[0]?.[1].publicationEpoch).toBe(
|
||||
snapshot.publicationEpoch
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
|
||||
|
||||
// Fragments stay side-effect ordered: mocks, then lifecycle, then fixtures.
|
||||
const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec')
|
||||
await import('./orca-runtime-test-lifecycle.spec')
|
||||
const { store, TEST_WORKTREE_ID } = await import('./orca-runtime-test-fixtures.spec')
|
||||
|
||||
it.each(['renderer:active-generation', 'headless:active-generation'])(
|
||||
'keeps %s live when runtime-owned creation supplements its inventory',
|
||||
async (publicationEpoch) => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-runtime-fallback' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.syncWindowGraph(0, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch,
|
||||
snapshotVersion: 7,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
]
|
||||
})
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged(
|
||||
(snapshot) => events.push(snapshot),
|
||||
'paired-client'
|
||||
)
|
||||
try {
|
||||
const created = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
activate: false,
|
||||
select: false,
|
||||
navigation: 'caller',
|
||||
clientNavigationId: 'paired-client'
|
||||
})
|
||||
expect(created.tab.status).toBe('ready')
|
||||
expect(created.publicationEpoch).toBe(publicationEpoch)
|
||||
expect(created.snapshotVersion).toBeGreaterThan(7)
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
publicationEpoch: `${publicationEpoch}:client-navigation`,
|
||||
tabs: [expect.objectContaining({ id: created.tab.id, status: 'ready' })]
|
||||
})
|
||||
} finally {
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { execFile, execFileSync } from 'node:child_process'
|
||||
import { runProcess, runProcessSync, type ProcessResult } from '../shared/child-process/run-process'
|
||||
import {
|
||||
_resetWslAvailabilityCacheForTests,
|
||||
isWslAvailable,
|
||||
isWslAvailableAsync
|
||||
} from './wsl-availability'
|
||||
|
||||
vi.mock('node:child_process', () => ({ execFile: vi.fn(), execFileSync: vi.fn() }))
|
||||
vi.mock('../shared/child-process/run-process', () => ({
|
||||
runProcess: vi.fn(),
|
||||
runProcessSync: vi.fn()
|
||||
}))
|
||||
vi.mock('./wsl-interop-spawn-directory', () => ({
|
||||
resolveWslInteropSpawnCwd: () => 'C:\\Windows'
|
||||
}))
|
||||
|
||||
const originalPlatform = process.platform
|
||||
const success: ProcessResult = { code: 0, signal: null, stdout: '', stderr: '', timedOut: false }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
_resetWslAvailabilityCacheForTests()
|
||||
})
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
||||
_resetWslAvailabilityCacheForTests()
|
||||
})
|
||||
|
||||
for (const mode of ['sync', 'async'] as const) {
|
||||
describe(`${mode} WSL1 availability without WSL2 kernel`, () => {
|
||||
const probe = () => (mode === 'sync' ? isWslAvailable() : isWslAvailableAsync())
|
||||
const guestRunner = () => (mode === 'sync' ? runProcessSync : runProcess)
|
||||
|
||||
function failStatus(code: number): void {
|
||||
vi.mocked(execFileSync).mockImplementation(() => {
|
||||
throw { status: code }
|
||||
})
|
||||
vi.mocked(execFile).mockImplementation((...args: unknown[]) => {
|
||||
const callback = args.at(-1) as (error: unknown) => void
|
||||
callback({ code })
|
||||
return {} as ReturnType<typeof execFile>
|
||||
})
|
||||
}
|
||||
function guestResult(result: ProcessResult): void {
|
||||
vi.mocked(runProcess).mockResolvedValue(result)
|
||||
vi.mocked(runProcessSync).mockReturnValue(result)
|
||||
}
|
||||
|
||||
// Node reports the Windows DWORD; the console prints its signed equivalent.
|
||||
for (const status of [-444, 4_294_966_852]) {
|
||||
it(`requires guest execution and caches its success for ${status}`, async () => {
|
||||
failStatus(status)
|
||||
guestResult(success)
|
||||
expect(await probe()).toBe(true)
|
||||
expect(await probe()).toBe(true)
|
||||
expect(guestRunner()).toHaveBeenCalledTimes(1)
|
||||
expect(guestRunner()).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
program: 'wsl.exe',
|
||||
args: ['--exec', '/bin/true'],
|
||||
timeoutMs: 5000,
|
||||
cwd: 'C:\\Windows'
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
for (const result of [
|
||||
{ ...success, code: 1 },
|
||||
{ ...success, code: null, timedOut: true }
|
||||
]) {
|
||||
it(`keeps a failed guest unavailable: ${JSON.stringify(result)}`, async () => {
|
||||
failStatus(-444)
|
||||
guestResult(result)
|
||||
expect(await probe()).toBe(false)
|
||||
})
|
||||
}
|
||||
|
||||
it('stays unavailable when the guest probe cannot be spawned', async () => {
|
||||
failStatus(-444)
|
||||
vi.mocked(runProcess).mockRejectedValue(new Error('EPERM'))
|
||||
vi.mocked(runProcessSync).mockImplementation(() => {
|
||||
throw new Error('EPERM')
|
||||
})
|
||||
expect(await probe()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not probe a guest for unrelated status failures', async () => {
|
||||
failStatus(1)
|
||||
expect(await probe()).toBe(false)
|
||||
expect(runProcess).not.toHaveBeenCalled()
|
||||
expect(runProcessSync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { execFile, execFileSync } from 'node:child_process'
|
||||
import { runProcess, runProcessSync, type ProcessSpec } from '../shared/child-process/run-process'
|
||||
import { buildWslExecArgs } from '../shared/wsl-login-shell-command'
|
||||
import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory'
|
||||
|
||||
type WslAvailabilityCache =
|
||||
@@ -94,6 +96,55 @@ function cacheWslAvailabilityProbeResult(error: unknown, startedAtGeneration: nu
|
||||
return !error
|
||||
}
|
||||
|
||||
// `wsl --status` exits 0x1bc when the WSL2 kernel package is missing -- a package
|
||||
// a WSL1 distro never needed. Node keeps the Windows DWORD; the console prints the
|
||||
// signed form, and either spelling can reach us.
|
||||
function isMissingWsl2KernelStatus(error: unknown): boolean {
|
||||
const failure = error as { status?: unknown; code?: unknown } | null
|
||||
return [failure?.status, failure?.code].some((code) => code === -444 || code === 4_294_966_852)
|
||||
}
|
||||
|
||||
// Cheapest proof the default guest runs: no login shell, no output to parse.
|
||||
function defaultGuestExecutionProbe(): ProcessSpec {
|
||||
return {
|
||||
program: 'wsl.exe',
|
||||
args: buildWslExecArgs(undefined, ['/bin/true']),
|
||||
cwd: resolveWslInteropSpawnCwd(),
|
||||
timeoutMs: WSL_AVAILABILITY_PROBE_TIMEOUT_MS,
|
||||
maxOutputBytes: 4096
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `--status` error still worth caching, or null once the guest ran anyway.
|
||||
*
|
||||
* Why it returns that error rather than a fresh negative: a guest probe that could not
|
||||
* spawn means "could not ask", and minting an answer for that is the bug this subsystem
|
||||
* keeps re-shipping (docs/reference/wsl-probe-failure-semantics.md).
|
||||
*/
|
||||
function wslStatusErrorAfterGuestProbe(error: unknown): unknown {
|
||||
if (!isMissingWsl2KernelStatus(error)) {
|
||||
return error
|
||||
}
|
||||
try {
|
||||
return runProcessSync(defaultGuestExecutionProbe()).code === 0 ? null : error
|
||||
} catch {
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
/** Async twin of `wslStatusErrorAfterGuestProbe`; the sync/async pair share one cache. */
|
||||
async function wslStatusErrorAfterGuestProbeAsync(error: unknown): Promise<unknown> {
|
||||
if (!isMissingWsl2KernelStatus(error)) {
|
||||
return error
|
||||
}
|
||||
try {
|
||||
return (await runProcess(defaultGuestExecutionProbe())).code === 0 ? null : error
|
||||
} catch {
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
function probeWslStatus(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
@@ -147,7 +198,10 @@ export function isWslAvailable(): boolean {
|
||||
})
|
||||
return cacheWslAvailabilityProbeResult(null, startedAtGeneration)
|
||||
} catch (error) {
|
||||
return cacheWslAvailabilityProbeResult(error, startedAtGeneration)
|
||||
return cacheWslAvailabilityProbeResult(
|
||||
wslStatusErrorAfterGuestProbe(error),
|
||||
startedAtGeneration
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +230,12 @@ export function isWslAvailableAsync(): Promise<boolean> {
|
||||
const startedAtGeneration = wslAvailabilityCacheGeneration
|
||||
wslAvailabilityProbeInFlight = probeWslStatus()
|
||||
.then(() => cacheWslAvailabilityProbeResult(null, startedAtGeneration))
|
||||
.catch((error: unknown) => cacheWslAvailabilityProbeResult(error, startedAtGeneration))
|
||||
.catch(async (error: unknown) =>
|
||||
cacheWslAvailabilityProbeResult(
|
||||
await wslStatusErrorAfterGuestProbeAsync(error),
|
||||
startedAtGeneration
|
||||
)
|
||||
)
|
||||
.finally(() => {
|
||||
wslAvailabilityProbeInFlight = null
|
||||
})
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
PtySourceRecoveryResult
|
||||
} from '../shared/pty-source-recovery-contract'
|
||||
import type { PtySourceReceivingActivation } from '../shared/pty-source-receiving-activation'
|
||||
import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract'
|
||||
import type {
|
||||
PtySourceDeliveryIdentity,
|
||||
PtySourceDeliverySnapshot
|
||||
} from '../shared/pty-source-credit-contract'
|
||||
import type { RequestContext } from './dispatcher'
|
||||
import type {
|
||||
RelayPtySourceDeliveryRecord,
|
||||
@@ -12,6 +15,16 @@ import type {
|
||||
} from './relay-pty-source-send-scheduler'
|
||||
import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter'
|
||||
|
||||
// Takes the post-rotation snapshot: creditedEndSu is the accepted checkpoint and windowSu the
|
||||
// reconnecting client's window. The pre-rotation snapshot would fence below the checkpoint.
|
||||
export function boundedPtyRecoveryEnd(
|
||||
snapshot: Pick<PtySourceDeliverySnapshot, 'receivedEndSu' | 'creditedEndSu' | 'windowSu'>
|
||||
): number {
|
||||
const { receivedEndSu, creditedEndSu, windowSu } = snapshot
|
||||
// Oversized quarantine cannot earn credit; fence at the checkpoint and drain it live.
|
||||
return receivedEndSu - creditedEndSu > windowSu ? creditedEndSu : receivedEndSu
|
||||
}
|
||||
|
||||
export function createPtySourceReceivingActivation(
|
||||
identity: PtySourceDeliveryIdentity,
|
||||
checkpointSourceEndSu: number,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { PtySourceReceivingActivation } from '../shared/pty-source-receivin
|
||||
import {
|
||||
createPtySourceReceivingActivation,
|
||||
pendingPtySourceRecoveryResult,
|
||||
boundedPtyRecoveryEnd,
|
||||
registerCanceledPtySourceRetirement,
|
||||
registerPtySourceActivationSettlement,
|
||||
samePtySourceRecoveryRequest
|
||||
@@ -66,9 +67,7 @@ export class RelayPtySourcePublication {
|
||||
recovery?: PtySourceRecoveryRequest
|
||||
): false | 'opened' | 'rotated' | 'existing' | PtySourceRecoveryResult {
|
||||
let current = this.deliveries.get(id)
|
||||
// A superseded request can find the delivery its own replacement opened: releasing that fence
|
||||
// resumes a send the replacement is still rotating, and cancelling it blanks the pane that owns
|
||||
// it. So every bail-out below acts only on a record this caller still owns.
|
||||
// Only release this caller's delivery; its replacement may still be rotating.
|
||||
const owned = current?.clientId === context?.clientId ? current : undefined
|
||||
if (!context?.onResponseSettled) {
|
||||
this.sender.releaseRotationFence(owned)
|
||||
@@ -141,7 +140,7 @@ export class RelayPtySourcePublication {
|
||||
identity = rotation.identity
|
||||
displayEnd = current.displayEnd
|
||||
recoveryCheckpointSourceEndSu = recovery.acceptedSourceEndSu
|
||||
recoveryEndSu = snapshot.receivedEndSu
|
||||
recoveryEndSu = boundedPtyRecoveryEnd(this.session.sourceDeliverySnapshot(identity))
|
||||
recoveryWasSealed = snapshot.state === 'sealed-unsettled'
|
||||
this.counters.rotated++
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { RelayDispatcher, type RelayClientSessionIdentity } from './dispatcher'
|
||||
import { boundedPtyRecoveryEnd } from './relay-pty-source-activation'
|
||||
import { encodeJsonRpcFrame, MessageType } from './protocol'
|
||||
import { RelayPtySourcePublication } from './relay-pty-source-publication'
|
||||
import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter'
|
||||
|
||||
const endpointIdentity: RelayClientSessionIdentity = {
|
||||
principal: 'endpoint-principal',
|
||||
authenticated: true,
|
||||
allowSessionOwner: true,
|
||||
authenticationKind: 'endpoint-credential'
|
||||
}
|
||||
|
||||
type Frame = {
|
||||
id?: number
|
||||
method?: string
|
||||
params?: Record<string, unknown>
|
||||
result?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function decode(buffer: Buffer): Frame | null {
|
||||
return buffer[0] === MessageType.Regular
|
||||
? JSON.parse(buffer.subarray(13, 13 + buffer.readUInt32BE(9)).toString('utf8'))
|
||||
: null
|
||||
}
|
||||
|
||||
const flushRequests = (): Promise<void> => new Promise((resolve) => setImmediate(resolve))
|
||||
let dispatcher: RelayDispatcher | undefined
|
||||
|
||||
afterEach(() => dispatcher?.dispose())
|
||||
|
||||
it.each([0, 4])(
|
||||
'drains a retained tail larger than the window from checkpoint %i',
|
||||
async (checkpoint) => {
|
||||
const original: Frame[] = []
|
||||
dispatcher = new RelayDispatcher(
|
||||
(data, settled) => {
|
||||
const frame = decode(data)
|
||||
if (frame) {
|
||||
original.push(frame)
|
||||
}
|
||||
settled({ ok: true })
|
||||
return true
|
||||
},
|
||||
{ supportsWriteCallback: true },
|
||||
endpointIdentity
|
||||
)
|
||||
const mux = dispatcher
|
||||
let publication: RelayPtySourcePublication
|
||||
const adapter = new SshPtyConsumerSessionAdapter(mux, 'build', undefined, (id) =>
|
||||
publication.onCreditAvailable(id)
|
||||
)
|
||||
publication = new RelayPtySourcePublication(mux, adapter, () => {})
|
||||
const open = (clientId: number, id: number, resume?: Record<string, unknown>): void => {
|
||||
mux.feedClient(
|
||||
clientId,
|
||||
encodeJsonRpcFrame(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method: 'pty.openClient',
|
||||
params: {
|
||||
protocolVersion: 1,
|
||||
clientInstanceId: 'client',
|
||||
requestedRole: 'session-owner',
|
||||
resume,
|
||||
capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } }
|
||||
}
|
||||
},
|
||||
id,
|
||||
0
|
||||
)
|
||||
)
|
||||
}
|
||||
open(1, 1)
|
||||
await flushRequests()
|
||||
publication.activate('pty', 'incarnation', {
|
||||
clientId: 1,
|
||||
isStale: () => false,
|
||||
sessionIdentity: endpointIdentity,
|
||||
onResponseSettled: (settle) => queueMicrotask(() => settle({ ok: true }))
|
||||
})
|
||||
await flushRequests()
|
||||
expect(publication.publish('pty', { data: 'abcdefghijkl' }, false)).toBe(true)
|
||||
const oldFrame = original.find((frame) => frame.method === 'pty.data')!.params!
|
||||
const grant = original.find((frame) => frame.id === 1)!.result!
|
||||
mux.invalidateClient()
|
||||
const replacement: Frame[] = []
|
||||
const clientId = mux.attachClient(
|
||||
(data, settled) => {
|
||||
const frame = decode(data)
|
||||
if (frame) {
|
||||
replacement.push(frame)
|
||||
}
|
||||
settled({ ok: true })
|
||||
return true
|
||||
},
|
||||
{ supportsWriteCallback: true },
|
||||
endpointIdentity
|
||||
)
|
||||
open(clientId, 2, { ownerGeneration: grant.ownerGeneration, ownerLease: grant.ownerLease })
|
||||
await flushRequests()
|
||||
const recovery = publication.activate(
|
||||
'pty',
|
||||
'incarnation',
|
||||
{
|
||||
clientId,
|
||||
isStale: () => false,
|
||||
sessionIdentity: endpointIdentity,
|
||||
onResponseSettled: (settle) => queueMicrotask(() => settle({ ok: true }))
|
||||
},
|
||||
{
|
||||
status: 'checkpoint',
|
||||
clientGeneration: Number(oldFrame.clientGeneration),
|
||||
ownerGeneration: Number(oldFrame.ownerGeneration),
|
||||
deliveryToken: String(oldFrame.deliveryToken),
|
||||
ptyIncarnation: 'incarnation',
|
||||
acceptedSourceEndSu: checkpoint
|
||||
}
|
||||
)
|
||||
// The fence lands on the checkpoint itself: the tail drains live rather than behind it.
|
||||
expect(recovery).toMatchObject({
|
||||
status: 'pending',
|
||||
checkpointSourceEndSu: checkpoint,
|
||||
recoveryEndSu: checkpoint
|
||||
})
|
||||
await flushRequests()
|
||||
// The receiver cannot ACK quarantined data until this fence arrives.
|
||||
expect(replacement.filter((frame) => frame.method === 'pty.recoveryComplete')).toHaveLength(1)
|
||||
let accepted = checkpoint
|
||||
let output = ''
|
||||
for (let turn = 0; accepted < 12 && turn < 4; turn++) {
|
||||
const frames = replacement.filter(
|
||||
(frame) => frame.method === 'pty.data' && Number(frame.params!.sourceEndSu) > accepted
|
||||
)
|
||||
expect(frames.length).toBeGreaterThan(0)
|
||||
for (const frame of frames) {
|
||||
const params = frame.params!
|
||||
expect(Number(params.sourceEndSu) - Number(params.sourceLengthSu)).toBe(accepted)
|
||||
accepted = Number(params.sourceEndSu)
|
||||
output += String(params.data)
|
||||
}
|
||||
expect(publication.getDebugSnapshot().outstandingSourceUnits).toBeLessThanOrEqual(4)
|
||||
const params = frames.at(-1)!.params!
|
||||
mux.feedClient(
|
||||
clientId,
|
||||
encodeJsonRpcFrame(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
method: 'pty.ackData',
|
||||
params: {
|
||||
acknowledgements: [
|
||||
{
|
||||
id: 'pty',
|
||||
clientGeneration: params.clientGeneration,
|
||||
ownerGeneration: params.ownerGeneration,
|
||||
deliveryToken: params.deliveryToken,
|
||||
creditedEndSu: accepted
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
3 + turn,
|
||||
0
|
||||
)
|
||||
)
|
||||
await flushRequests()
|
||||
}
|
||||
expect(accepted).toBe(12)
|
||||
expect(output).toBe('abcdefghijkl'.slice(checkpoint))
|
||||
expect(publication.getDebugSnapshot().outstandingSourceUnits).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('fences at the checkpoint only once the tail outgrows the window', () => {
|
||||
const tail = (receivedEndSu: number) => ({ receivedEndSu, creditedEndSu: 4, windowSu: 4 })
|
||||
// Exactly one window is still deliverable without credit, so it keeps the ordinary fence.
|
||||
expect(boundedPtyRecoveryEnd(tail(8))).toBe(8)
|
||||
expect(boundedPtyRecoveryEnd(tail(9))).toBe(4)
|
||||
})
|
||||
@@ -602,3 +602,113 @@ describe('WorkspacePortScanner', () => {
|
||||
expect(getPublishedRemoteWorktreePorts()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('advertised URL refresh bursts', () => {
|
||||
async function mountLocalScanner(): Promise<() => void> {
|
||||
useAppStore.setState({ settings: getDefaultSettings('/tmp/orca-workspaces') })
|
||||
await act(async () => {
|
||||
root?.render(<WorkspacePortScanner />)
|
||||
await flushPromises()
|
||||
})
|
||||
localScan.mockClear()
|
||||
return vi.mocked(window.api.workspacePorts.onAdvertisedUrlChanged).mock
|
||||
.calls[0][0] as () => void
|
||||
}
|
||||
|
||||
it('coalesces sequential URL changes into one immediate scan and one settled scan', async () => {
|
||||
const changed = await mountLocalScanner()
|
||||
for (let index = 0; index < 5; index++) {
|
||||
await act(async () => {
|
||||
changed()
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
})
|
||||
}
|
||||
expect(localScan).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
})
|
||||
expect(localScan).toHaveBeenCalledTimes(2)
|
||||
await act(async () => {
|
||||
changed()
|
||||
await flushPromises()
|
||||
})
|
||||
expect(localScan).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('cancels the settled scan on unmount', async () => {
|
||||
const changed = await mountLocalScanner()
|
||||
await act(async () => {
|
||||
changed()
|
||||
await flushPromises()
|
||||
})
|
||||
act(() => root?.unmount())
|
||||
root = null
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
expect(localScan).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips the settled scan while hidden and accepts the next visible URL change', async () => {
|
||||
let visibility: DocumentVisibilityState = 'visible'
|
||||
const restore = overrideDocumentVisibilityState(() => visibility)
|
||||
try {
|
||||
const changed = await mountLocalScanner()
|
||||
await act(async () => {
|
||||
changed()
|
||||
await flushPromises()
|
||||
})
|
||||
visibility = 'hidden'
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
})
|
||||
expect(localScan).toHaveBeenCalledTimes(1)
|
||||
visibility = 'visible'
|
||||
await act(async () => {
|
||||
changed()
|
||||
await flushPromises()
|
||||
})
|
||||
expect(localScan).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('releases the URL burst when its leading scan finishes while hidden', async () => {
|
||||
let visibility: DocumentVisibilityState = 'visible'
|
||||
const restore = overrideDocumentVisibilityState(() => visibility)
|
||||
try {
|
||||
useAppStore.setState({ settings: getDefaultSettings('/tmp/orca-workspaces') })
|
||||
await act(async () => {
|
||||
root?.render(<WorkspacePortScanner />)
|
||||
await flushPromises()
|
||||
})
|
||||
const changed = vi.mocked(window.api.workspacePorts.onAdvertisedUrlChanged).mock
|
||||
.calls[0][0] as () => void
|
||||
let finish!: (scan: WorkspacePortScanResult) => void
|
||||
localScan.mockClear()
|
||||
localScan.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<WorkspacePortScanResult>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
await act(async () => {
|
||||
changed()
|
||||
await flushPromises()
|
||||
})
|
||||
visibility = 'hidden'
|
||||
await act(async () => {
|
||||
finish(emptyScan)
|
||||
await flushPromises()
|
||||
})
|
||||
visibility = 'visible'
|
||||
await act(async () => {
|
||||
changed()
|
||||
await flushPromises()
|
||||
})
|
||||
expect(localScan).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -280,6 +280,7 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }):
|
||||
return
|
||||
}
|
||||
|
||||
let burstRefresh: Promise<void> | null = null
|
||||
let eventSequence = 0
|
||||
let disposed = false
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -296,15 +297,24 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }):
|
||||
const sequence = eventSequence
|
||||
clearRetryTimer()
|
||||
if (!isWindowVisible()) {
|
||||
burstRefresh = null
|
||||
return
|
||||
}
|
||||
void refresh({ force: true, targets: [runtimeTarget] }).finally(() => {
|
||||
if (disposed || sequence !== eventSequence || !isWindowVisible()) {
|
||||
// Keep the leading scan through the quiet window so sequential events share it too.
|
||||
burstRefresh ??= refresh({ force: true, targets: [runtimeTarget] })
|
||||
void burstRefresh.finally(() => {
|
||||
if (disposed || sequence !== eventSequence) {
|
||||
return
|
||||
}
|
||||
if (!isWindowVisible()) {
|
||||
burstRefresh = null
|
||||
return
|
||||
}
|
||||
// Why: some dev servers print their URL just before the listener is
|
||||
// visible to lsof/netstat. One quiet settle scan catches that startup race.
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null
|
||||
burstRefresh = null
|
||||
if (disposed || sequence !== eventSequence || !isWindowVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -345,6 +345,15 @@ function findMenuItemByText(node: unknown, label: string): ReactElementLike {
|
||||
return item
|
||||
}
|
||||
|
||||
/** Picks Rename, then fires the close-autofocus that actually opens the input. */
|
||||
function selectRenameFromMenu(node: unknown): void {
|
||||
;(findMenuItemByText(node, 'Rename').props.onSelect as () => void)()
|
||||
const content = findElementsByType(node, 'DropdownMenuContent')[0]!
|
||||
;(content.props.onCloseAutoFocus as (event: { preventDefault: () => void }) => void)({
|
||||
preventDefault: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
function findSpanByText(node: unknown, label: string): ReactElementLike {
|
||||
const span = findElementsByType(node, 'span').find(
|
||||
(candidate) =>
|
||||
@@ -401,7 +410,7 @@ describe('EditorFileTab rename menu', () => {
|
||||
// isUntitled; the tab menu must let users rename the screenshot-style
|
||||
// "untitled-N.md" files directly.
|
||||
expect(renameItem.props.disabled).toBe(false)
|
||||
;(renameItem.props.onSelect as () => void)()
|
||||
selectRenameFromMenu(firstRender)
|
||||
|
||||
const secondRender = expandNode((await renderEditorFileTab(file, onActivate)).element)
|
||||
const inputs = findElementsByType(secondRender, 'input')
|
||||
@@ -425,9 +434,8 @@ describe('EditorFileTab rename menu', () => {
|
||||
it('ignores IME composition Enter before renaming the editor file tab', async () => {
|
||||
const file = baseFile()
|
||||
const firstRender = expandNode((await renderEditorFileTab(file)).element)
|
||||
const renameItem = findMenuItemByText(firstRender, 'Rename')
|
||||
|
||||
;(renameItem.props.onSelect as () => void)()
|
||||
selectRenameFromMenu(firstRender)
|
||||
|
||||
const secondRender = expandNode((await renderEditorFileTab(file)).element)
|
||||
const input = findElementsByType(secondRender, 'input')[0]
|
||||
@@ -457,9 +465,8 @@ describe('EditorFileTab rename menu', () => {
|
||||
it('does not re-commit when unmounting the rename input emits multiple blur events', async () => {
|
||||
const file = baseFile()
|
||||
const firstRender = expandNode((await renderEditorFileTab(file)).element)
|
||||
const renameItem = findMenuItemByText(firstRender, 'Rename')
|
||||
|
||||
;(renameItem.props.onSelect as () => void)()
|
||||
selectRenameFromMenu(firstRender)
|
||||
|
||||
const secondRender = expandNode((await renderEditorFileTab(file)).element)
|
||||
const input = findElementsByType(secondRender, 'input')[0]
|
||||
|
||||
@@ -171,8 +171,8 @@ export default function EditorFileTab({
|
||||
if (!input) {
|
||||
return
|
||||
}
|
||||
// Why: Radix closes the context menu after onSelect; defer focus so its
|
||||
// teardown cannot steal focus back or blur-commit the newly mounted input.
|
||||
// Why: the tab re-lays out around the input; focus on the next frame so
|
||||
// that swap has settled before selecting text.
|
||||
renameFocusFrameRef.current = requestAnimationFrame(() => {
|
||||
renameFocusFrameRef.current = null
|
||||
if (renameInputRef.current !== input) {
|
||||
|
||||
@@ -202,7 +202,9 @@ function extractText(node: unknown): string {
|
||||
return el.props && 'children' in el.props ? extractText(el.props.children) : ''
|
||||
}
|
||||
|
||||
async function renderMenu(): Promise<unknown> {
|
||||
async function renderMenu(
|
||||
overrides: { onActivate?: () => void; onOpenRenameInput?: () => void } = {}
|
||||
): Promise<unknown> {
|
||||
const module = await import('./EditorFileTabContextMenu')
|
||||
return module.EditorFileTabContextMenu({
|
||||
open: true,
|
||||
@@ -238,7 +240,8 @@ async function renderMenu(): Promise<unknown> {
|
||||
onCloseAll: vi.fn(),
|
||||
onCloseToRight: vi.fn(),
|
||||
onCloseToLeft: vi.fn(),
|
||||
onOpenMarkdownPreview: vi.fn()
|
||||
onOpenMarkdownPreview: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
@@ -266,6 +269,27 @@ describe('EditorFileTabContextMenu close-all shortcut', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('opens rename only after menu close releases focus and consumes the request once', async () => {
|
||||
const onActivate = vi.fn()
|
||||
const onOpenRenameInput = vi.fn()
|
||||
const tree = expandNode(await renderMenu({ onActivate, onOpenRenameInput }))
|
||||
const rename = findElementsByType(tree, 'DropdownMenuItem').find((item) =>
|
||||
extractText(item.props.children).includes('Rename')
|
||||
)!
|
||||
const content = findElementsByType(tree, 'DropdownMenuContent')[0]!
|
||||
;(rename.props.onSelect as () => void)()
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
expect(onOpenRenameInput).not.toHaveBeenCalled()
|
||||
const preventDefault = vi.fn()
|
||||
const close = content.props.onCloseAutoFocus as (event: { preventDefault: () => void }) => void
|
||||
close({ preventDefault })
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(onActivate).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenRenameInput).toHaveBeenCalledTimes(1)
|
||||
close({ preventDefault })
|
||||
expect(onOpenRenameInput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders assigned shortcuts next to Rename, Close, and Close All Editor Tabs', async () => {
|
||||
const tree = expandNode(await renderMenu())
|
||||
const menuItems = findElementsByType(tree, 'DropdownMenuItem')
|
||||
|
||||
@@ -126,6 +126,10 @@ export function EditorFileTabContextMenu({
|
||||
}
|
||||
skipMenuFocusRestoreRef.current = false
|
||||
event.preventDefault()
|
||||
// Why: opening the input in onSelect lets the still-closing menu reclaim
|
||||
// focus, and the resulting blur commits the rename away before the user types.
|
||||
onActivate()
|
||||
onOpenRenameInput()
|
||||
}}
|
||||
>
|
||||
<TabWorkspaceLayoutMenuSection
|
||||
@@ -137,8 +141,6 @@ export function EditorFileTabContextMenu({
|
||||
disabled={!canRename || isRenaming}
|
||||
onSelect={() => {
|
||||
skipMenuFocusRestoreRef.current = true
|
||||
onActivate()
|
||||
onOpenRenameInput()
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
|
||||
@@ -90,6 +90,7 @@ describe('fitAndFocusPanes', () => {
|
||||
vi.stubGlobal('HTMLElement', FakeHTMLElement)
|
||||
vi.stubGlobal('document', {
|
||||
activeElement,
|
||||
querySelectorAll: vi.fn(() => []),
|
||||
querySelector: vi.fn((selector: string) =>
|
||||
selector === '[data-tab-rename-input="true"]' && renameInputMounted
|
||||
? (new FakeHTMLElement({ tagName: 'INPUT' }) as unknown as Element)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { focusPanePreservingOverlays } from '@/lib/pane-manager/pane-overlay-focus'
|
||||
|
||||
export function fitPanes(manager: PaneManager): void {
|
||||
manager.fitAllPanes()
|
||||
@@ -16,7 +17,9 @@ export function focusActivePane(manager: PaneManager): void {
|
||||
}
|
||||
const panes = manager.getPanes()
|
||||
const activePane = manager.getActivePane() ?? panes[0]
|
||||
activePane?.terminal.focus()
|
||||
if (activePane) {
|
||||
focusPanePreservingOverlays(activePane)
|
||||
}
|
||||
}
|
||||
|
||||
export function fitAndFocusPanes(manager: PaneManager): void {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { fitAndFocusPanes } from './pane-helpers'
|
||||
|
||||
function createLayoutFixture() {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.className = 'xterm-helper-textarea'
|
||||
document.body.append(textarea)
|
||||
textarea.focus()
|
||||
const terminal = { focus: vi.fn(() => textarea.focus()) }
|
||||
const manager = {
|
||||
fitAllPanes: vi.fn(),
|
||||
getActivePane: () => ({ terminal }),
|
||||
getPanes: () => [{ terminal }]
|
||||
} as unknown as PaneManager
|
||||
return { manager, terminal, textarea }
|
||||
}
|
||||
|
||||
function mountOverlay(role: string) {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.setAttribute('role', role)
|
||||
overlay.tabIndex = -1
|
||||
vi.spyOn(overlay, 'getClientRects').mockReturnValue([
|
||||
new DOMRect(0, 0, 100, 100)
|
||||
] as unknown as DOMRectList)
|
||||
document.body.append(overlay)
|
||||
return overlay
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('terminal layout preserves overlay focus', () => {
|
||||
it.each(['menu', 'dialog', 'alertdialog', 'listbox'])(
|
||||
'does not blur an open %s during a queued fit',
|
||||
(role) => {
|
||||
const { manager, terminal } = createLayoutFixture()
|
||||
const overlay = mountOverlay(role)
|
||||
overlay.focus()
|
||||
const blurred = vi.fn()
|
||||
overlay.addEventListener('blur', blurred)
|
||||
|
||||
fitAndFocusPanes(manager)
|
||||
|
||||
expect(manager.fitAllPanes).toHaveBeenCalledOnce()
|
||||
expect(terminal.focus).not.toHaveBeenCalled()
|
||||
expect(document.activeElement).toBe(overlay)
|
||||
expect(blurred).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves a mounted menu time to acquire focus', () => {
|
||||
const { manager, terminal, textarea } = createLayoutFixture()
|
||||
textarea.blur()
|
||||
mountOverlay('menu')
|
||||
|
||||
fitAndFocusPanes(manager)
|
||||
|
||||
expect(terminal.focus).not.toHaveBeenCalled()
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
})
|
||||
|
||||
it('allows focus after the menu closes', () => {
|
||||
const { manager, terminal, textarea } = createLayoutFixture()
|
||||
const overlay = mountOverlay('menu')
|
||||
overlay.focus()
|
||||
overlay.remove()
|
||||
|
||||
fitAndFocusPanes(manager)
|
||||
|
||||
expect(terminal.focus).toHaveBeenCalledOnce()
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
})
|
||||
|
||||
it('hands focus back to a menu that is animating closed', () => {
|
||||
const { manager, terminal, textarea } = createLayoutFixture()
|
||||
mountOverlay('menu').setAttribute('data-state', 'closed')
|
||||
|
||||
fitAndFocusPanes(manager)
|
||||
|
||||
expect(terminal.focus).toHaveBeenCalledOnce()
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
})
|
||||
|
||||
it('does not treat the workspace sidebar as a focus-owning overlay', () => {
|
||||
const { manager, terminal } = createLayoutFixture()
|
||||
const sidebar = mountOverlay('listbox')
|
||||
sidebar.setAttribute('data-worktree-sidebar', '')
|
||||
|
||||
fitAndFocusPanes(manager)
|
||||
|
||||
expect(terminal.focus).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types'
|
||||
import type { Tab } from '../../../shared/tab-types'
|
||||
import { makeUnifiedTab, makeWorktree } from './worktree-jump-palette-test-fixtures'
|
||||
import { useWorktreeJumpPaletteOpenTabs } from './use-worktree-jump-palette-open-tabs'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
it('keeps same-id browser results on their owner with recency, and follows ownership changes', () => {
|
||||
const worktrees = [
|
||||
makeWorktree('same-id', 'Local workspace', { hostId: 'local' }),
|
||||
makeWorktree('same-id', 'Remote workspace', { hostId: 'runtime:paired' })
|
||||
]
|
||||
const page: BrowserPage = {
|
||||
id: 'page',
|
||||
workspaceId: 'browser',
|
||||
worktreeId: 'same-id',
|
||||
url: 'https://example.test/docs',
|
||||
title: 'Browser proof',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
const workspace: BrowserWorkspace = {
|
||||
...page,
|
||||
id: 'browser',
|
||||
activePageId: page.id,
|
||||
pageIds: [page.id]
|
||||
}
|
||||
const tab: Tab = {
|
||||
...makeUnifiedTab('tab', 'same-id', 'browser', 'Browser proof'),
|
||||
contentType: 'browser',
|
||||
executionHostId: 'runtime:paired',
|
||||
lastFocusedAt: 5_000
|
||||
}
|
||||
type PaletteInput = Parameters<typeof useWorktreeJumpPaletteOpenTabs>[0]
|
||||
const input: Partial<PaletteInput> = {
|
||||
...useAppStore.getInitialState(),
|
||||
// The store holds {key, result}; the hook takes the unwrapped result.
|
||||
workspacePortScan: null,
|
||||
paletteStatusInputsActive: true,
|
||||
allWorktrees: worktrees,
|
||||
browserSortedWorktrees: worktrees,
|
||||
repoMap: new Map(),
|
||||
repoByHostIdentity: new Map(),
|
||||
worktreeOrder: new Map(),
|
||||
worktreeMatches: [],
|
||||
hasQuery: true,
|
||||
deferredQuery: 'Browser proof',
|
||||
browserTabsByWorktree: { 'same-id': [workspace] },
|
||||
browserPagesByWorkspace: { browser: [page] },
|
||||
unifiedTabsByWorktree: { 'same-id': [tab] }
|
||||
}
|
||||
const { result, rerender } = renderHook(
|
||||
(props: Partial<PaletteInput>) => useWorktreeJumpPaletteOpenTabs(props as PaletteInput),
|
||||
{ initialProps: input }
|
||||
)
|
||||
// lastActiveAt rides the same map: without it every browser row sorts as never-focused.
|
||||
const owners = () =>
|
||||
result.current.browserItems.map(({ result: entry }) => [
|
||||
entry.pageId,
|
||||
entry.executionHostId,
|
||||
entry.lastActiveAt
|
||||
])
|
||||
|
||||
expect(owners()).toEqual([['page', 'runtime:paired', 5_000]])
|
||||
|
||||
rerender({
|
||||
...input,
|
||||
unifiedTabsByWorktree: {
|
||||
'same-id': [{ ...tab, executionHostId: 'local' }]
|
||||
}
|
||||
})
|
||||
expect(owners()).toEqual([['page', 'local', 5_000]])
|
||||
})
|
||||
@@ -93,6 +93,7 @@ export function useWorktreeJumpPaletteOpenTabs({
|
||||
worktreeOrder,
|
||||
browserTabsByWorktree,
|
||||
browserPagesByWorkspace,
|
||||
unifiedTabsByWorktree,
|
||||
activeBrowserTabId,
|
||||
activeWorktreeId,
|
||||
activeWorkspaceExecutionHostId,
|
||||
@@ -109,6 +110,7 @@ export function useWorktreeJumpPaletteOpenTabs({
|
||||
browserPagesByWorkspace,
|
||||
browserTabsByWorktree,
|
||||
browserSortedWorktrees,
|
||||
unifiedTabsByWorktree,
|
||||
repoByHostIdentity,
|
||||
repoMap,
|
||||
unifiedTabsByWorktree,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode'
|
||||
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
|
||||
import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route'
|
||||
import { insertUnifiedTabAfterAnchor } from '@/lib/unified-tab-anchor-insertion'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '../../store'
|
||||
import {
|
||||
@@ -83,30 +84,11 @@ export function registerTerminalRequestIpcBridge(unsubs: (() => void)[]): void {
|
||||
requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] })
|
||||
}
|
||||
if (data.afterTabId) {
|
||||
const createdUnifiedTab = useAppStore
|
||||
const createdUnifiedTabId = useAppStore
|
||||
.getState()
|
||||
.unifiedTabsByWorktree[worktreeId]?.find((item) => item.entityId === tab.id)
|
||||
const anchorUnifiedTab = useAppStore
|
||||
.getState()
|
||||
.unifiedTabsByWorktree[worktreeId]?.find((item) => item.id === data.afterTabId)
|
||||
if (
|
||||
createdUnifiedTab &&
|
||||
anchorUnifiedTab &&
|
||||
createdUnifiedTab.groupId === anchorUnifiedTab.groupId
|
||||
) {
|
||||
const group = useAppStore
|
||||
.getState()
|
||||
.groupsByWorktree[worktreeId]?.find((item) => item.id === createdUnifiedTab.groupId)
|
||||
const order = (group?.tabOrder ?? []).filter((id) => id !== createdUnifiedTab.id)
|
||||
const anchorIndex = order.indexOf(anchorUnifiedTab.id)
|
||||
order.splice(
|
||||
anchorIndex === -1 ? order.length : anchorIndex + 1,
|
||||
0,
|
||||
createdUnifiedTab.id
|
||||
)
|
||||
useAppStore.getState().reorderUnifiedTabs(createdUnifiedTab.groupId, order, {
|
||||
recordInteraction: false
|
||||
})
|
||||
.unifiedTabsByWorktree[worktreeId]?.find((item) => item.entityId === tab.id)?.id
|
||||
if (createdUnifiedTabId) {
|
||||
insertUnifiedTabAfterAnchor(worktreeId, createdUnifiedTabId, data.afterTabId)
|
||||
}
|
||||
}
|
||||
if (shouldActivate) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { focusPanePreservingOverlays } from './pane-overlay-focus'
|
||||
import type { ManagedPane, ManagedPaneInternal, PaneManagerOptions } from './pane-manager-types'
|
||||
import type { PaneManagerHost } from './pane-manager-host'
|
||||
import { applyPaneOpacity } from './pane-divider'
|
||||
@@ -23,7 +24,7 @@ export function createInitialManagedPane(
|
||||
applyPaneOpacity(host.panes.values(), host.getActivePaneId(), host.getStyleOptions())
|
||||
|
||||
if (opts?.focus !== false) {
|
||||
pane.terminal.focus()
|
||||
focusPanePreservingOverlays(pane)
|
||||
}
|
||||
|
||||
host.publishPaneCreated(pane)
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { focusPanePreservingOverlays } from './pane-overlay-focus'
|
||||
import type {
|
||||
PaneManagerOptions,
|
||||
PaneStyleOptions,
|
||||
ManagedPane,
|
||||
ManagedPaneInternal,
|
||||
PaneRenderingDiagnostics,
|
||||
DropZone,
|
||||
PaneExternalDropHandler,
|
||||
PaneExternalDropResolver,
|
||||
PaneExternalDropTarget
|
||||
DropZone
|
||||
} from './pane-manager-types'
|
||||
import type { SplitPaneAroundLeafIdsOptions } from './pane-subtree-split'
|
||||
import type { PaneManagerHost } from './pane-manager-host'
|
||||
@@ -68,7 +66,7 @@ export type {
|
||||
PaneExternalDropTarget,
|
||||
PaneExternalDropResolver,
|
||||
PaneExternalDropHandler
|
||||
}
|
||||
} from './pane-manager-types'
|
||||
|
||||
export class PaneManager {
|
||||
private root: HTMLElement
|
||||
@@ -235,7 +233,7 @@ export class PaneManager {
|
||||
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
|
||||
|
||||
if (opts?.focus !== false) {
|
||||
pane.terminal.focus()
|
||||
focusPanePreservingOverlays(pane)
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PaneManager } from './pane-manager'
|
||||
import { createInitialManagedPane } from './pane-manager-pane-creation'
|
||||
import type { PaneManagerHost } from './pane-manager-host'
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
|
||||
vi.mock('./pane-lifecycle', () => ({
|
||||
openTerminal: vi.fn(),
|
||||
createPaneDOM: vi.fn(),
|
||||
disposePane: vi.fn(),
|
||||
setLigaturesEnabled: vi.fn()
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
const root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
const container = document.createElement('div')
|
||||
const textarea = document.createElement('textarea')
|
||||
container.append(textarea)
|
||||
const pane = {
|
||||
id: 1,
|
||||
container,
|
||||
terminal: { focus: vi.fn(() => textarea.focus()) }
|
||||
} as unknown as ManagedPaneInternal
|
||||
const panes = new Map([[pane.id, pane]])
|
||||
const publishPaneCreated = vi.fn()
|
||||
const onActivePaneChange = vi.fn()
|
||||
const manager = Object.create(PaneManager.prototype) as PaneManager
|
||||
Object.assign(manager, {
|
||||
panes,
|
||||
activePaneId: null,
|
||||
styleOptions: {},
|
||||
options: { onActivePaneChange }
|
||||
})
|
||||
const host = {
|
||||
options: {},
|
||||
root,
|
||||
panes,
|
||||
createPaneInternal: () => pane,
|
||||
setActivePaneId: vi.fn(),
|
||||
getActivePaneId: () => pane.id,
|
||||
getStyleOptions: () => ({}),
|
||||
publishPaneCreated
|
||||
} as unknown as PaneManagerHost
|
||||
return { root, container, textarea, pane, host, manager, publishPaneCreated, onActivePaneChange }
|
||||
}
|
||||
|
||||
function overlay(role: string) {
|
||||
const element = document.createElement('div')
|
||||
element.setAttribute('role', role)
|
||||
element.tabIndex = -1
|
||||
document.body.append(element)
|
||||
element.focus()
|
||||
return element
|
||||
}
|
||||
|
||||
describe.each(['initial', 'active'] as const)('%s pane focus', (operation) => {
|
||||
function focus(f: ReturnType<typeof fixture>, requested = true) {
|
||||
if (operation === 'initial') {
|
||||
createInitialManagedPane(f.host, { focus: requested })
|
||||
expect(f.publishPaneCreated).toHaveBeenCalledWith(f.pane)
|
||||
} else {
|
||||
f.root.append(f.container)
|
||||
f.manager.setActivePane(f.pane.id, { focus: requested })
|
||||
expect(f.manager.getActivePane()?.id).toBe(f.pane.id)
|
||||
expect(f.onActivePaneChange).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
}
|
||||
|
||||
it.each(['menu', 'dialog', 'alertdialog', 'listbox'])('preserves a visible %s', (role) => {
|
||||
const f = fixture()
|
||||
const popup = overlay(role)
|
||||
focus(f)
|
||||
expect(document.activeElement).toBe(popup)
|
||||
expect(f.pane.terminal.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('focuses a terminal hosted inside a dialog', () => {
|
||||
const f = fixture()
|
||||
overlay('dialog').append(f.root)
|
||||
focus(f)
|
||||
expect(document.activeElement).toBe(f.textarea)
|
||||
})
|
||||
|
||||
it('preserves a nested popup over a dialog-hosted terminal', () => {
|
||||
const f = fixture()
|
||||
const dialog = overlay('dialog')
|
||||
dialog.append(f.root)
|
||||
const popup = overlay('menu')
|
||||
dialog.append(popup)
|
||||
popup.focus()
|
||||
focus(f)
|
||||
expect(document.activeElement).toBe(popup)
|
||||
})
|
||||
|
||||
it('allows focus with only persistent sidebar chrome', () => {
|
||||
const f = fixture()
|
||||
overlay('listbox').setAttribute('data-worktree-sidebar', '')
|
||||
focus(f)
|
||||
expect(document.activeElement).toBe(f.textarea)
|
||||
})
|
||||
|
||||
it('allows focus after the overlay closes', () => {
|
||||
const f = fixture()
|
||||
overlay('menu').style.display = 'none'
|
||||
focus(f)
|
||||
expect(document.activeElement).toBe(f.textarea)
|
||||
})
|
||||
|
||||
it('preserves a popup nested inside sidebar chrome', () => {
|
||||
const f = fixture()
|
||||
const sidebar = overlay('listbox')
|
||||
sidebar.setAttribute('data-worktree-sidebar', '')
|
||||
const popup = overlay('menu')
|
||||
sidebar.append(popup)
|
||||
popup.focus()
|
||||
focus(f)
|
||||
expect(document.activeElement).toBe(popup)
|
||||
})
|
||||
|
||||
it('honors an explicit no-focus request', () => {
|
||||
const f = fixture()
|
||||
focus(f, false)
|
||||
expect(f.pane.terminal.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { hasVisibleOverlay } from '../visible-overlay'
|
||||
import type { ManagedPane } from './pane-manager-types'
|
||||
|
||||
export function focusPanePreservingOverlays(
|
||||
pane: Pick<ManagedPane, 'container' | 'terminal'>
|
||||
): void {
|
||||
if (
|
||||
typeof document !== 'undefined' &&
|
||||
hasVisibleOverlay({
|
||||
ignoreMatches: '[role="listbox"][data-worktree-sidebar]',
|
||||
ignoreContaining: pane.container,
|
||||
ignoreDismissed: true
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
pane.terminal.focus()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useAppStore } from '../store'
|
||||
|
||||
/** Move `tabId` to sit immediately after `anchorTabId`; no-op unless both share a group. */
|
||||
export function insertUnifiedTabAfterAnchor(
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
anchorTabId: string
|
||||
): void {
|
||||
if (tabId === anchorTabId) {
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const group = (state.groupsByWorktree[worktreeId] ?? []).find(
|
||||
(candidate) => candidate.tabOrder.includes(tabId) && candidate.tabOrder.includes(anchorTabId)
|
||||
)
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
const order = group.tabOrder.filter((id) => id !== tabId)
|
||||
order.splice(order.indexOf(anchorTabId) + 1, 0, tabId)
|
||||
state.reorderUnifiedTabs(group.id, order, { recordInteraction: false })
|
||||
}
|
||||
@@ -5,12 +5,19 @@ const OVERLAY_SELECTOR =
|
||||
type VisibleOverlayOptions = {
|
||||
/** Overlays inside a match are treated as page content, not as a layer above it. */
|
||||
ignoreSelector?: string
|
||||
/** Ignore matching chrome itself while retaining overlays nested within it. */
|
||||
ignoreMatches?: string
|
||||
/** A terminal hosted inside an overlay may still take focus within that overlay. */
|
||||
ignoreContaining?: Element
|
||||
/** An overlay animating out no longer outranks focus that was queued before it closed. */
|
||||
ignoreDismissed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a dialog, alert dialog, listbox, or menu is on screen. Page-level Escape
|
||||
* handlers ask this before acting: the overlay owns the first Escape, and a page
|
||||
* that preventDefaults instead vetoes the overlay's own dismissal.
|
||||
* that preventDefaults instead vetoes the overlay's own dismissal. Terminal focus
|
||||
* asks the same question: a live overlay outranks a queued pane focus.
|
||||
*/
|
||||
export function hasVisibleOverlay(options?: VisibleOverlayOptions): boolean {
|
||||
return Array.from(document.querySelectorAll(OVERLAY_SELECTOR)).some((element) => {
|
||||
@@ -23,6 +30,19 @@ export function hasVisibleOverlay(options?: VisibleOverlayOptions): boolean {
|
||||
if (options?.ignoreSelector && element.closest(options.ignoreSelector)) {
|
||||
return false
|
||||
}
|
||||
if (options?.ignoreMatches && element.matches(options.ignoreMatches)) {
|
||||
return false
|
||||
}
|
||||
if (options?.ignoreContaining && element.contains(options.ignoreContaining)) {
|
||||
return false
|
||||
}
|
||||
// Why: overlays stay mounted and painted through their exit animation, so a
|
||||
// dismissed one would otherwise keep owning a queued focus for ~300ms. Escape
|
||||
// callers opt out: they run before the attribute flips, so it only ever hides
|
||||
// a still-open overlay from them.
|
||||
if (options?.ignoreDismissed && element.getAttribute('data-state') === 'closed') {
|
||||
return false
|
||||
}
|
||||
const style = window.getComputedStyle(element)
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
|
||||
@@ -25,34 +25,28 @@ export abstract class RemoteRuntimeTerminalBinaryController extends RemoteRuntim
|
||||
this.failConnection(new Error('Remote terminal stream received a malformed frame.'))
|
||||
return
|
||||
}
|
||||
const isOutput =
|
||||
frame.opcode === TerminalStreamOpcode.Output ||
|
||||
frame.opcode === TerminalStreamOpcode.OutputSpan
|
||||
const stream = this.streams.get(frame.streamId)
|
||||
if (!stream) {
|
||||
if (
|
||||
frame.opcode === TerminalStreamOpcode.Output ||
|
||||
frame.opcode === TerminalStreamOpcode.OutputSpan
|
||||
) {
|
||||
if (isOutput) {
|
||||
// Why: the renderer already disposed this stream; unsubscribe releases server credit that cannot reach a parser.
|
||||
this.sendFrame(frame.streamId, TerminalStreamOpcode.Unsubscribe)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (
|
||||
(frame.opcode === TerminalStreamOpcode.Output ||
|
||||
frame.opcode === TerminalStreamOpcode.OutputSpan) &&
|
||||
shouldDropE2eRemoteTerminalOutput(stream, frame.payload.byteLength)
|
||||
) {
|
||||
if (isOutput && shouldDropE2eRemoteTerminalOutput(stream, frame.payload.byteLength)) {
|
||||
this.queueOutputAcknowledgement(stream, frame.payload.byteLength)
|
||||
return
|
||||
}
|
||||
stream.watchdog.recordInbound()
|
||||
// Control frames prove transport activity, not delivery of command output.
|
||||
stream.watchdog.recordInbound(isOutput)
|
||||
if (frame.opcode === TerminalStreamOpcode.WriteUnavailable) {
|
||||
stream.callbacks.onWriteUnavailable?.()
|
||||
return
|
||||
}
|
||||
if (
|
||||
frame.opcode === TerminalStreamOpcode.Output ||
|
||||
frame.opcode === TerminalStreamOpcode.OutputSpan
|
||||
) {
|
||||
if (isOutput) {
|
||||
this.handleOutputFrame(frame, stream)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export abstract class RemoteRuntimeTerminalResponseController extends RemoteRunt
|
||||
if (!stream) {
|
||||
return
|
||||
}
|
||||
stream.watchdog.recordInbound()
|
||||
stream.watchdog.recordInbound(false)
|
||||
if (event.type === 'end' && shouldHoldE2eRemoteTerminalEnd(stream.terminal)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -236,7 +236,28 @@ describe('remote terminal stalled stream recovery', () => {
|
||||
stream.close()
|
||||
})
|
||||
|
||||
it('restarts a stream when the authoritative snapshot advanced without live output', async () => {
|
||||
// Why: a control frame landing just after Enter is transport activity, not the command's answer.
|
||||
it.each<[string, (streamId: number) => void]>([
|
||||
['no intervening frame', () => {}],
|
||||
['a resize acknowledgement', (id) => emitControlFrame(id, TerminalStreamOpcode.Resized)],
|
||||
['a metadata frame', (id) => emitControlFrame(id, TerminalStreamOpcode.Metadata)],
|
||||
[
|
||||
'a fit-override change',
|
||||
(id) =>
|
||||
emitStreamEvent({
|
||||
type: 'fit-override-changed',
|
||||
streamId: id,
|
||||
mode: 'mobile-fit',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
],
|
||||
[
|
||||
'a driver change',
|
||||
(id) => emitStreamEvent({ type: 'driver-changed', streamId: id, driver: { kind: 'idle' } })
|
||||
],
|
||||
['an unsolicited snapshot', (id) => emitSnapshot(id, undefined, 'baseline', 8)]
|
||||
])('recovers missing live output despite %s', async (_label, emitIntervening) => {
|
||||
const { getRemoteRuntimeTerminalMultiplexer } =
|
||||
await import('./remote-runtime-terminal-multiplexer')
|
||||
const onTransportClose = vi.fn()
|
||||
@@ -250,8 +271,10 @@ describe('remote terminal stalled stream recovery', () => {
|
||||
sendBinary.mockClear()
|
||||
|
||||
expect(stream.sendInput('echo missing\r')).toBe(true)
|
||||
emitIntervening(stream.streamId)
|
||||
await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS)
|
||||
const request = sentFrames(TerminalStreamOpcode.SnapshotRequest)[0]
|
||||
expect(request).toBeDefined()
|
||||
const payload = request
|
||||
? decodeTerminalStreamJson<{ requestId: number }>(request.payload)
|
||||
: null
|
||||
@@ -445,6 +468,21 @@ describe('remote terminal stalled stream recovery', () => {
|
||||
)
|
||||
}
|
||||
|
||||
function emitControlFrame(streamId: number, opcode: TerminalStreamOpcode): void {
|
||||
callbacks?.onBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode,
|
||||
streamId,
|
||||
seq: 0,
|
||||
payload: encodeTerminalStreamJson({ cols: 80, rows: 24 })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function emitStreamEvent(result: Record<string, unknown>): void {
|
||||
callbacks?.onResponse({ ok: true, result })
|
||||
}
|
||||
|
||||
function emitSnapshot(
|
||||
streamId: number,
|
||||
requestId: number | undefined,
|
||||
|
||||
@@ -13,7 +13,8 @@ export type RemoteTerminalStreamWatchdog = {
|
||||
recordOutputAcknowledged: (bytes: number) => void
|
||||
completeCommandResponseProbe: () => void
|
||||
recordCommandInput: (text: string) => void
|
||||
recordInbound: () => void
|
||||
/** Only live output answers a pending command; control frames prove transport activity alone. */
|
||||
recordInbound: (carriesOutput: boolean) => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
@@ -111,9 +112,11 @@ export function createRemoteTerminalStreamWatchdog(
|
||||
REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS
|
||||
)
|
||||
},
|
||||
recordInbound() {
|
||||
recordInbound(carriesOutput) {
|
||||
lastInboundAtMs = Date.now()
|
||||
clearResponseTimer()
|
||||
if (carriesOutput) {
|
||||
clearResponseTimer()
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
disposed = true
|
||||
|
||||
@@ -131,6 +131,71 @@ describe('createWebRuntimeSessionTerminal', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
agent: 'codex' as const,
|
||||
predecessor: 'web-terminal-host-tab-1',
|
||||
afterTabId: 'web-terminal-host-tab-1%3A%3Aleaf-1'
|
||||
},
|
||||
{
|
||||
agent: undefined,
|
||||
predecessor: 'web-terminal-host-tab-1',
|
||||
afterTabId: 'web-terminal-host-tab-1%3A%3Aleaf-1'
|
||||
},
|
||||
{ agent: undefined, predecessor: 'local-browser-tab', afterTabId: 'local-browser-tab' },
|
||||
{
|
||||
agent: undefined,
|
||||
predecessor: 'web-terminal-host-tab-1%3A%3Aleaf-1',
|
||||
afterTabId: 'web-terminal-host-tab-1%3A%3Aleaf-1'
|
||||
}
|
||||
])(
|
||||
'settles after $afterTabId for $agent creation without activating it',
|
||||
async ({ agent, predecessor, afterTabId }) => {
|
||||
const successor = 'web-terminal-host-tab-3'
|
||||
const created = 'web-terminal-host-tab-2'
|
||||
const reorderUnifiedTabs = vi.fn()
|
||||
mocks.getState.mockReturnValue({
|
||||
...mocks.getState(),
|
||||
unifiedTabsByWorktree: {
|
||||
[WORKTREE_ID]: [predecessor, successor, created].map((id) => ({
|
||||
id,
|
||||
groupId: 'client-group'
|
||||
}))
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[WORKTREE_ID]: [{ id: 'client-group', tabOrder: [predecessor, successor, created] }]
|
||||
},
|
||||
reorderUnifiedTabs,
|
||||
moveUnifiedTabToGroup: mocks.moveUnifiedTabToGroup
|
||||
})
|
||||
const runtimeCall = vi.fn(async (request: { method: string }) => ({
|
||||
id: request.method,
|
||||
ok: true,
|
||||
result:
|
||||
request.method === 'session.tabs.createTerminal'
|
||||
? { tab: { id: 'host-tab-2::leaf-2' }, publicationEpoch: 'epoch-1', snapshotVersion: 2 }
|
||||
: makeSnapshot()
|
||||
}))
|
||||
vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } })
|
||||
|
||||
await expect(
|
||||
createWebRuntimeSessionTerminal({
|
||||
worktreeId: WORKTREE_ID,
|
||||
afterTabId,
|
||||
agent,
|
||||
activate: false
|
||||
})
|
||||
).resolves.toEqual({ status: 'created' })
|
||||
|
||||
expect(reorderUnifiedTabs).toHaveBeenCalledExactlyOnceWith(
|
||||
'client-group',
|
||||
[predecessor, created, successor],
|
||||
{ recordInteraction: false }
|
||||
)
|
||||
expect(mocks.moveUnifiedTabToGroup).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('can create a terminal without selecting the target worktree', async () => {
|
||||
const setStateResults: unknown[] = []
|
||||
mocks.setState.mockImplementation((updater: (state: unknown) => unknown) => {
|
||||
|
||||
@@ -248,20 +248,26 @@ export async function createWebRuntimeSessionTerminalResult(
|
||||
// tab to THIS new terminal, instead of sticky-keeping the prior tab.
|
||||
recordWebSessionFocusIntent(intentOwner, args.worktreeId, createdTabId, createdLeafId)
|
||||
}
|
||||
const placementTabId =
|
||||
createdTabId && (args.targetGroupId || args.afterTabId) ? createdTabId : undefined
|
||||
await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, {
|
||||
expectedEnvironmentPairingRevision: intentOwner.pairingRevision,
|
||||
// Why: the publication can beat the RPC response; replay it once after caller intent exists.
|
||||
acceptCurrentSnapshot:
|
||||
Boolean(createdTabId) && (args.activate !== false || Boolean(args.targetGroupId)),
|
||||
Boolean(createdTabId) && (args.activate !== false || Boolean(placementTabId)),
|
||||
// Why: a placement record needs a post-create list; a deduped in-flight one can predate it.
|
||||
...(args.targetGroupId && createdTabId ? { afterCurrentInFlight: true } : {})
|
||||
...(placementTabId ? { afterCurrentInFlight: true } : {})
|
||||
})
|
||||
if (args.targetGroupId && createdTabId) {
|
||||
if (placementTabId) {
|
||||
await settleWebRuntimeTerminalPlacement(
|
||||
environmentId,
|
||||
args.worktreeId,
|
||||
webTerminalPlacementParentTabId(createdTabId),
|
||||
{ groupId: args.targetGroupId, activate: args.activate !== false }
|
||||
webTerminalPlacementParentTabId(placementTabId),
|
||||
{
|
||||
groupId: args.targetGroupId,
|
||||
afterTabId: args.afterTabId,
|
||||
activate: args.activate !== false
|
||||
}
|
||||
)
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import { insertUnifiedTabAfterAnchor } from '../lib/unified-tab-anchor-insertion'
|
||||
import { useAppStore } from '../store'
|
||||
import { forgetWebSessionTerminalPlacement } from './web-session-terminal-placement'
|
||||
import { toWebTerminalSurfaceTabId } from './web-terminal-surface-id'
|
||||
import {
|
||||
forgetWebSessionTerminalPlacement,
|
||||
webTerminalPlacementParentTabId
|
||||
} from './web-session-terminal-placement'
|
||||
import {
|
||||
isWebTerminalSurfaceTabId,
|
||||
toHostSessionTabId,
|
||||
toWebTerminalSurfaceTabId
|
||||
} from './web-terminal-surface-id'
|
||||
|
||||
/** Snapshots key mirrored terminals by the parent tab, so an unknown `parent::leaf` anchor resolves to its parent. */
|
||||
function anchorUnifiedTabId(worktreeId: string, afterTabId: string): string {
|
||||
const known = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).some(
|
||||
(tab) => tab.id === afterTabId
|
||||
)
|
||||
return known || !isWebTerminalSurfaceTabId(afterTabId)
|
||||
? afterTabId
|
||||
: toWebTerminalSurfaceTabId(webTerminalPlacementParentTabId(toHostSessionTabId(afterTabId)))
|
||||
}
|
||||
|
||||
/** Settle the placement once the mirrored tab exists (bounded poll), then consume the record. */
|
||||
export async function settleWebRuntimeTerminalPlacement(
|
||||
environmentId: string,
|
||||
worktreeId: string,
|
||||
hostTabId: string,
|
||||
placement: { groupId: string; activate: boolean }
|
||||
placement: { groupId?: string; afterTabId?: string; activate: boolean }
|
||||
): Promise<void> {
|
||||
const unifiedTabId = toWebTerminalSurfaceTabId(hostTabId)
|
||||
const findTab = () =>
|
||||
@@ -20,18 +38,36 @@ export async function settleWebRuntimeTerminalPlacement(
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
const tab = findTab()
|
||||
if (!tab) {
|
||||
return
|
||||
}
|
||||
const anchorId = placement.afterTabId
|
||||
? anchorUnifiedTabId(worktreeId, placement.afterTabId)
|
||||
: undefined
|
||||
const state = useAppStore.getState()
|
||||
const targetGroupExists = (state.groupsByWorktree[worktreeId] ?? []).some(
|
||||
(group) => group.id === placement.groupId
|
||||
)
|
||||
if (tab && targetGroupExists && tab.groupId !== placement.groupId) {
|
||||
const groups = state.groupsByWorktree[worktreeId] ?? []
|
||||
// Why: the requested group can be closed while the mirrored tab is still in flight; the
|
||||
// anchor's own group still expresses where the caller asked for this terminal.
|
||||
const targetGroup =
|
||||
groups.find((group) => group.id === placement.groupId) ??
|
||||
(anchorId === undefined
|
||||
? undefined
|
||||
: groups.find((group) => group.tabOrder.includes(anchorId)))
|
||||
if (!targetGroup) {
|
||||
return
|
||||
}
|
||||
if (tab.groupId !== targetGroup.id) {
|
||||
// Why: a snapshot can adopt the tab before the record exists (the publication races the
|
||||
// RPC response); repair through the same client-owned move a user drag takes.
|
||||
state.moveUnifiedTabToGroup(unifiedTabId, placement.groupId, {
|
||||
state.moveUnifiedTabToGroup(unifiedTabId, targetGroup.id, {
|
||||
activate: placement.activate,
|
||||
recordInteraction: false
|
||||
})
|
||||
}
|
||||
if (anchorId) {
|
||||
// The create caller owns this insertion; subsequent host snapshots preserve client order.
|
||||
insertUnifiedTabAfterAnchor(worktreeId, unifiedTabId, anchorId)
|
||||
}
|
||||
} finally {
|
||||
forgetWebSessionTerminalPlacement({ environmentId, worktreeId, hostTabId })
|
||||
}
|
||||
|
||||
@@ -74,6 +74,20 @@ describe('agent pane authority', () => {
|
||||
expect(retirePaneAuthority).toHaveBeenCalledWith(TARGET)
|
||||
})
|
||||
|
||||
it('re-retiring an already-retired pane keeps the retired-key map identity and epochs', () => {
|
||||
const store = createTestStore()
|
||||
store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'target' })
|
||||
store.getState().retireAgentPaneAuthority(TARGET)
|
||||
const before = store.getState()
|
||||
|
||||
store.getState().retireAgentPaneAuthority(TARGET)
|
||||
|
||||
const after = store.getState()
|
||||
expect(after.recentlyRetiredAgentStatusPaneKeys).toBe(before.recentlyRetiredAgentStatusPaneKeys)
|
||||
expect(after.agentStatusEpoch).toBe(before.agentStatusEpoch)
|
||||
expect(after.sortEpoch).toBe(before.sortEpoch)
|
||||
})
|
||||
|
||||
it('retires the pane activity cutoff with the rest of its pane-owned state', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX,
|
||||
RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX,
|
||||
boundRecentlyClosedAgentStatusTabIds,
|
||||
boundRecentlyRetiredAgentStatusPaneKeys
|
||||
} from './agent-status-pane-keyed-records'
|
||||
|
||||
function keyRecord(keys: readonly string[]): Record<string, true> {
|
||||
const record: Record<string, true> = {}
|
||||
for (const key of keys) {
|
||||
record[key] = true
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
function fullRecord(max: number, prefix: string): Record<string, true> {
|
||||
return keyRecord(Array.from({ length: max }, (_, i) => `${prefix}${i}`))
|
||||
}
|
||||
|
||||
describe('boundRecentlyRetiredAgentStatusPaneKeys', () => {
|
||||
it('returns the existing record when there is nothing to add', () => {
|
||||
const existing = keyRecord(['a', 'b'])
|
||||
expect(boundRecentlyRetiredAgentStatusPaneKeys(existing, [])).toBe(existing)
|
||||
const empty = keyRecord([])
|
||||
expect(boundRecentlyRetiredAgentStatusPaneKeys(empty, [])).toBe(empty)
|
||||
})
|
||||
|
||||
it('returns the existing record when the additions already form its tail in order', () => {
|
||||
const existing = keyRecord(['a', 'b', 'c'])
|
||||
expect(boundRecentlyRetiredAgentStatusPaneKeys(existing, ['c'])).toBe(existing)
|
||||
expect(boundRecentlyRetiredAgentStatusPaneKeys(existing, ['b', 'c'])).toBe(existing)
|
||||
expect(boundRecentlyRetiredAgentStatusPaneKeys(existing, ['a', 'b', 'c'])).toBe(existing)
|
||||
})
|
||||
|
||||
// Why: LRU order decides which key the cap evicts next. A key-set match is not a
|
||||
// no-op when the re-added key is not already at the tail — it must move there.
|
||||
it('re-retiring an existing non-tail key changes identity and moves it to the tail', () => {
|
||||
const existing = keyRecord(['a', 'b', 'c'])
|
||||
const next = boundRecentlyRetiredAgentStatusPaneKeys(existing, ['a'])
|
||||
expect(next).not.toBe(existing)
|
||||
expect(Object.keys(next)).toEqual(['b', 'c', 'a'])
|
||||
expect(Object.keys(existing)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('tail keys re-added in a different relative order are rebuilt in the new order', () => {
|
||||
const existing = keyRecord(['a', 'b', 'c'])
|
||||
const next = boundRecentlyRetiredAgentStatusPaneKeys(existing, ['c', 'b'])
|
||||
expect(next).not.toBe(existing)
|
||||
expect(Object.keys(next)).toEqual(['a', 'c', 'b'])
|
||||
})
|
||||
|
||||
it('appends new keys after the existing ones', () => {
|
||||
const existing = keyRecord(['a'])
|
||||
const next = boundRecentlyRetiredAgentStatusPaneKeys(existing, ['b', 'c'])
|
||||
expect(next).not.toBe(existing)
|
||||
expect(Object.keys(next)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('evicts the oldest keys once the cap is exceeded', () => {
|
||||
const full = fullRecord(RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX, 'k')
|
||||
const next = boundRecentlyRetiredAgentStatusPaneKeys(full, ['fresh'])
|
||||
const keys = Object.keys(next)
|
||||
expect(keys).toHaveLength(RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)
|
||||
expect(keys[0]).toBe('k1')
|
||||
expect(keys.at(-1)).toBe('fresh')
|
||||
expect(next.k0).toBeUndefined()
|
||||
})
|
||||
|
||||
it('re-retiring the oldest key at the cap keeps it fenced and evicts the next oldest', () => {
|
||||
const full = fullRecord(RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX, 'k')
|
||||
const bumped = boundRecentlyRetiredAgentStatusPaneKeys(full, ['k0'])
|
||||
expect(bumped).not.toBe(full)
|
||||
expect(Object.keys(bumped).at(-1)).toBe('k0')
|
||||
const afterFresh = boundRecentlyRetiredAgentStatusPaneKeys(bumped, ['fresh'])
|
||||
expect(afterFresh.k0).toBe(true)
|
||||
expect(afterFresh.k1).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never returns an over-cap record unchanged', () => {
|
||||
const over = fullRecord(RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX + 1, 'k')
|
||||
const last = `k${RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX}`
|
||||
const next = boundRecentlyRetiredAgentStatusPaneKeys(over, [last])
|
||||
expect(next).not.toBe(over)
|
||||
expect(Object.keys(next)).toHaveLength(RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)
|
||||
expect(next.k0).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('boundRecentlyClosedAgentStatusTabIds', () => {
|
||||
it('returns the existing record when the tab is already the most recent', () => {
|
||||
const existing = keyRecord(['t1', 't2'])
|
||||
expect(boundRecentlyClosedAgentStatusTabIds(existing, 't2')).toBe(existing)
|
||||
})
|
||||
|
||||
it('moves a re-closed tab to the tail', () => {
|
||||
const existing = keyRecord(['t1', 't2'])
|
||||
const next = boundRecentlyClosedAgentStatusTabIds(existing, 't1')
|
||||
expect(next).not.toBe(existing)
|
||||
expect(Object.keys(next)).toEqual(['t2', 't1'])
|
||||
})
|
||||
|
||||
it('evicts the oldest tab once the cap is exceeded', () => {
|
||||
const full = fullRecord(RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX, 't')
|
||||
const next = boundRecentlyClosedAgentStatusTabIds(full, 'fresh')
|
||||
expect(Object.keys(next)).toHaveLength(RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX)
|
||||
expect(next.t0).toBeUndefined()
|
||||
expect(next.fresh).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -3,47 +3,66 @@ export const RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX = 1024
|
||||
|
||||
// delete-then-set for LRU recency, then evict oldest keys past the cap (Record iterates
|
||||
// insertion order); safe because a status for a tab closed >MAX tabs ago cannot still arrive.
|
||||
export function boundRecentlyClosedAgentStatusTabIds(
|
||||
function boundLruKeyRecord(
|
||||
existing: Record<string, true>,
|
||||
tabId: string
|
||||
additions: ReadonlySet<string>,
|
||||
max: number
|
||||
): Record<string, true> {
|
||||
const next: Record<string, true> = {}
|
||||
for (const key of Object.keys(existing)) {
|
||||
if (key !== tabId) {
|
||||
next[key] = true
|
||||
}
|
||||
if (isLruKeyRecordUnchanged(existing, additions, max)) {
|
||||
return existing
|
||||
}
|
||||
next[tabId] = true
|
||||
const keys = Object.keys(next)
|
||||
if (keys.length > RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX) {
|
||||
for (const stale of keys.slice(0, keys.length - RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX)) {
|
||||
delete next[stale]
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function boundRecentlyRetiredAgentStatusPaneKeys(
|
||||
existing: Record<string, true>,
|
||||
paneKeys: readonly string[]
|
||||
): Record<string, true> {
|
||||
const additions = new Set(paneKeys)
|
||||
const next: Record<string, true> = {}
|
||||
for (const key of Object.keys(existing)) {
|
||||
if (!additions.has(key)) {
|
||||
next[key] = true
|
||||
}
|
||||
}
|
||||
for (const paneKey of additions) {
|
||||
next[paneKey] = true
|
||||
for (const key of additions) {
|
||||
next[key] = true
|
||||
}
|
||||
const keys = Object.keys(next)
|
||||
for (const stale of keys.slice(0, -RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)) {
|
||||
for (const stale of keys.slice(0, -max)) {
|
||||
delete next[stale]
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// The rebuild is a no-op only when nothing would be evicted and the additions are
|
||||
// already the tail of `existing` in that same relative order. A matching key SET is
|
||||
// not enough: re-adding a key moves it to the tail, and that order decides which key
|
||||
// the cap evicts next, so a stale-order hit would un-fence a recently retired pane.
|
||||
function isLruKeyRecordUnchanged(
|
||||
existing: Record<string, true>,
|
||||
additions: ReadonlySet<string>,
|
||||
max: number
|
||||
): boolean {
|
||||
const keys = Object.keys(existing)
|
||||
if (keys.length > max || additions.size > keys.length) {
|
||||
return false
|
||||
}
|
||||
let index = keys.length - additions.size
|
||||
for (const key of additions) {
|
||||
if (keys[index++] !== key) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function boundRecentlyClosedAgentStatusTabIds(
|
||||
existing: Record<string, true>,
|
||||
tabId: string
|
||||
): Record<string, true> {
|
||||
return boundLruKeyRecord(existing, new Set([tabId]), RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX)
|
||||
}
|
||||
|
||||
export function boundRecentlyRetiredAgentStatusPaneKeys(
|
||||
existing: Record<string, true>,
|
||||
paneKeys: readonly string[]
|
||||
): Record<string, true> {
|
||||
return boundLruKeyRecord(existing, new Set(paneKeys), RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)
|
||||
}
|
||||
|
||||
export function movePaneKeyedRecord<T>(
|
||||
record: Record<string, T>,
|
||||
fromPaneKey: string,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getFallbackTabTypeForWorktree, isLocalBrowserPageOwner } from './browse
|
||||
import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-close'
|
||||
import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants'
|
||||
import { destroyWorkspaceWebviews } from '../browser-webview-cleanup'
|
||||
import { omitRecordKeys } from '../worktrees/teardown/record-key-omission'
|
||||
|
||||
export function createBrowserCloseActions(
|
||||
set: BrowserSliceSet,
|
||||
@@ -231,10 +232,15 @@ export function createBrowserCloseActions(
|
||||
destroyWorkspaceWebviews(browserPagesByWorkspace, workspace.id)
|
||||
}
|
||||
set((s) => {
|
||||
const nextBrowserTabsByWorktree = { ...s.browserTabsByWorktree }
|
||||
delete nextBrowserTabsByWorktree[worktreeId]
|
||||
const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree }
|
||||
delete nextActiveBrowserTabIdByWorktree[worktreeId]
|
||||
const removedWorktreeIds = [worktreeId]
|
||||
const nextBrowserTabsByWorktree = omitRecordKeys(
|
||||
s.browserTabsByWorktree,
|
||||
removedWorktreeIds
|
||||
)
|
||||
const nextActiveBrowserTabIdByWorktree = omitRecordKeys(
|
||||
s.activeBrowserTabIdByWorktree,
|
||||
removedWorktreeIds
|
||||
)
|
||||
// Why: reset the global browser surface only when the shut-down worktree is the active one AND had tabs.
|
||||
const shouldResetGlobalBrowser = s.activeWorktreeId === worktreeId && hadBrowserTabs
|
||||
return {
|
||||
|
||||
@@ -35,6 +35,12 @@ export function applyRemoveWorktreeSuccessState(
|
||||
}
|
||||
}
|
||||
const omitByFileId = <T>(m: Record<string, T> | undefined) => omitRecordKeys(m, removedFileIds)
|
||||
// Why guarded: a removed worktree usually has no open file, and an unconditional
|
||||
// filter would hand openFiles a new identity anyway — the sibling purge path
|
||||
// already does this.
|
||||
const nextOpenFiles = s.openFiles.some((f) => f.worktreeId === worktreeId)
|
||||
? s.openFiles.filter((f) => f.worktreeId !== worktreeId)
|
||||
: s.openFiles
|
||||
// If the active file belonged to the removed worktree, clear it
|
||||
const activeFileCleared = s.activeFileId
|
||||
? s.openFiles.some((f) => f.id === s.activeFileId && f.worktreeId === worktreeId)
|
||||
@@ -79,7 +85,7 @@ export function applyRemoveWorktreeSuccessState(
|
||||
? null
|
||||
: s.activeWorkspaceExecutionHostId,
|
||||
activeTabId: s.activeTabId && tabIds.has(s.activeTabId) ? null : s.activeTabId,
|
||||
openFiles: s.openFiles.filter((f) => f.worktreeId !== worktreeId),
|
||||
openFiles: nextOpenFiles,
|
||||
browserTabsByWorktree: omitByWorktree(s.browserTabsByWorktree),
|
||||
// Why: closeBrowserTab records a Cmd+Shift+T undo snapshot, but a deleted worktree's tabs can't be restored; purge it.
|
||||
recentlyClosedBrowserTabsByWorktree: omitByWorktree(s.recentlyClosedBrowserTabsByWorktree),
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AppState } from '../../../types'
|
||||
import { applyRemoveWorktreeSuccessState } from './remove-worktree-store-cleanup'
|
||||
|
||||
type OpenFile = AppState['openFiles'][number]
|
||||
|
||||
const REMOVED = 'repo-1::/repos/one/removed'
|
||||
const KEPT = 'repo-1::/repos/one/kept'
|
||||
|
||||
function fileFor(worktreeId: string, id: string): OpenFile {
|
||||
return { id, worktreeId, path: `${worktreeId}/f.ts`, name: 'f.ts' } as unknown as OpenFile
|
||||
}
|
||||
|
||||
function buildState(openFiles: OpenFile[]): AppState {
|
||||
return {
|
||||
worktreesByRepo: { 'repo-1': [] },
|
||||
tabsByWorktree: { [KEPT]: [] },
|
||||
openFiles,
|
||||
everActivatedWorktreeIds: new Set<string>(),
|
||||
lastVisitedAtByWorktreeId: {},
|
||||
deleteStateByWorktreeId: {},
|
||||
sortEpoch: 0
|
||||
} as unknown as AppState
|
||||
}
|
||||
|
||||
function removeWorktree(state: AppState): AppState {
|
||||
let current = state
|
||||
applyRemoveWorktreeSuccessState(
|
||||
(update) => {
|
||||
const patch = typeof update === 'function' ? update(current) : update
|
||||
current = { ...current, ...patch }
|
||||
},
|
||||
REMOVED,
|
||||
new Set<string>()
|
||||
)
|
||||
return current
|
||||
}
|
||||
|
||||
describe('worktree removal openFiles identity', () => {
|
||||
it('keeps the openFiles reference when the removed worktree had no open file', () => {
|
||||
// openFiles is selected whole by the editor panel, file explorer and git-status
|
||||
// polling, so a fresh array here rerenders all of them for no data change.
|
||||
const before = buildState([fileFor(KEPT, 'kept-file')])
|
||||
|
||||
const after = removeWorktree(before)
|
||||
|
||||
expect(after.openFiles).toBe(before.openFiles)
|
||||
})
|
||||
|
||||
it('still drops the removed worktree files', () => {
|
||||
const before = buildState([fileFor(KEPT, 'kept-file'), fileFor(REMOVED, 'gone-file')])
|
||||
|
||||
const after = removeWorktree(before)
|
||||
|
||||
expect(after.openFiles).not.toBe(before.openFiles)
|
||||
expect(after.openFiles.map((f) => f.id)).toEqual(['kept-file'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import { createTerminalShutdownGuardController } from './terminal-shutdown-guards'
|
||||
|
||||
vi.mock('@/components/terminal-pane/pty-transport', () => ({
|
||||
restorePtyDataHandlersAfterFailedShutdown: vi.fn(),
|
||||
unregisterPtyDataHandlers: vi.fn(() => [])
|
||||
}))
|
||||
vi.mock('@/components/terminal-pane/terminal-parked-watcher-registry', () => ({
|
||||
disposeParkedTerminalWatchersForPtyIds: vi.fn()
|
||||
}))
|
||||
vi.mock('@/components/terminal-pane/pty-shutdown-exit-deferral', () => ({
|
||||
clearCommittedPtyShutdownSettlements: vi.fn(),
|
||||
hasCommittedPtyShutdownSettlement: vi.fn(() => false),
|
||||
markCommittedPtyShutdowns: vi.fn(),
|
||||
noteCommittedPtyShutdownSettlements: vi.fn(),
|
||||
settleDeferredPtyShutdownExits: vi.fn()
|
||||
}))
|
||||
|
||||
function harness(initial: Partial<AppState>, exitGuardPtyIds: readonly string[]) {
|
||||
let current = initial as AppState
|
||||
const set = vi.fn((update: unknown) => {
|
||||
const patch =
|
||||
typeof update === 'function' ? (update as (s: AppState) => object)(current) : update
|
||||
current = { ...current, ...(patch as object) }
|
||||
})
|
||||
const guards = createTerminalShutdownGuardController({
|
||||
exitGuardPtyIds,
|
||||
get: (() => current) as never,
|
||||
keepIdentifiers: false,
|
||||
rendererShutdownPtyIds: exitGuardPtyIds,
|
||||
runtimeEnvironmentId: null,
|
||||
set: set as never,
|
||||
tabs: []
|
||||
})
|
||||
return { guards, set, state: () => current }
|
||||
}
|
||||
|
||||
describe('markShutdownPending identity', () => {
|
||||
it('does not write the store when there is nothing to guard', () => {
|
||||
const { guards, set } = harness({ suppressedPtyExitIds: {}, pendingPtyShutdownIds: {} }, [])
|
||||
|
||||
guards.markShutdownPending()
|
||||
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still counts a pending owner when every id is already suppressed', () => {
|
||||
const suppressedPtyExitIds: Record<string, true> = { 'pty-1': true }
|
||||
const { guards, state } = harness(
|
||||
{ suppressedPtyExitIds, pendingPtyShutdownIds: { 'pty-1': 1 } },
|
||||
['pty-1']
|
||||
)
|
||||
|
||||
guards.markShutdownPending()
|
||||
|
||||
expect(state().suppressedPtyExitIds).toBe(suppressedPtyExitIds)
|
||||
expect(state().pendingPtyShutdownIds).toEqual({ 'pty-1': 2 })
|
||||
})
|
||||
|
||||
it('suppresses the ids that were not yet suppressed', () => {
|
||||
const { guards, state } = harness(
|
||||
{ suppressedPtyExitIds: { 'pty-1': true }, pendingPtyShutdownIds: {} },
|
||||
['pty-1', 'pty-2']
|
||||
)
|
||||
|
||||
guards.markShutdownPending()
|
||||
|
||||
expect(state().suppressedPtyExitIds).toEqual({ 'pty-1': true, 'pty-2': true })
|
||||
expect(state().pendingPtyShutdownIds).toEqual({ 'pty-1': 1, 'pty-2': 1 })
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
settleDeferredPtyShutdownExits
|
||||
} from '@/components/terminal-pane/pty-shutdown-exit-deferral'
|
||||
import type { TerminalStoreGet, TerminalStoreSet } from './terminal-state'
|
||||
import { copyOnWriteRecord } from '../copy-on-write-record'
|
||||
|
||||
export type TerminalShutdownGuardController = {
|
||||
commitHandlerSnapshots: () => void
|
||||
@@ -48,18 +49,22 @@ export function createTerminalShutdownGuardController({
|
||||
let partialRendererStopSettled = false
|
||||
|
||||
const markShutdownPending = (): void => {
|
||||
// Why the early return: tearing down a worktree whose panes already exited passes
|
||||
// no guard ids, and the spreads below would still hand both maps a new identity.
|
||||
if (exitGuardPtyIds.length === 0) {
|
||||
return
|
||||
}
|
||||
set((state) => {
|
||||
const pendingPtyShutdownIds = { ...state.pendingPtyShutdownIds }
|
||||
// Why copy-on-write: re-guarding an already-suppressed pty writes the same `true`.
|
||||
const suppressedPtyExitIds = copyOnWriteRecord(state.suppressedPtyExitIds)
|
||||
for (const ptyId of exitGuardPtyIds) {
|
||||
pendingPtyShutdownIds[ptyId] = (pendingPtyShutdownIds[ptyId] ?? 0) + 1
|
||||
if (state.suppressedPtyExitIds[ptyId] !== true) {
|
||||
suppressedPtyExitIds.set(ptyId, true)
|
||||
}
|
||||
}
|
||||
return {
|
||||
suppressedPtyExitIds: {
|
||||
...state.suppressedPtyExitIds,
|
||||
...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const))
|
||||
},
|
||||
pendingPtyShutdownIds
|
||||
}
|
||||
return { suppressedPtyExitIds: suppressedPtyExitIds.read(), pendingPtyShutdownIds }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
PS_ARGS,
|
||||
PS_MAX_BUFFER_BYTES,
|
||||
ProcessTableCaptureError,
|
||||
SHELL_FOREGROUND_PS_ARGS,
|
||||
parseProcessTableRows,
|
||||
parseShellForegroundRows,
|
||||
parseStrictProcessTableRows,
|
||||
type ProcessTableRow
|
||||
} from './process-table-snapshot'
|
||||
@@ -250,29 +252,47 @@ async function readLinuxProcessStartTimes(
|
||||
return result
|
||||
}
|
||||
|
||||
async function captureProcessTable(args: readonly string[]): Promise<string> {
|
||||
let stdout: string
|
||||
try {
|
||||
;({ stdout } = await execFile('ps', [...args], {
|
||||
encoding: 'utf-8',
|
||||
timeout: PS_TIMEOUT_MS,
|
||||
maxBuffer: PS_MAX_BUFFER_BYTES
|
||||
}))
|
||||
} catch (error) {
|
||||
// A ceiling hit is truncation, not absence: name it in the domain vocabulary.
|
||||
if ((error as { code?: unknown } | null)?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
|
||||
throw new ProcessTableCaptureError('capture_truncated')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return assertWholeCapture(stdout)
|
||||
}
|
||||
|
||||
const processTableReader = createProcessTableSnapshotReader<ProcessTableCapture>({
|
||||
runPs: async () => {
|
||||
let stdout: string
|
||||
try {
|
||||
;({ stdout } = await execFile('ps', [...PS_ARGS], {
|
||||
encoding: 'utf-8',
|
||||
timeout: PS_TIMEOUT_MS,
|
||||
maxBuffer: PS_MAX_BUFFER_BYTES
|
||||
}))
|
||||
} catch (error) {
|
||||
// A ceiling hit is truncation, not absence: name it in the domain vocabulary.
|
||||
if ((error as { code?: unknown } | null)?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
|
||||
throw new ProcessTableCaptureError('capture_truncated')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const baseCapture = createProcessTableCapture(assertWholeCapture(stdout))
|
||||
const stdout = await captureProcessTable(PS_ARGS)
|
||||
const baseCapture = createProcessTableCapture(stdout)
|
||||
const startTimesByPid = await readLinuxProcessStartTimes(baseCapture.lenient())
|
||||
return createProcessTableCapture(stdout, startTimesByPid, process.platform === 'linux')
|
||||
},
|
||||
now: () => Date.now()
|
||||
})
|
||||
|
||||
// Its own reader, not a column-set flag on the shared one: terminal-name resolution dominates
|
||||
// macOS capture time, and a shell proof must not queue behind a full capture it cannot use.
|
||||
const shellForegroundReader = createProcessTableSnapshotReader<ProcessTableRow[]>({
|
||||
runPs: async () => parseShellForegroundRows(await captureProcessTable(SHELL_FOREGROUND_PS_ARGS)),
|
||||
now: () => Date.now()
|
||||
})
|
||||
|
||||
export async function getFreshShellForegroundSnapshot(): Promise<ProcessTableRow[]> {
|
||||
return process.platform === 'darwin'
|
||||
? shellForegroundReader.getFreshSnapshot()
|
||||
: getFreshProcessTableSnapshot()
|
||||
}
|
||||
|
||||
export async function getProcessTableSnapshot(): Promise<ProcessTableRow[]> {
|
||||
return (await processTableReader.getSnapshot()).lenient()
|
||||
}
|
||||
@@ -334,4 +354,5 @@ export async function getStrictProcessTableSnapshotWithAge(): Promise<{
|
||||
|
||||
export function resetProcessTableSnapshotForTests(): void {
|
||||
processTableReader.reset()
|
||||
shellForegroundReader.reset()
|
||||
}
|
||||
|
||||
@@ -38,6 +38,47 @@ export const CHEAP_PS_ARGS = (
|
||||
: ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=']
|
||||
) as readonly string[]
|
||||
|
||||
/**
|
||||
* Shell-proof tier: job control plus argv, dropping only the columns the shell predicate never
|
||||
* reads — macOS `tty=` (0.29s of the 0.34s on a 1,900-process Mac) and the start marker. Enough
|
||||
* to name a pane's foreground process; never enough to correlate a pid across captures.
|
||||
*/
|
||||
export const SHELL_FOREGROUND_PS_ARGS = [
|
||||
'-axo',
|
||||
'pid=,ppid=,pgid=,tpgid=,stat=,command='
|
||||
] as readonly string[]
|
||||
|
||||
/**
|
||||
* Parse a {@link SHELL_FOREGROUND_PS_ARGS} capture, anchored to exactly those columns.
|
||||
* Not {@link parseProcessTableRows}: with no `tty=` to absorb it, that parser's optional
|
||||
* tty/start pair eats the head of an argv shaped `python 3 app.py`, and a command-less zombie
|
||||
* row parses into a garbage pid/stat pair.
|
||||
*
|
||||
* Lenient per row like its siblings, but a capture yielding none is unreadable rather than a
|
||||
* machine with no processes: the shell proof must not read that as "the shell is gone".
|
||||
*/
|
||||
export function parseShellForegroundRows(stdout: string): ProcessTableRow[] {
|
||||
const rows: ProcessTableRow[] = []
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const match = rawLine.trim().match(/^(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(\S+)\s+(.+)$/)
|
||||
const pid = match ? Number(match[1]) : 0
|
||||
if (match && Number.isSafeInteger(pid) && pid > 0) {
|
||||
rows.push({
|
||||
pid,
|
||||
ppid: Number(match[2]),
|
||||
pgid: Number(match[3]),
|
||||
tpgid: Number(match[4]),
|
||||
stat: match[5],
|
||||
command: match[6]
|
||||
})
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
throw new ProcessTableCaptureError('empty_capture')
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export type CheapProcessTableRow = {
|
||||
pid: number
|
||||
ppid: number
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }))
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import {
|
||||
getFreshShellForegroundSnapshot,
|
||||
getProcessTableSnapshot,
|
||||
resetProcessTableSnapshotForTests
|
||||
} from './process-table-snapshot-reader'
|
||||
import { parseShellForegroundRows } from './process-table-snapshot'
|
||||
|
||||
type Callback = (error: Error | null, result: { stdout: string; stderr: string }) => void
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
const shell = '100 99 100 100 Ss+ /bin/zsh -l'
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
execFileMock.mockReset()
|
||||
resetProcessTableSnapshotForTests()
|
||||
})
|
||||
afterEach(() => Object.defineProperty(process, 'platform', platform))
|
||||
|
||||
it('answers concurrent shell proofs without waiting for a pending full capture', async () => {
|
||||
let finishFull!: Callback
|
||||
execFileMock.mockImplementation((_program, args: string[], _options, callback: Callback) => {
|
||||
if (args[1]?.includes('tty=')) {
|
||||
finishFull = callback
|
||||
} else {
|
||||
expect(args).toEqual(['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,command='])
|
||||
callback(null, { stdout: shell, stderr: '' })
|
||||
}
|
||||
})
|
||||
const full = getProcessTableSnapshot()
|
||||
const [first, second] = await Promise.all([
|
||||
getFreshShellForegroundSnapshot(),
|
||||
getFreshShellForegroundSnapshot()
|
||||
])
|
||||
expect(first).toEqual([
|
||||
{ pid: 100, ppid: 99, pgid: 100, tpgid: 100, stat: 'Ss+', command: '/bin/zsh -l' }
|
||||
])
|
||||
expect(second).toBe(first)
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
finishFull(null, { stdout: shell, stderr: '' })
|
||||
await full
|
||||
await getFreshShellForegroundSnapshot()
|
||||
expect(execFileMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('requires a new capture after an earlier shell proof has started', async () => {
|
||||
const callbacks: Callback[] = []
|
||||
execFileMock.mockImplementation((_program, _args, _options, callback: Callback) => {
|
||||
callbacks.push(callback)
|
||||
})
|
||||
const first = getFreshShellForegroundSnapshot()
|
||||
await vi.waitFor(() => expect(callbacks).toHaveLength(1))
|
||||
const second = getFreshShellForegroundSnapshot()
|
||||
callbacks[0]!(null, { stdout: shell, stderr: '' })
|
||||
await first
|
||||
await vi.waitFor(() => expect(callbacks).toHaveLength(2))
|
||||
callbacks[1]!(null, { stdout: shell.replace('Ss+', 'Ss'), stderr: '' })
|
||||
expect((await second)[0]?.stat).toBe('Ss')
|
||||
})
|
||||
|
||||
// With no `tty=` column to absorb them, the shared parser read `python`/`3` as tty/start.
|
||||
it('keeps an argv whose second token is numeric', () => {
|
||||
expect(parseShellForegroundRows('101 100 101 101 S+ /usr/bin/python 3 app.py')).toEqual([
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, stat: 'S+', command: '/usr/bin/python 3 app.py' }
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an unreadable shell capture', async () => {
|
||||
execFileMock.mockImplementation((_program, _args, _options, callback: Callback) => {
|
||||
callback(null, { stdout: '', stderr: '' })
|
||||
})
|
||||
await expect(getFreshShellForegroundSnapshot()).rejects.toThrow('empty_capture')
|
||||
})
|
||||
@@ -163,8 +163,7 @@ test.describe('SSH transport drop recovery', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// #18018: local authority-aware recovery still loses the flooded pane's relay channel.
|
||||
test.fixme('stays bounded when a disconnected shell floods its pty', async ({
|
||||
test('stays bounded when a disconnected shell floods its pty', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.slow()
|
||||
|
||||
Reference in New Issue
Block a user