diff --git a/config/ast-grep/rule-tests/__snapshots__/claude-chat-entrypoint-callers-snapshot.yml b/config/ast-grep/rule-tests/__snapshots__/claude-chat-entrypoint-callers-snapshot.yml deleted file mode 100644 index 6bd5f856dd1..00000000000 --- a/config/ast-grep/rule-tests/__snapshots__/claude-chat-entrypoint-callers-snapshot.yml +++ /dev/null @@ -1,14 +0,0 @@ -id: claude-chat-entrypoint-callers -snapshots: - "import { withOrcaClaudeChatEntrypoint as stamp } from './claude-chat-entrypoint'": - labels: - - source: withOrcaClaudeChatEntrypoint - style: primary - start: 9 - end: 37 - 'withOrcaClaudeChatEntrypoint(env, platform)': - labels: - - source: withOrcaClaudeChatEntrypoint - style: primary - start: 0 - end: 28 diff --git a/config/ast-grep/rule-tests/__snapshots__/claude-chat-entrypoint-owner-snapshot.yml b/config/ast-grep/rule-tests/__snapshots__/claude-chat-entrypoint-owner-snapshot.yml deleted file mode 100644 index 05f177c95df..00000000000 --- a/config/ast-grep/rule-tests/__snapshots__/claude-chat-entrypoint-owner-snapshot.yml +++ /dev/null @@ -1,20 +0,0 @@ -id: claude-chat-entrypoint-owner -snapshots: - "const env = { CLAUDE_CODE_ENTRYPOINT: 'orca' }": - labels: - - source: CLAUDE_CODE_ENTRYPOINT - style: primary - start: 14 - end: 36 - const identity = 'sdk-cli': - labels: - - source: sdk-cli - style: primary - start: 18 - end: 25 - "env['claude_code_entrypoint'] = 'other'": - labels: - - source: claude_code_entrypoint - style: primary - start: 5 - end: 27 diff --git a/config/ast-grep/rule-tests/__snapshots__/claude-entrypoint-key-owner-snapshot.yml b/config/ast-grep/rule-tests/__snapshots__/claude-entrypoint-key-owner-snapshot.yml deleted file mode 100644 index ea2790098fb..00000000000 --- a/config/ast-grep/rule-tests/__snapshots__/claude-entrypoint-key-owner-snapshot.yml +++ /dev/null @@ -1,14 +0,0 @@ -id: claude-entrypoint-key-owner -snapshots: - "env[CLAUDE_ENTRYPOINT_ENV] = 'orca'": - labels: - - source: CLAUDE_ENTRYPOINT_ENV - style: primary - start: 4 - end: 25 - "import { CLAUDE_ENTRYPOINT_ENV as key } from './claude-chat-entrypoint'": - labels: - - source: CLAUDE_ENTRYPOINT_ENV - style: primary - start: 9 - end: 30 diff --git a/config/ast-grep/rule-tests/claude-chat-entrypoint-callers-test.yml b/config/ast-grep/rule-tests/claude-chat-entrypoint-callers-test.yml deleted file mode 100644 index 2af948c77c9..00000000000 --- a/config/ast-grep/rule-tests/claude-chat-entrypoint-callers-test.yml +++ /dev/null @@ -1,6 +0,0 @@ -id: claude-chat-entrypoint-callers -valid: - - 'stripInheritedAgentSessionEnv(env)' -invalid: - - 'withOrcaClaudeChatEntrypoint(env, platform)' - - "import { withOrcaClaudeChatEntrypoint as stamp } from './claude-chat-entrypoint'" diff --git a/config/ast-grep/rule-tests/claude-chat-entrypoint-owner-test.yml b/config/ast-grep/rule-tests/claude-chat-entrypoint-owner-test.yml deleted file mode 100644 index 87a75f4a143..00000000000 --- a/config/ast-grep/rule-tests/claude-chat-entrypoint-owner-test.yml +++ /dev/null @@ -1,8 +0,0 @@ -id: claude-chat-entrypoint-owner -valid: - - 'const env = withOrcaClaudeChatEntrypoint(configured, platform)' - - "const binary = 'orca'" -invalid: - - "const env = { CLAUDE_CODE_ENTRYPOINT: 'orca' }" - - "env['claude_code_entrypoint'] = 'other'" - - "const identity = 'sdk-cli'" diff --git a/config/ast-grep/rule-tests/claude-entrypoint-key-owner-test.yml b/config/ast-grep/rule-tests/claude-entrypoint-key-owner-test.yml deleted file mode 100644 index c63b8b38063..00000000000 --- a/config/ast-grep/rule-tests/claude-entrypoint-key-owner-test.yml +++ /dev/null @@ -1,6 +0,0 @@ -id: claude-entrypoint-key-owner -valid: - - "const binary = 'orca'" -invalid: - - "env[CLAUDE_ENTRYPOINT_ENV] = 'orca'" - - "import { CLAUDE_ENTRYPOINT_ENV as key } from './claude-chat-entrypoint'" diff --git a/config/ast-grep/rule-tests/flaky-test-patterns-test.yml b/config/ast-grep/rule-tests/flaky-test-patterns-test.yml index 33a6a491a41..62eb5036b32 100644 --- a/config/ast-grep/rule-tests/flaky-test-patterns-test.yml +++ b/config/ast-grep/rule-tests/flaky-test-patterns-test.yml @@ -1,69 +1,43 @@ id: test-no-wall-clock valid: - - "const createdAt = Date.parse('2026-01-01T00:00:00Z')" - - "const fixed = new Date('2026-01-01T00:00:00Z')" - 'const dir = join(tmpdir(), `run-${Date.now()}`)' - | vi.useFakeTimers() const now = Date.now() invalid: - const started = Date.now() - - 'const record = { closedAt: new Date() }' --- id: test-no-real-time-wait valid: - await new Promise((resolve) => setTimeout(resolve, 0)) - - await new Promise((resolve) => setImmediate(resolve)) - "await new Promise((resolve, reject) => { setTimeout(() => reject(new Error('timeout')), 5_000); emitter.once('ready', resolve) })" - - await vi.advanceTimersByTimeAsync(1_000) - - await expect.poll(() => read()).toBe(true) invalid: - - await new Promise((resolve) => setTimeout(resolve, 50)) - await new Promise((r) => setTimeout(r, 50)) - await new Promise((resolve) => setTimeout(() => resolve(), DELAY_MS)) - 'await new Promise((resolve) => { setTimeout(resolve, 20) })' - - await page.waitForTimeout(500) - - await sleep(100) - - await setTimeout(100) - - 'while (!ready()) { await sleep(0); await sleep(25) }' --- id: test-no-polling-loop valid: - 'for (const item of items) { await sleep(10) }' - - 'while (queue.length > 0) { await drain(queue.shift()) }' - - 'await expect.poll(() => read(), { timeout: 5_000 }).toBe(true)' invalid: - 'while (!ready()) { await new Promise((resolve) => setTimeout(resolve, 20)) }' - - 'do { value = read(); await sleep(50) } while (value === undefined)' - 'while (!ready()) { await sleep(0); await sleep(25) }' - - 'for (let attempt = 0; attempt < 20; attempt++) { if (done()) break; await page.waitForTimeout(100) }' --- id: test-skip-needs-quarantine valid: - - | - // quarantine: #1234 times out on Windows runners - it.skip('restores the session', async () => {}) - | // quarantine: #1234 flakes under load // Re-enable once the PTY fixture is deterministic. describe.skip('pty fixture', () => {}) - test.skip(process.platform === 'win32', 'POSIX shell only') - - it.skipIf(isWindows)('uses POSIX paths', () => {}) invalid: - - it.skip('restores the session', async () => {}) - - test.fixme('drag reorders tabs', async () => {}) - test.describe.skip('layout', () => {}) - - it.only('focuses', () => {}) - | // TODO: re-enable it.skip('restores the session', async () => {}) - - | - // quarantine: flaky - it.skip('restores the session', async () => {}) --- id: test-timeout-needs-reason valid: - - "it('reads', async () => {})" - | // Spawns a real shell, which is slow on Windows runners. it('spawns', async () => {}, 20_000) @@ -71,27 +45,15 @@ valid: it('spawns', async () => {}, // Spawns a real shell. 20_000) - - | - // Builds the packaged app first. - test.setTimeout(300_000) - 'await expect(locator).toBeVisible({ timeout: 10_000 })' invalid: - "it('spawns', async () => {}, 20_000)" - "it('spawns', { timeout: 20_000 }, async () => {})" - - "describe('suite', { timeout: 60_000 }, () => {})" - - test.setTimeout(300_000) - - test.slow() --- id: test-no-in-test-import-actual valid: - "vi.mock('./git', async () => ({ ...(await vi.importActual('./git')), run: vi.fn() }))" - - "beforeAll(async () => { actual = await vi.importActual('./git') })" - "const actual = await vi.importActual('./git')" invalid: - "vi.doMock('./git', async () => ({ ...(await vi.importActual('./git')) }))" - - "function mockGit() { vi.doMock('./git', async () => ({ ...(await vi.importActual('./git')) })) }" - "it('creates', async () => { const actual = await vi.importActual('./git') })" - - "async function useReal() { return await vi.importActual('./git') }" - - "beforeEach(async () => { actual = await vi.importActual('./git') })" - - "it('reloads', async () => { vi.doMock('./git', async () => ({ ...(await vi.importActual('./git')) })) })" - - "it.each([1])('reloads %s', async () => { vi.doMock('./git', async () => ({ ...(await vi.importActual('./git')) })) })" diff --git a/config/ast-grep/rule-tests/main-worker-thread-spawn-test.yml b/config/ast-grep/rule-tests/main-worker-thread-spawn-test.yml deleted file mode 100644 index 0d5186d5655..00000000000 --- a/config/ast-grep/rule-tests/main-worker-thread-spawn-test.yml +++ /dev/null @@ -1,7 +0,0 @@ -id: main-worker-thread-spawn -valid: - - 'spawnWorkerThread(workerPath, { workerData })' - - 'new WorkerThreadChildProcessHost(factory)' -invalid: - - 'new Worker(workerPath)' - - 'new workerThreads.Worker(workerPath)' diff --git a/config/ast-grep/rule-tests/mobile-no-unchecked-zod-custom-test.yml b/config/ast-grep/rule-tests/mobile-no-unchecked-zod-custom-test.yml index 345781b3498..92cc44ab54a 100644 --- a/config/ast-grep/rule-tests/mobile-no-unchecked-zod-custom-test.yml +++ b/config/ast-grep/rule-tests/mobile-no-unchecked-zod-custom-test.yml @@ -1,9 +1,5 @@ id: mobile-no-unchecked-zod-custom valid: - 'z.custom(isWorktreeCatalogRow)' - - 'z.custom((value) => isRpcResponse(value))' - - 'z.unknown()' invalid: - - 'z.custom()' - 'z.array(z.custom()).optional()' - - 'z.custom()' diff --git a/config/ast-grep/rule-tests/native-chat-design-tokens-test.yml b/config/ast-grep/rule-tests/native-chat-design-tokens-test.yml index af4978c510f..80ec65b5b5c 100644 --- a/config/ast-grep/rule-tests/native-chat-design-tokens-test.yml +++ b/config/ast-grep/rule-tests/native-chat-design-tokens-test.yml @@ -1,32 +1,8 @@ id: native-chat-no-arbitrary-font-size-tsx valid: - - 'export const X = () =>

' - - 'export const X = () =>

'
   - 'export const X = () => 

' - 'export const X = () =>

' invalid: - - 'export const X = () =>

' - - 'export const X = () =>

' - 'export const X = () =>

' - "export const X = () =>

" - 'export const X = () =>

' ---- -id: native-chat-no-arbitrary-font-size -valid: - - "const ROW = 'flex text-[var(--size)]'" -invalid: - - "const ROW = 'flex text-[10px]'" ---- -id: native-chat-no-softened-foreground-tsx -valid: - - 'export const X = () =>

' - - 'export const X = () =>

' -invalid: - - 'export const X = () =>

' - - 'export const X = () =>

' ---- -id: native-chat-no-weakened-focus-ring-tsx -valid: - - 'export const X = () => ' -) - -const hostWithoutAnEngine = () => ({ - postToHost: () => {}, - hasEngine: () => false -}) - -describe('the document start sequence', () => { - it('would have installed one, so the null above is a measurement', () => { - document.body.innerHTML = MARKUP_WITH_THE_MENU - const previous = window.onerror - window.onerror = null - try { - const started = createTerminalDocument(hostWithoutAnEngine()) - expect(window.onerror).not.toBe(null) - started.stop() - expect(window.onerror).toBe(null) - } finally { - window.onerror = previous - } - }) -}) diff --git a/mobile/src/terminal/key-input/terminal-key-chord-input.test.ts b/mobile/src/terminal/key-input/terminal-key-chord-input.test.ts deleted file mode 100644 index c62498fdb0e..00000000000 --- a/mobile/src/terminal/key-input/terminal-key-chord-input.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createTerminalKeyChordInput } from './terminal-key-chord-input' - -const chord = (key: string, modifiers: { ctrl?: boolean; alt?: boolean; shift?: boolean } = {}) => - createTerminalKeyChordInput({ - key, - ctrl: modifiers.ctrl ?? false, - alt: modifiers.alt ?? false, - shift: modifiers.shift ?? false - }) - -describe('createTerminalKeyChordInput', () => { - it('keeps the accessory Del local-edit rule for a bare forward delete only', () => { - expect(chord('delete')).toEqual({ bytes: '\x1b[3~', localEdit: 'delete' }) - expect(chord('delete', { ctrl: true })).toEqual({ bytes: '\x1b[3;5~' }) - }) -}) diff --git a/mobile/src/terminal/keyboard/terminal-keyboard-avoidance-lift.test.ts b/mobile/src/terminal/keyboard/terminal-keyboard-avoidance-lift.test.ts index 71f73e63a0a..99ec531d338 100644 --- a/mobile/src/terminal/keyboard/terminal-keyboard-avoidance-lift.test.ts +++ b/mobile/src/terminal/keyboard/terminal-keyboard-avoidance-lift.test.ts @@ -30,21 +30,6 @@ describe('computeActiveTerminalKeyboardLift', () => { expect({ candidate, cursorOnly }).toEqual({ candidate: 220, cursorOnly: 140 }) }) - it('applies the supplied keyboard coverage to the lift', () => { - const tuiMetrics = metrics({ cursorY: 30, contentBottomRow: 34 }) - const android = computeActiveTerminalKeyboardLift({ - keyboardLift: 300, - metrics: tuiMetrics, - terminalFrameHeight: FRAME_HEIGHT - }) - const ios = computeActiveTerminalKeyboardLift({ - keyboardLift: 266, - metrics: tuiMetrics, - terminalFrameHeight: FRAME_HEIGHT - }) - expect({ android, ios }).toEqual({ android: 220, ios: 186 }) - }) - describe('a keyboard that covers the frame', () => { const lift = (keyboardLift: number, m: TerminalKeyboardAvoidanceMetrics, frame = 713.7) => computeActiveTerminalKeyboardLift({ diff --git a/mobile/src/terminal/live-input/terminal-live-text-commit.test.ts b/mobile/src/terminal/live-input/terminal-live-text-commit.test.ts index 610b2679b6f..dc5a8c5d666 100644 --- a/mobile/src/terminal/live-input/terminal-live-text-commit.test.ts +++ b/mobile/src/terminal/live-input/terminal-live-text-commit.test.ts @@ -5,10 +5,7 @@ describe('terminal live accessory local edit text', () => { it.each([ ['backspace', 'a🙂b', { start: 3, end: 3 }, 'ab'], ['delete', 'a🙂b', { start: 1, end: 1 }, 'ab'], - ['backspace', '可是你好', { start: 1, end: 3 }, '可好'], - ['delete', '可是你好', { start: 1, end: 3 }, '可好'], - ['backspace', 'abc', { start: 0, end: 0 }, 'abc'], - ['delete', 'abc', { start: 3, end: 3 }, 'abc'] + ['backspace', '可是你好', { start: 1, end: 3 }, '可好'] ] as const)( 'edits %s in %j at its native UTF-16 selection', (localEdit, fieldText, selection, expected) => { diff --git a/mobile/src/terminal/live-input/terminal-text-field-submit-binding.web.test.ts b/mobile/src/terminal/live-input/terminal-text-field-submit-binding.web.test.ts index 45111eb0fa1..50197155b9b 100644 --- a/mobile/src/terminal/live-input/terminal-text-field-submit-binding.web.test.ts +++ b/mobile/src/terminal/live-input/terminal-text-field-submit-binding.web.test.ts @@ -23,17 +23,6 @@ function sendBeforeInput(field: HTMLElement, inputType: string): InputEvent { } describe('the web sibling', () => { - it('submits on the line break the browser reports, and cancels it', () => { - const onSubmit = vi.fn() - const field = mountField() - bindOnWeb(asField(field), onSubmit) - - const event = sendBeforeInput(field, 'insertLineBreak') - - expect(onSubmit).toHaveBeenCalledTimes(1) - expect(event.defaultPrevented).toBe(true) - }) - // IME candidate acceptance emits insertCompositionText, so it must not submit terminal input. it('leaves a composition commit alone', () => { const onSubmit = vi.fn() @@ -78,18 +67,17 @@ describe.each(['android', 'ios'] as const)('live terminal keyboard chords on %s' return { field, onChord, unbind, key } } - it.each([ - ['c', { ctrlKey: true }, '\x03'], - ['ArrowLeft', { ctrlKey: true }, '\x1b[1;5D'], - ['Enter', { shiftKey: true }, '\x1b[13;2u'] - ])('claims %s once before RNWeb can resend it', (name, options, bytes) => { - const { field, onChord, key } = setup() - const parentKey = vi.fn() - field.parentElement?.addEventListener('keydown', parentKey, { once: true }) - expect(key(name, options).defaultPrevented).toBe(true) - expect(onChord).toHaveBeenCalledExactlyOnceWith({ bytes }) - expect(parentKey).not.toHaveBeenCalled() - }) + it.each([['c', { ctrlKey: true }, '\x03']])( + 'claims %s once before RNWeb can resend it', + (name, options, bytes) => { + const { field, onChord, key } = setup() + const parentKey = vi.fn() + field.parentElement?.addEventListener('keydown', parentKey, { once: true }) + expect(key(name, options).defaultPrevented).toBe(true) + expect(onChord).toHaveBeenCalledExactlyOnceWith({ bytes }) + expect(parentKey).not.toHaveBeenCalled() + } + ) it.each(['b'])('keeps Option-generated %j native on iOS and Alt chords on Android', (name) => { const { onChord, key } = setup() @@ -98,17 +86,12 @@ describe.each(['android', 'ios'] as const)('live terminal keyboard chords on %s' }) it.each([ - ['a', {}], - ['Enter', {}], ['Enter', { ctrlKey: true, shiftKey: true }], - ['Backspace', {}], - ['ArrowLeft', {}], ['c', { ctrlKey: true, isComposing: true }], ['c', { ctrlKey: true, keyCode: 229 }], ['c', { ctrlKey: true, altKey: true }], ['c', { metaKey: true }], - [' ', { ctrlKey: true }], - ['1', { ctrlKey: true }] + [' ', { ctrlKey: true }] ])('leaves %s input and IME/platform shortcuts alone', (name, options) => { const { onChord, key } = setup() expect(key(name, options).defaultPrevented).toBe(false) diff --git a/mobile/src/terminal/live-input/terminal-text-input-normalization.test.ts b/mobile/src/terminal/live-input/terminal-text-input-normalization.test.ts deleted file mode 100644 index 8bd9b39c105..00000000000 --- a/mobile/src/terminal/live-input/terminal-text-input-normalization.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { normalizeTerminalTextInput } from './terminal-text-input-normalization' - -describe('normalizeTerminalTextInput', () => { - it('converts iOS smart dash replacements back to terminal hyphens', () => { - expect(normalizeTerminalTextInput('git checkout – file')).toBe('git checkout -- file') - expect(normalizeTerminalTextInput('git checkout — file')).toBe('git checkout -- file') - }) -}) diff --git a/mobile/src/terminal/output-flow/terminal-write-coalescer.test.ts b/mobile/src/terminal/output-flow/terminal-write-coalescer.test.ts index 41e833f8990..c524ee4b0ac 100644 --- a/mobile/src/terminal/output-flow/terminal-write-coalescer.test.ts +++ b/mobile/src/terminal/output-flow/terminal-write-coalescer.test.ts @@ -16,20 +16,6 @@ describe('terminal write coalescer', () => { vi.useRealTimers() }) - it('batches writes inside the trailing window preserving byte order', () => { - vi.useFakeTimers() - const sink = createDeliverySink() - const coalescer = createTerminalWriteCoalescer(sink.deliver) - - coalescer.write('a') - coalescer.write('b') - coalescer.write('c') - expect(sink.delivered).toEqual(['a']) - - vi.advanceTimersByTime(48) - expect(sink.delivered).toEqual(['a', 'bc']) - }) - it('flushes within one window even when the wall clock jumps backwards mid-stream', () => { vi.useFakeTimers() vi.setSystemTime(100_000) diff --git a/mobile/src/terminal/pointer-input/terminal-gesture-input.test.ts b/mobile/src/terminal/pointer-input/terminal-gesture-input.test.ts index a920f37a7a3..fcf6d04f44b 100644 --- a/mobile/src/terminal/pointer-input/terminal-gesture-input.test.ts +++ b/mobile/src/terminal/pointer-input/terminal-gesture-input.test.ts @@ -43,22 +43,6 @@ describe('isTerminalGestureInput', () => { ).toBe(true) }) - it('accepts repeated default mouse wheel sequences', () => { - expect( - isTerminalGestureInput( - `${ESC}[M${String.fromCharCode(96, 97, 33)}${ESC}[M${String.fromCharCode(97, 126, 126)}` - ) - ).toBe(true) - }) - - it('accepts bounded default left-click press and release sequences', () => { - expect( - isTerminalGestureInput( - `${ESC}[M${String.fromCharCode(32, 33, 33)}${ESC}[M${String.fromCharCode(35, 33, 33)}` - ) - ).toBe(true) - }) - it('rejects shell text and other terminal input', () => { expect(isTerminalGestureInput('rm -rf .\r')).toBe(false) expect(isTerminalGestureInput(`${ESC}[200~paste${ESC}[201~`)).toBe(false) diff --git a/mobile/src/terminal/pointer-input/terminal-mouse-authority.test.ts b/mobile/src/terminal/pointer-input/terminal-mouse-authority.test.ts index 866528b4470..425d97ad595 100644 --- a/mobile/src/terminal/pointer-input/terminal-mouse-authority.test.ts +++ b/mobile/src/terminal/pointer-input/terminal-mouse-authority.test.ts @@ -96,7 +96,6 @@ describe('mobile mouse encoding proof', () => { }) it.each([ - ['SGR', '?1006h', '[<65;'], ['pixel', '?1016h', '[<65;'], ['explicit legacy', '?1006l', '[Ma'] ])('keeps %s encoding proven by a live write', (_name, mode, prefix) => { diff --git a/mobile/src/terminal/quick-commands/QuickCommandsSheet.test.ts b/mobile/src/terminal/quick-commands/QuickCommandsSheet.test.ts index 2afda96b32c..cf82027d5f2 100644 --- a/mobile/src/terminal/quick-commands/QuickCommandsSheet.test.ts +++ b/mobile/src/terminal/quick-commands/QuickCommandsSheet.test.ts @@ -160,10 +160,7 @@ describe('QuickCommandsSheet', () => { saved.resolve(true) await saved.promise }) - expect(editor().props.draft).toMatchObject({ - label: 'Newer', - id: mocks.persist.mock.calls[0]?.[0].command.id - }) + expect(editor().props.draft.label).toBe('Newer') expect(editor().props.saving).toBe(false) client.close() }) diff --git a/mobile/src/terminal/session/creation/use-initial-session-terminal-autocreate.test.ts b/mobile/src/terminal/session/creation/use-initial-session-terminal-autocreate.test.ts deleted file mode 100644 index e8755754ad8..00000000000 --- a/mobile/src/terminal/session/creation/use-initial-session-terminal-autocreate.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { createElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it } from 'vitest' -import { useWorktreeSessionTabsLoaded } from './use-initial-session-terminal-autocreate' - -describe('useWorktreeSessionTabsLoaded', () => { - let renderer: ReactTestRenderer | null = null - let current: readonly [boolean, (loaded: boolean) => void] | null = null - - function Harness({ worktreeId }: { worktreeId: string }): null { - current = useWorktreeSessionTabsLoaded(worktreeId) - return null - } - - afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - current = null - }) - - it('does not carry a loaded snapshot across a reused route', async () => { - await act(async () => { - renderer = create(createElement(Harness, { worktreeId: 'existing' })) - }) - act(() => current?.[1](true)) - expect(current?.[0]).toBe(true) - - await act(async () => { - renderer?.update(createElement(Harness, { worktreeId: 'new' })) - }) - expect(current?.[0]).toBe(false) - }) -}) diff --git a/mobile/src/terminal/session/inventory/mobile-terminal-inventory-request.test.ts b/mobile/src/terminal/session/inventory/mobile-terminal-inventory-request.test.ts deleted file mode 100644 index 9b22756d32f..00000000000 --- a/mobile/src/terminal/session/inventory/mobile-terminal-inventory-request.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { MobileTerminalInventoryRequest } from './mobile-terminal-inventory-request' - -describe('MobileTerminalInventoryRequest', () => { - afterEach(() => { - vi.restoreAllMocks() - }) - - it('fences a response after its route activation is replaced', async () => { - const oldResponse = Promise.withResolvers() - const applied: string[] = [] - const requests = new MobileTerminalInventoryRequest() - const deactivateOld = requests.activate() - const oldResult = requests.run(true, async (_allowsEmpty, isCurrent) => { - await oldResponse.promise - if (!isCurrent()) { - return false - } - applied.push('old') - return true - }) - await Promise.resolve() - - requests.activate() - deactivateOld() - oldResponse.resolve() - await expect(oldResult).resolves.toBe(false) - await expect( - requests.run(true, async (_allowsEmpty, isCurrent) => { - if (!isCurrent()) { - return false - } - applied.push('next') - return true - }) - ).resolves.toBe(true) - expect(applied).toEqual(['next']) - }) -}) diff --git a/mobile/src/terminal/terminal-webview-html/document-style.test.ts b/mobile/src/terminal/terminal-webview-html/document-style.test.ts deleted file mode 100644 index 7537423d714..00000000000 --- a/mobile/src/terminal/terminal-webview-html/document-style.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { scopeStyleToHost } from '../../style-scoping/document-style-scoping' -import { XTERM_ENGINE_CSS } from '../document/terminal-webview-engine-css.generated' -import { TERMINAL_DOCUMENT_ELEMENT_STYLE } from './document-style' - -// Verify native root rules remain separate while page-injected rules can reach only terminal-owned elements. -const PREFIX = '.orca-terminal-document-host' - -function selectorsOf(css: string): string[] { - return [...css.matchAll(/(?:^|\})\s*([^{}]+)\{/g)].flatMap((match) => - match[1]!.split(',').map((one) => one.trim()) - ) -} - -describe('the terminal document stylesheet', () => { - it('holds every selector of both injected sheets under the host', () => { - for (const sheet of [TERMINAL_DOCUMENT_ELEMENT_STYLE, XTERM_ENGINE_CSS]) { - const scoped = scopeStyleToHost(sheet, PREFIX) - const selectors = selectorsOf(scoped) - expect(selectors.length).toBeGreaterThan(0) - expect(selectors.filter((one) => !one.startsWith(`${PREFIX} `))).toEqual([]) - } - }) -}) diff --git a/mobile/src/terminal/viewport/terminal-auto-restore-fit-state.test.ts b/mobile/src/terminal/viewport/terminal-auto-restore-fit-state.test.ts deleted file mode 100644 index 08e4fdf466c..00000000000 --- a/mobile/src/terminal/viewport/terminal-auto-restore-fit-state.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - readTerminalAutoRestoreFitMs, - saveTerminalAutoRestoreFitMs -} from './terminal-auto-restore-fit-state' - -describe('terminal auto restore fit state', () => { - it('reads a host without the setting as the default', async () => { - const sendRequest = vi.fn().mockResolvedValue({ - id: '1', - ok: false, - error: { code: 'method_not_found', message: 'Unknown method' } - }) - - await expect(readTerminalAutoRestoreFitMs({ sendRequest })).resolves.toBeNull() - await expect(saveTerminalAutoRestoreFitMs({ sendRequest }, 60_000)).resolves.toBeNull() - }) -}) diff --git a/mobile/src/terminal/viewport/terminal-cell-box.test.ts b/mobile/src/terminal/viewport/terminal-cell-box.test.ts deleted file mode 100644 index 06569b2af30..00000000000 --- a/mobile/src/terminal/viewport/terminal-cell-box.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readTerminalCellBox } from './terminal-cell-box' - -const CELL_1X = { fontScale: 1, cellWidth: 23 / 3, cellHeight: 15 } - -describe('readTerminalCellBox', () => { - it('reads a well-formed box and rejects a malformed one', () => { - expect(readTerminalCellBox({ cellBox: CELL_1X })).toEqual(CELL_1X) - expect(readTerminalCellBox({ cellBox: { ...CELL_1X, cellWidth: 0 } })).toBeNull() - expect(readTerminalCellBox({ cellBox: [CELL_1X] })).toBeNull() - }) -}) diff --git a/mobile/src/terminal/webview/terminal-webview-contract.test.ts b/mobile/src/terminal/webview/terminal-webview-contract.test.ts index cde678336f8..1e8a70e5cc1 100644 --- a/mobile/src/terminal/webview/terminal-webview-contract.test.ts +++ b/mobile/src/terminal/webview/terminal-webview-contract.test.ts @@ -3,15 +3,6 @@ import { describe, expect, it } from 'vitest' import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' describe('parseTerminalKeyboardAvoidanceMetrics', () => { - it('defaults contentBottomRow to cursorY when absent (older WebView bundles)', () => { - expect(parseTerminalKeyboardAvoidanceMetrics({ cursorY: 12, rows: 40 })).toEqual({ - cursorY: 12, - contentBottomRow: 12, - rows: 40, - altScreen: false - }) - }) - it('bounds untrusted numeric fields to the reported viewport', () => { expect( parseTerminalKeyboardAvoidanceMetrics({ diff --git a/mobile/src/test-support/rpc-recording/derived-goldens.ts b/mobile/src/test-support/rpc-recording/derived-goldens.ts index 9af29766d0f..ca5748330c5 100644 --- a/mobile/src/test-support/rpc-recording/derived-goldens.ts +++ b/mobile/src/test-support/rpc-recording/derived-goldens.ts @@ -22,7 +22,6 @@ const SIBLING_SCHEDULE_BASES = [ 'b3', 'settings-new-tab-ssh', 'settings-workspace-context-fulfilled', - 'settings-resume-metadata-fulfilled', 'settings-task-hydration-fulfilled', 'settings-repo-metadata-fulfilled' ] diff --git a/mobile/src/test-support/rpc-recording/golden-recorder-failure-absence.test.ts b/mobile/src/test-support/rpc-recording/golden-recorder-failure-absence.test.ts deleted file mode 100644 index 572796b842d..00000000000 --- a/mobile/src/test-support/rpc-recording/golden-recorder-failure-absence.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { readdirSync } from 'node:fs' -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { readGolden } from './golden-recording' -import { OBSERVATION_FIELDS } from './golden-value-pool' -import type { Observation } from './recording-scenario' -import type { RecordedValue } from './recording-values' - -const root = resolve(import.meta.dirname, '../../../..') -const directory = - process.env.RPC_FOUNDATION_GOLDENS ?? resolve(root, 'mobile/rpc-foundation/goldens') - -/** `captureValue`'s two refusals. Neither is ever product behaviour. */ -const PROJECTION_REFUSALS = [ - 'Unsupported observation', - 'Observation requires an explicit projection' -] - -function refusalText(value: RecordedValue): boolean { - if (typeof value === 'string') { - return PROJECTION_REFUSALS.some((refusal) => value.includes(refusal)) - } - if (Array.isArray(value)) { - return value.some(refusalText) - } - return typeof value === 'object' && value !== null && Object.values(value).some(refusalText) -} - -function failures(at: string, observation: Observation): string[] { - const found: string[] = [] - for (const field of OBSERVATION_FIELDS) { - if (refusalText(observation[field])) { - found.push(`${at}.${field}: recorder refused to project a value`) - } - } - return found -} - -// Projection refusals can be captured as data and re-recorded green; reject those while allowing real detached product failures. -describe('recorder failures never reach a golden', () => { - it('records no refused projection in any observation field', () => { - const ids = readdirSync(directory) - .filter((file) => file.endsWith('.json')) - .map((file) => file.replace(/\.json$/, '')) - - // Seed a captured refusal to prove absence checking detects the real failure shape. - const seeded: Observation = { - sender: [], - payloads: [], - settlements: { fetch: { status: 'pending' } }, - state: { fetched: 'Observation requires an explicit projection for non-plain objects' }, - effects: [ - { name: 'unhandled-rejection', value: { message: 'Unsupported observation: function' } } - ] - } - expect(failures('seeded', seeded)).toEqual([ - 'seeded.state: recorder refused to project a value', - 'seeded.effects: recorder refused to project a value' - ]) - - let checkpoints = 0 - const found = ids.flatMap((id) => - readGolden(directory, id).recording.checkpoints.flatMap((checkpoint) => { - checkpoints++ - return failures(`${id}/${checkpoint.id}`, checkpoint.observation) - }) - ) - expect(found).toEqual([]) - expect(ids.length).toBeGreaterThan(0) - expect(checkpoints).toBeGreaterThan(ids.length) - }) -}) diff --git a/mobile/src/test-support/rpc-recording/vitest-recording-scheduler.test.ts b/mobile/src/test-support/rpc-recording/vitest-recording-scheduler.test.ts deleted file mode 100644 index d0cf2fed5f7..00000000000 --- a/mobile/src/test-support/rpc-recording/vitest-recording-scheduler.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { runRecording } from './run-recording' -import { reactActQueuePrimed, vitestRecordingScheduler } from './vitest-recording-scheduler' -import type { MountAdapter, RecordingScenario } from './recording-scenario' - -/** The seeded generator's first value: `seed = 1`, one LCG step, divided by 2^32. */ -const SEEDED_FIRST_DRAW = 0.23645552527159452 - -const drawing: MountAdapter = ({ effect }) => ({ - action: (name) => { - if (name !== 'draw') { - throw new Error(`Unexpected action: ${name}`) - } - effect('draw', Math.random()) - return undefined - }, - state: () => null, - dispose: () => {} -}) - -/** `flushFirst` puts a zero-time drain — one `await act` — ahead of the draw. */ -function scenario(id: string, flushFirst: boolean): RecordingScenario { - const draw: RecordingScenario['steps'] = [{ action: 'draw', id: 'd' }, { checkpoint: 'drawn' }] - return { - id, - operation: 'scheduler-determinism', - family: 'scheduler-determinism', - sites: [], - schedules: [], - steps: flushFirst ? [{ checkpoint: 'mounted' }, ...draw] : draw - } -} - -async function record(id: string, flushFirst: boolean): Promise { - const recording = await runRecording( - scenario(id, flushFirst), - drawing, - vitestRecordingScheduler() - ) - const drawn = recording.checkpoints.at(-1)?.observation.effects - return JSON.parse(JSON.stringify(drawn)) -} - -describe('recording scheduler randomness', () => { - // Keep this test first: only a process’s first act can expose an unprimed React random draw. - it('draws the same seeded value first in the process as afterwards', async () => { - expect(reactActQueuePrimed()).toBe(false) - const first = await record('first-in-process', true) - const later = await record('later-in-process', true) - const expected = [{ name: 'draw', ordinal: 1, value: SEEDED_FIRST_DRAW }] - expect([first, later]).toEqual([expected, expected]) - }) -}) diff --git a/mobile/src/theme/themed-styles.test.ts b/mobile/src/theme/themed-styles.test.ts deleted file mode 100644 index 8d89343ecdc..00000000000 --- a/mobile/src/theme/themed-styles.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { applyMobileColorScheme, colors, paletteForScheme } from './mobile-theme' -import { themedStyles } from './themed-styles' - -vi.mock('react-native', () => ({ StyleSheet: { create: (styles: T): T => styles } })) - -afterEach(() => { - applyMobileColorScheme('dark') -}) - -it('serves each scheme its own stylesheet, including ones built before the switch', () => { - const styles = themedStyles(() => ({ row: { backgroundColor: colors.bgBase } })) - const merged = themedStyles(() => ({ ...styles })) - expect(merged.row.backgroundColor).toBe(paletteForScheme('dark').bgBase) - - applyMobileColorScheme('light') - expect(styles.row.backgroundColor).toBe(paletteForScheme('light').bgBase) - expect(merged.row.backgroundColor).toBe(paletteForScheme('light').bgBase) - - applyMobileColorScheme('dark') - expect(merged.row.backgroundColor).toBe(paletteForScheme('dark').bgBase) -}) diff --git a/mobile/src/transport/connection/client-context.test.ts b/mobile/src/transport/connection/client-context.test.ts index 4d0097b6f53..29c2853f5a7 100644 --- a/mobile/src/transport/connection/client-context.test.ts +++ b/mobile/src/transport/connection/client-context.test.ts @@ -190,30 +190,6 @@ describe('useHostClient', () => { }) describe('useAllHostClients', () => { - it('rerenders when Relay becomes pending without a transport-state change', async () => { - const client = makeFakeClient('reconnecting') - connectMock.mockReturnValue(client) - loadHostsMock.mockResolvedValue([HOST]) - let pendingPath: MobileConnectionPath | null | undefined - let renderer!: ReturnType - - function Probe(): null { - pendingPath = useAllHostClients([HOST.id])[0]?.pendingPath - return null - } - - await act(async () => { - renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) - await Promise.resolve() - }) - expect(pendingPath).toBeNull() - - act(() => client.emitPendingPath('relay')) - expect(pendingPath).toBe('relay') - - act(() => renderer.unmount()) - }) - it('publishes a latched pairing rejection to the screens', async () => { const client = makeFakeClient('reconnecting') connectMock.mockReturnValue(client) diff --git a/mobile/src/transport/encryption/paired-e2ee-native-codec.test.ts b/mobile/src/transport/encryption/paired-e2ee-native-codec.test.ts index 41e233f03a6..d9ecfafda52 100644 --- a/mobile/src/transport/encryption/paired-e2ee-native-codec.test.ts +++ b/mobile/src/transport/encryption/paired-e2ee-native-codec.test.ts @@ -83,13 +83,11 @@ describe('the native paired E2EE codec', () => { await sodium.ready }) - // UTF-8 fallback must preserve an already-opened frame whose counter advanced. - // libsodium sizes its output as the box length minus the MAC, which underflows and crashes. it('never hands libsodium a box shorter than its MAC', async () => { const { native } = await load(true) secretboxOpenCalls.mockClear() - for (let length = 0; length < 16; length++) { + for (const length of [0, 15]) { expect( native!.openSecretbox(new Uint8Array(length), randomBytes(24), randomBytes(32)) ).toBeNull() diff --git a/mobile/src/transport/relay/mobile-relay-resume-director.test.ts b/mobile/src/transport/relay/mobile-relay-resume-director.test.ts index 5eae841fb37..92042ef207f 100644 --- a/mobile/src/transport/relay/mobile-relay-resume-director.test.ts +++ b/mobile/src/transport/relay/mobile-relay-resume-director.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director' const relay = { @@ -11,30 +11,7 @@ const relay = { } describe('mobile relay resume director', () => { - it('rejects non-canonical targets and oversized bodies', async () => { - const badTarget = vi.fn( - async () => - new Response( - JSON.stringify({ - v: 1, - cellUrl: 'http://relay-c2.onorca.dev', - assignmentEpoch: 8, - leaseExpiresAt: 1 - }) - ) - ) - await expect( - resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl: badTarget }) - ).rejects.toThrow(/cellUrl/) - - const oversized = vi.fn( - async () => - new Response('x'.repeat(16 * 1024 + 1), { headers: { 'content-length': '16385' } }) - ) - await expect( - resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl: oversized }) - ).rejects.toThrow(/too large/) - + it('rejects oversized bodies despite an underreported content length', async () => { const oversizedBody = ' '.repeat(16 * 1024) + JSON.stringify({ @@ -43,14 +20,12 @@ describe('mobile relay resume director', () => { assignmentEpoch: 8, leaseExpiresAt: 1 }) - for (const headers of [new Headers(), new Headers({ 'content-length': '1' })]) { - await expect( - resolveMobileRelayEndpoint({ - relay, - resumeToken: 'A'.repeat(43), - fetchImpl: async () => new Response(oversizedBody, { headers }) - }) - ).rejects.toThrow(/too large/) - } + await expect( + resolveMobileRelayEndpoint({ + relay, + resumeToken: 'A'.repeat(43), + fetchImpl: async () => new Response(oversizedBody, { headers: { 'content-length': '1' } }) + }) + ).rejects.toThrow(/too large/) }) }) diff --git a/mobile/src/transport/rpc/foreground-stale-dial-restart.test.ts b/mobile/src/transport/rpc/foreground-stale-dial-restart.test.ts index 1670f485f50..8a08333ce11 100644 --- a/mobile/src/transport/rpc/foreground-stale-dial-restart.test.ts +++ b/mobile/src/transport/rpc/foreground-stale-dial-restart.test.ts @@ -106,27 +106,6 @@ describe('foregrounding a phone that was suspended mid-dial', () => { vi.unstubAllGlobals() }) - it('leaves a dial opened after the foreground edge alone', async () => { - const client = connect(TAILSCALE_ENDPOINT, 'token', 'server-key') - latest().authenticate() - latest().close() - await vi.advanceTimersByTimeAsync(500) - const fresh = latest() - expect(client.getState()).toBe('connecting') - - // Coalesce handoff and AppState nudges so the second cannot churn the first’s fresh socket. - client.notifyForeground() - const afterFirstNudge = latest() - client.notifyForeground() - client.notifyForeground() - - expect(latest()).toBe(afterFirstNudge) - expect(afterFirstNudge).toBe(fresh) - expect(fresh.readyState).toBe(MockWebSocket.CONNECTING) - - client.close() - }) - it('abandons a handshake stalled across the suspension', async () => { const client = connect(TAILSCALE_ENDPOINT, 'token', 'server-key') latest().authenticate() diff --git a/mobile/src/transport/rpc/rpc-client-capabilities.test.ts b/mobile/src/transport/rpc/rpc-client-capabilities.test.ts index 0fa58bac108..846db195627 100644 --- a/mobile/src/transport/rpc/rpc-client-capabilities.test.ts +++ b/mobile/src/transport/rpc/rpc-client-capabilities.test.ts @@ -100,7 +100,7 @@ describe('mobile rpc-client capabilities', () => { await vi.advanceTimersByTimeAsync(5_001) expect(client.getState()).toBe('connected') - expect(sentRequest(socket, 'session.tabs.subscribe')).toBeDefined() + sentRequest(socket, 'session.tabs.subscribe') expect(socket.readyState).toBe(MockWebSocket.OPEN) client.close() diff --git a/mobile/src/transport/rpc/rpc-client-connect-wait-replay.test.ts b/mobile/src/transport/rpc/rpc-client-connect-wait-replay.test.ts index 75807b2ddf5..ee000bdc61e 100644 --- a/mobile/src/transport/rpc/rpc-client-connect-wait-replay.test.ts +++ b/mobile/src/transport/rpc/rpc-client-connect-wait-replay.test.ts @@ -127,7 +127,3 @@ describe('mobile rpc-client connect-wait replay', () => { } }) }) - -// Exercise the hosted page's JSON boundary and the installed shell's real direct sender together. - -// The fake is provider/network I/O only; page, JSON parser, host, logical scope and direct wait are production. diff --git a/mobile/src/transport/rpc/rpc-client.test.ts b/mobile/src/transport/rpc/rpc-client.test.ts index 657cc241905..5042789d156 100644 --- a/mobile/src/transport/rpc/rpc-client.test.ts +++ b/mobile/src/transport/rpc/rpc-client.test.ts @@ -91,8 +91,6 @@ describe('mobile rpc-client connection timeout', () => { globalThis.WebSocket = originalWebSocket }) - // Foreground recovery must heal Android sessions left half-open after suspension (#5049). - // Transient auth rejection must not force re-pairing while the desktop still has valid credentials (#5200). describe('auth rejection retry (issue #5200)', () => { it('retries the handshake on a transient e2ee_error instead of latching auth-failed', async () => { diff --git a/mobile/src/transport/rpc/rpc-session-liveness-integration.test.ts b/mobile/src/transport/rpc/rpc-session-liveness-integration.test.ts index 3dbd1b69b40..5a82459be52 100644 --- a/mobile/src/transport/rpc/rpc-session-liveness-integration.test.ts +++ b/mobile/src/transport/rpc/rpc-session-liveness-integration.test.ts @@ -36,8 +36,6 @@ class MockWebSocket { onclose: (() => void) | null = null onmessage: ((event: { data: unknown }) => void) | null = null onerror: (() => void) | null = null - throwOnSend = false - throwForMethod: string | null = null readonly sent: string[] = [] close = vi.fn(() => { this.readyState = MockWebSocket.CLOSED @@ -49,12 +47,6 @@ class MockWebSocket { } send(payload: string): void { - if ( - this.throwOnSend || - (this.throwForMethod && payload.includes(`"method":"${this.throwForMethod}"`)) - ) { - throw new Error('socket send failed') - } this.sent.push(payload) } diff --git a/mobile/src/transport/web-bundle/mobile-web-bundle-range-decode.test.ts b/mobile/src/transport/web-bundle/mobile-web-bundle-range-decode.test.ts index c87f1aca214..07bb99e779a 100644 --- a/mobile/src/transport/web-bundle/mobile-web-bundle-range-decode.test.ts +++ b/mobile/src/transport/web-bundle/mobile-web-bundle-range-decode.test.ts @@ -1,7 +1,5 @@ import { gzipSync } from 'fflate' import { describe, expect, it } from 'vitest' -import gunzipVectors from '../../../modules/orca-gzip-utf8-codec/tests/gunzip-vectors.json' -import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal' import { decodeMobileWebBundleRange } from './mobile-web-bundle-range-decode' const RAW = Uint8Array.from({ length: 20_000 }, (_, index) => (index * 31) % 7) @@ -20,25 +18,4 @@ describe('decoding a bundle range', () => { RAW.byteLength - 99 ) }) - - // Pin fflate’s leniency against shared native vectors for CRC/length/trailing bytes; native decoding is stricter. - it('answers the shared gunzip vectors as fflate does', () => { - for (const vector of gunzipVectors) { - let actual: string | null - try { - actual = Buffer.from( - decodeMobileWebBundleRange( - RANGE, - Buffer.from(vector.gzipHex, 'hex'), - vector.outputLimit - 1 - ) - ).toString('hex') - } catch (error) { - expect(error, vector.name).toBeInstanceOf(MobileWebBundleFetchError) - expect(error, vector.name).toMatchObject({ refusal: 'range-undecodable' }) - actual = null - } - expect(actual, vector.name).toBe(vector.js) - } - }) }) diff --git a/mobile/src/worktree/agent-row-lineage.test.ts b/mobile/src/worktree/agent-row-lineage.test.ts index a2483c375f2..013c6fe7c3b 100644 --- a/mobile/src/worktree/agent-row-lineage.test.ts +++ b/mobile/src/worktree/agent-row-lineage.test.ts @@ -36,18 +36,6 @@ describe('buildAgentRowLineageTree', () => { }) describe('flattenAgentRowLineage', () => { - it('emits parent then descendants with increasing depth', () => { - const rows = [row('a'), row('b', 'a'), row('c', 'b'), row('d')] - const flat = flattenAgentRowLineage(rows) - - expect(flat.map((n) => [n.row.paneKey, n.depth])).toEqual([ - ['a', 0], - ['b', 1], - ['c', 2], - ['d', 0] - ]) - }) - it('keeps a cyclic component visible even when other roots exist', () => { // The disconnected a↔b cycle has no root and must remain visible beside the ordinary tree. const rows = [row('root'), row('a', 'b'), row('b', 'a')] diff --git a/mobile/src/worktree/folder-workspace-lane-order.ts b/mobile/src/worktree/folder-workspace-lane-order.ts index df3d6502e0a..5313eeeb09b 100644 --- a/mobile/src/worktree/folder-workspace-lane-order.ts +++ b/mobile/src/worktree/folder-workspace-lane-order.ts @@ -1,4 +1,4 @@ -import { compareFolderWorkspacesForDisplay } from '../../../src/shared/sidebar-list/folder-workspace-lanes' +import { compareFolderWorkspacesForDisplay } from '../../../src/shared/sidebar-list/folders/folder-workspace-lanes' import type { Worktree } from './workspace-list-types' function folderDisplayOrder(w: Worktree) { diff --git a/mobile/src/worktree/last-visited-worktree-by-host.test.ts b/mobile/src/worktree/last-visited-worktree-by-host.test.ts index 832346d85e0..ca5a804d406 100644 --- a/mobile/src/worktree/last-visited-worktree-by-host.test.ts +++ b/mobile/src/worktree/last-visited-worktree-by-host.test.ts @@ -1,16 +1,13 @@ import { describe, expect, it, vi } from 'vitest' -type StorageDouble = { rejection: unknown; values: Map } +type StorageDouble = { values: Map } -const storage = vi.hoisted((): StorageDouble => ({ rejection: null, values: new Map() })) +const storage = vi.hoisted((): StorageDouble => ({ values: new Map() })) vi.mock('@react-native-async-storage/async-storage', () => ({ default: { getItem: async (key: string) => storage.values.get(key) ?? null, setItem: async (key: string, value: string) => { - if (storage.rejection !== null) { - throw storage.rejection - } storage.values.set(key, value) }, removeItem: async (key: string) => { @@ -25,32 +22,11 @@ import { withLastVisitedWorktree, writeLastVisitedWorktree } from './last-visited-worktree-by-host' -import { - LAST_VISITED_WORKTREE_STORAGE_KEY, - readLastVisitedWorktreeRecord -} from './last-visited-worktree-repo' +import { LAST_VISITED_WORKTREE_STORAGE_KEY } from './last-visited-worktree-repo' const stored = () => storage.values.get(LAST_VISITED_WORKTREE_STORAGE_KEY) ?? null describe('readLastVisitedWorktreeOnHost', () => { - it('remembers each host’s own visit, whichever host was visited last', () => { - let raw = withLastVisitedWorktree(null, { hostId: 'host-1', worktreeId: 'repo::/w/a' }) - raw = withLastVisitedWorktree(raw, { - hostId: 'host-2', - worktreeId: 'repo::/w/b', - executionHost: 'runtime:env-1' - }) - expect(readLastVisitedWorktreeOnHost(raw, 'host-1')).toEqual({ worktreeId: 'repo::/w/a' }) - expect(readLastVisitedWorktreeOnHost(raw, 'host-2')).toEqual({ - worktreeId: 'repo::/w/b', - executionHost: 'runtime:env-1' - }) - expect(readLastVisitedWorktreeRecord(raw)).toEqual({ - hostId: 'host-2', - worktreeId: 'repo::/w/b' - }) - }) - it('reads a record stored before per-host visits for its own host, and keeps it on the next write', () => { const legacy = JSON.stringify({ hostId: 'host-1', worktreeId: 'repo::/w/a' }) expect(readLastVisitedWorktreeOnHost(legacy, 'host-1')).toEqual({ worktreeId: 'repo::/w/a' }) @@ -96,15 +72,4 @@ describe('writeLastVisitedWorktree', () => { executionHost: 'runtime:env-1' }) }) - - it('handles a store that refuses the write instead of leaving the rejection loose', async () => { - storage.rejection = new Error('quota exceeded') - try { - await expect( - writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'repo::/w/c' }) - ).resolves.toBeUndefined() - } finally { - storage.rejection = null - } - }) }) diff --git a/mobile/src/worktree/last-visited-worktree-repo.test.ts b/mobile/src/worktree/last-visited-worktree-repo.test.ts deleted file mode 100644 index 61faf5bd7bc..00000000000 --- a/mobile/src/worktree/last-visited-worktree-repo.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - readLastVisitedWorktreeRecord, - readLastVisitedWorktreeRepoId -} from './last-visited-worktree-repo' - -describe('last visited worktree repo', () => { - it('ignores records for another host', () => { - const raw = JSON.stringify({ hostId: 'host-2', worktreeId: 'repo-2::/tmp/worktree' }) - - expect(readLastVisitedWorktreeRepoId(raw, 'host-1')).toBeNull() - }) -}) - -describe('readLastVisitedWorktreeRecord', () => { - it('rejects empty ids that would build a route to nowhere', () => { - expect( - readLastVisitedWorktreeRecord(JSON.stringify({ hostId: '', worktreeId: 'repo::/wt' })) - ).toBeNull() - expect( - readLastVisitedWorktreeRecord(JSON.stringify({ hostId: 'host-1', worktreeId: '' })) - ).toBeNull() - }) -}) diff --git a/mobile/src/worktree/structural-equality.test.ts b/mobile/src/worktree/structural-equality.test.ts index d924795637c..60f71307526 100644 --- a/mobile/src/worktree/structural-equality.test.ts +++ b/mobile/src/worktree/structural-equality.test.ts @@ -5,7 +5,6 @@ describe('areStructurallyEqual', () => { it('equates value-equal nested objects and arrays', () => { const value = () => ({ id: 'a', agents: [{ state: 'working', mainAgent: { stopping: true } }] }) expect(areStructurallyEqual(value(), value())).toBe(true) - expect(areStructurallyEqual([1, [2, { x: null }]], [1, [2, { x: null }]])).toBe(true) }) it('detects a changed nested value', () => { diff --git a/mobile/src/worktree/use-last-visited-worktree-on-host.test.tsx b/mobile/src/worktree/use-last-visited-worktree-on-host.test.tsx index 08c915028c0..66f494abe50 100644 --- a/mobile/src/worktree/use-last-visited-worktree-on-host.test.tsx +++ b/mobile/src/worktree/use-last-visited-worktree-on-host.test.tsx @@ -98,15 +98,6 @@ describe('the list page highlight across the shell’s separate pages', () => { list.unmount() }) - it('reads the stored visit when the list page loads again', async () => { - appStore.clear() - const session = await loadPage(`/h/${HOST}/session/w`) - await session.visits.writeLastVisitedWorktree({ hostId: HOST, worktreeId: 'repo::/w/3' }) - const list = await mountListPage() - expect(list.value()).toEqual({ worktreeId: 'repo::/w/3' }) - list.unmount() - }) - it('follows a session page’s write when the list is the app’s own screen beside it', async () => { appStore.clear() const list = await mountListPage() diff --git a/mobile/src/worktree/workspace-details-model.test.ts b/mobile/src/worktree/workspace-details-model.test.ts index ae0ee36256b..a3132c97822 100644 --- a/mobile/src/worktree/workspace-details-model.test.ts +++ b/mobile/src/worktree/workspace-details-model.test.ts @@ -37,32 +37,6 @@ describe('buildWorkspaceDetailLinks', () => { expect(links[3]).toMatchObject({ title: 'Bug', url: 'https://x/3' }) }) - it("carries the row's PR state, title, link and checks onto its entry", () => { - const [pr, other] = buildWorkspaceDetailLinks( - row({ - linkedItems: [{ provider: 'github', type: 'pr', number: 9, title: 'Older linked PR' }], - linkedPR: { - number: 12, - state: 'merged', - title: 'Search keyboard', - url: 'https://github.com/o/r/pull/12', - checksStatus: 'failure' - } - }) - ) - - expect(pr).toMatchObject({ label: 'PR #9', title: 'Older linked PR' }) - expect(pr).not.toHaveProperty('state') - expect(other).toMatchObject({ - label: 'PR #12', - number: 12, - title: 'Search keyboard', - url: 'https://github.com/o/r/pull/12', - state: 'merged', - checks: 'failure' - }) - }) - it('enriches a linked entry for the same repo and PR', () => { const links = buildWorkspaceDetailLinks( row({ diff --git a/mobile/src/worktree/workspace-list-ordering.ts b/mobile/src/worktree/workspace-list-ordering.ts index 3f82b36c047..f7450b5c77f 100644 --- a/mobile/src/worktree/workspace-list-ordering.ts +++ b/mobile/src/worktree/workspace-list-ordering.ts @@ -5,13 +5,16 @@ import { buildWorkspaceRowComparator, recentActivityWithCreateGrace } from '../../../src/shared/workspace-view/workspace-row-sort' -import { compareCodePoints, compareSidebarText } from '../../../src/shared/sidebar-list/text-order' +import { + compareCodePoints, + compareSidebarText +} from '../../../src/shared/sidebar-list/sorting/text-order' import { getWorktreeRowIdentity } from './worktree-host-row-identity' import { compareSmartStamps, recentStamp, type StampOrderRow -} from '../../../src/shared/sidebar-list/stamp-order' +} from '../../../src/shared/sidebar-list/sorting/stamp-order' function finiteOr(value: number | undefined, fallback: number): number { return value !== undefined && Number.isFinite(value) ? value : fallback diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts index 01a688ad759..6edf97ae245 100644 --- a/mobile/src/worktree/workspace-list-sections.ts +++ b/mobile/src/worktree/workspace-list-sections.ts @@ -1,6 +1,6 @@ -import { orderMainWorktreeFirst } from '../../../src/shared/sidebar-list/section-order' +import { orderMainWorktreeFirst } from '../../../src/shared/sidebar-list/sections/section-order' import { withFolderWorkspacesLast } from './folder-workspace-lane-order' -import { prGroupOfPsLinkedPR } from '../../../src/shared/sidebar-list/workspace-review-facts' +import { prGroupOfPsLinkedPR } from '../../../src/shared/sidebar-list/feed-facts/workspace-review-facts' import { buildWorktreeOccurrencePlan, type WorktreeOccurrenceSection diff --git a/mobile/src/worktree/workspace-view-settings.test.ts b/mobile/src/worktree/workspace-view-settings.test.ts index c63ae3e5b66..9597425b216 100644 --- a/mobile/src/worktree/workspace-view-settings.test.ts +++ b/mobile/src/worktree/workspace-view-settings.test.ts @@ -33,18 +33,6 @@ describe('the other-client filter on an older desktop', () => { }) describe('applyDesktopViewSettings', () => { - it("falls back to the desktop's legacy host scope when no host list is saved", () => { - const scoped = applyDesktopViewSettings(base, { - visibleWorkspaceHostIds: null, - workspaceHostScope: 'ssh:box' - }) - expect(scoped.filters.visibleHostIds).toEqual(['ssh:box']) - expect( - applyDesktopViewSettings(scoped, { visibleWorkspaceHostIds: null, workspaceHostScope: 'all' }) - .filters.visibleHostIds - ).toBeNull() - }) - it('ignores an unrecognized groupBy rather than blanking the mode', () => { const newerGroup: WorkspaceViewSettings = JSON.parse('{"groupBy":"mystery"}') const next = applyDesktopViewSettings(base, newerGroup) diff --git a/mobile/src/worktree/worktree-host-row-identity.test.ts b/mobile/src/worktree/worktree-host-row-identity.test.ts index 8130eebde25..87216b8b719 100644 --- a/mobile/src/worktree/worktree-host-row-identity.test.ts +++ b/mobile/src/worktree/worktree-host-row-identity.test.ts @@ -65,16 +65,6 @@ describe('host-qualified row display state', () => { expect([...retained]).toEqual([getWorktreeRowIdentity(remote)]) }) - it('keeps first-match behavior when confirmed identities are duplicated', () => { - const identity = getWorktreeRowIdentity(row('shared', 'ssh:a')) - const retained = retainLiveSleptWorktreeIdentities(new Set([identity]), [ - row('shared', 'ssh:a', { liveTerminalCount: 0 }), - row('shared', 'ssh:a', { liveTerminalCount: 1 }) - ]) - - expect([...retained]).toEqual([]) - }) - it('applies active and slept overrides to the matching host row only', () => { const local = row('shared', 'ssh:a') const remote = row('shared', 'ssh:b') diff --git a/mobile/src/worktree/worktree-list-completeness.test.ts b/mobile/src/worktree/worktree-list-completeness.test.ts index 51cdf7b180f..89b15ecb947 100644 --- a/mobile/src/worktree/worktree-list-completeness.test.ts +++ b/mobile/src/worktree/worktree-list-completeness.test.ts @@ -56,17 +56,6 @@ function visibleWorktreeIds( } describe('every worktree stays visible regardless of repo.list contents', () => { - it('shows worktrees whose repoId is missing from repo.list', () => { - const worktrees = [ - worktree('orca-local', 'a'), - worktree('orca-local', 'b'), - worktree('orca-runtime', 'c') - ] - const catalogRepos = [['orca-runtime', 'orca-runtime']] as const - - expect(visibleWorktreeIds(worktrees, catalogRepos)).toEqual(['a', 'b', 'c']) - }) - it('shows every worktree when the same repo name maps to two host-specific ids', () => { // Same-name local/runtime repos expose any name-keyed map that discards an owner id. const worktrees = [ diff --git a/src/main/browser/client-hosts/commands/browser-client-host-command-dispatcher.ts b/src/main/browser/client-hosts/commands/browser-client-host-command-dispatcher.ts index 5333fe80322..2299bb73d25 100644 --- a/src/main/browser/client-hosts/commands/browser-client-host-command-dispatcher.ts +++ b/src/main/browser/client-hosts/commands/browser-client-host-command-dispatcher.ts @@ -10,19 +10,25 @@ import { assertBrowserClientHostCommandAuthority, snapshotBrowserClientHostLeaseAuthority } from './browser-client-host-command-authority' -import { joinBrowserClientHostCommands } from './browser-client-host-command-join' +import { + joinBrowserClientHostCommands, + retireBrowserClientCommandPage +} from './browser-client-host-command-join' import { assertNewPageCommand, + cancelBrowserClientPageCommands, + enqueueBrowserClientPageCommand, findExistingCommand, isBrowserClientPageBootstrapCommand, recordBrowserClientPageCommandResult, recordNewPageCommand, removeScheduledCommandPage, - selectCommandPage + selectCommandPage, + releaseBrowserClientPageInitialization, + settleBrowserClientPageInitialization } from './browser-client-host-command-page' import { BrowserClientHostCommandResultCache } from './browser-client-host-command-result-cache' import { - createCommandRecord, type CommandHandler, type DispatcherOptions, failedCommandResult, @@ -37,11 +43,8 @@ import { export class BrowserClientHostCommandDispatcher { private readonly authority: BrowserClientHostLeaseAuthority private readonly handler: CommandHandler - private readonly maxPages: number - private readonly maxActiveCommands: number - private readonly maxConcurrentHandlers: number - private readonly maxQueuedCommandsPerPage: number - private readonly joinTimeoutMs: number + private readonly acquireInitialization: DispatcherOptions['acquireInitialization'] + private readonly limits: ReturnType private readonly resultCache: BrowserClientHostCommandResultCache private readonly pages = new Map() private readonly readyPages: PageState[] = [] @@ -60,13 +63,10 @@ export class BrowserClientHostCommandDispatcher { } this.authority = snapshotBrowserClientHostLeaseAuthority(options.authority) this.handler = options.handler + this.acquireInitialization = options.acquireInitialization const limits = resolveDispatcherLimits(options) - this.maxPages = limits.maxPages - this.maxActiveCommands = limits.maxActiveCommands - this.maxConcurrentHandlers = limits.maxConcurrentHandlers - this.maxQueuedCommandsPerPage = limits.maxQueuedCommandsPerPage + this.limits = limits this.resultCache = new BrowserClientHostCommandResultCache(limits) - this.joinTimeoutMs = limits.joinTimeoutMs this.closedSettlement = new Promise((resolve) => { this.resolveClosedSettlement = resolve }) @@ -78,9 +78,22 @@ export class BrowserClientHostCommandDispatcher { } assertBrowserClientHostCommandAuthority(this.authority, command) const acceptedCommand = snapshotCommandEvent(command) + const previousPage = this.pages.get(acceptedCommand.browserPageId) + if ( + previousPage && + acceptedCommand.pageHostGeneration > previousPage.generation && + !previousPage.retired && + previousPage.initialization?.isRetired() + ) { + return this.retirePage(previousPage.browserPageId, previousPage.generation).then((settled) => + settled + ? this.dispatch(acceptedCommand) + : failedCommandResult('browser_host_page_retirement_pending') + ) + } const admission = selectCommandPage( this.pages, - this.maxPages, + this.limits.maxPages, this.retiredGenerationFloor, acceptedCommand ) @@ -90,23 +103,22 @@ export class BrowserClientHostCommandDispatcher { return existing.promise } assertNewPageCommand(page, acceptedCommand) - if (this.activeCommands >= this.maxActiveCommands) { + if (this.activeCommands >= this.limits.maxActiveCommands) { throw new Error('browser_host_command_capacity') } - if (page.queue.length >= this.maxQueuedCommandsPerPage) { + if (page.queue.length >= this.limits.maxQueuedCommandsPerPage) { throw new Error('browser_host_page_command_capacity') } - const previousPage = this.pages.get(acceptedCommand.browserPageId) + if (acceptedCommand.command.type === 'createPage') { + page.initialization = this.acquireInitialization?.(acceptedCommand) + } admission.commit() recordNewPageCommand(page, acceptedCommand) if (previousPage && previousPage !== page) { + previousPage.initialization?.release() this.resultCache.releasePage(previousPage) } - const record = createCommandRecord(acceptedCommand) - page.records.set(acceptedCommand.commandSequence, record) - page.sequencesByCommandId.set(acceptedCommand.commandId, acceptedCommand.commandSequence) - page.queue.push(record) - page.nextSequence += 1 + const record = enqueueBrowserClientPageCommand(page, acceptedCommand) this.activeCommands += 1 this.schedulePage(page) return record.promise @@ -117,29 +129,10 @@ export class BrowserClientHostCommandDispatcher { if (!page || page.generation !== pageHostGeneration) { throw new Error('browser_host_page_generation_stale') } - if (page.retired) { - return true - } - if (page.retirementPromise) { - return page.retirementPromise - } - let resolveRetirement = (_settled: boolean): void => {} - const retirement = new Promise((resolve) => { - resolveRetirement = resolve + return retireBrowserClientCommandPage(page, this.limits.joinTimeoutMs, () => { + removeScheduledCommandPage(this.readyPages, this.readyPageIds, page) + this.cancelPage(page, 'browser_host_command_cancelled') }) - page.retirementPromise = retirement - page.retiring = true - removeScheduledCommandPage(this.readyPages, this.readyPageIds, page) - this.cancelPage(page, 'browser_host_command_cancelled') - void this.waitForPageHandlers(page).then((settled) => { - if (settled) { - this.finishRetirement(page) - } else if (page.retirementPromise === retirement) { - page.retirementPromise = undefined - } - resolveRetirement(settled) - }) - return retirement } forgetPage(browserPageId: string, pageHostGeneration: number): boolean { @@ -148,6 +141,7 @@ export class BrowserClientHostCommandDispatcher { return false } this.retiredGenerationFloor = Math.max(this.retiredGenerationFloor, pageHostGeneration) + releaseBrowserClientPageInitialization(page) this.resultCache.releasePage(page) return this.pages.delete(browserPageId) } @@ -168,9 +162,12 @@ export class BrowserClientHostCommandDispatcher { [...this.pages.values()].flatMap((page) => page.queue.flatMap((record) => (record.handlerPromise ? [record.handlerPromise] : [])) ), - this.joinTimeoutMs + this.limits.joinTimeoutMs ) if (settled) { + for (const page of this.pages.values()) { + page.initialization?.release() + } this.pages.clear() this.resultCache.clear() } @@ -198,7 +195,7 @@ export class BrowserClientHostCommandDispatcher { } private drain(): void { - while (!this.closed && this.runningHandlers < this.maxConcurrentHandlers) { + while (!this.closed && this.runningHandlers < this.limits.maxConcurrentHandlers) { const page = this.readyPages.shift() if (!page) { return @@ -213,7 +210,7 @@ export class BrowserClientHostCommandDispatcher { private startHandler(page: PageState, record: CommandRecord): void { record.status = 'running' - record.controller = new AbortController() + record.controller ??= new AbortController() this.runningHandlers += 1 let finishJoin = (): void => {} record.handlerPromise = new Promise((resolve) => { @@ -221,7 +218,7 @@ export class BrowserClientHostCommandDispatcher { }) let handled: BrowserClientHostCommandResult | Promise try { - handled = this.handler(record.event, record.controller.signal) + handled = this.handler(record.event, record.controller.signal, record.closeInitialization) } catch { handled = Promise.reject(new Error('handler failed')) } @@ -253,19 +250,24 @@ export class BrowserClientHostCommandDispatcher { recordBrowserClientPageCommandResult(page, record.event.command, result) } record.status = 'settled' + settleBrowserClientPageInitialization(page, record, result) record.controller = undefined record.handlerPromise = undefined this.removeActiveRecord(page, record) if (isBrowserClientPageBootstrapCommand(record.event.command) && !page.created) { - this.cancelPage(page, 'browser_host_command_dependency_failed') + this.cancelPage(page, 'browser_host_command_dependency_failed', true) } if (page.retiring && page.queue.length === 0) { - this.finishRetirement(page) + page.retiring = false + page.retired = true } else { this.schedulePage(page) } this.drain() if (this.closed && this.runningHandlers === 0) { + for (const page of this.pages.values()) { + page.initialization?.release() + } this.pages.clear() this.resultCache.clear() this.settleClosedHandlers() @@ -280,18 +282,14 @@ export class BrowserClientHostCommandDispatcher { this.resolveClosedSettlement() } - private cancelPage(page: PageState, errorCode: string): void { - for (const record of page.queue.slice()) { - if (record.status === 'running') { - record.status = 'cancelling' - record.controller?.abort() - resolveCommandRecord(record, failedCommandResult(errorCode)) - } else if (record.status === 'queued') { - record.status = 'settled' - resolveCommandRecord(record, failedCommandResult(errorCode)) - this.removeActiveRecord(page, record) - } - } + private cancelPage( + page: PageState, + errorCode: string, + preserveInitializationCleanup = false + ): void { + cancelBrowserClientPageCommands(page, errorCode, preserveInitializationCleanup, (record) => + this.removeActiveRecord(page, record) + ) } private removeActiveRecord(page: PageState, record: CommandRecord): void { @@ -300,16 +298,4 @@ export class BrowserClientHostCommandDispatcher { this.resultCache.record(page, record, !this.closed) } } - - private async waitForPageHandlers(page: PageState): Promise { - return joinBrowserClientHostCommands( - page.queue.flatMap((record) => (record.handlerPromise ? [record.handlerPromise] : [])), - this.joinTimeoutMs - ) - } - - private finishRetirement(page: PageState): void { - page.retiring = false - page.retired = true - } } diff --git a/src/main/browser/client-hosts/commands/browser-client-host-command-join.ts b/src/main/browser/client-hosts/commands/browser-client-host-command-join.ts index d4a88220997..bd7a0eb4d92 100644 --- a/src/main/browser/client-hosts/commands/browser-client-host-command-join.ts +++ b/src/main/browser/client-hosts/commands/browser-client-host-command-join.ts @@ -1,3 +1,5 @@ +import type { PageState } from './browser-client-host-command-state' +import { releaseBrowserClientPageInitialization } from './browser-client-host-command-page' export async function joinBrowserClientHostCommands( handlers: Promise[], timeoutMs: number @@ -19,3 +21,34 @@ export async function joinBrowserClientHostCommands( } } } + +export function retireBrowserClientCommandPage( + page: PageState, + joinTimeoutMs: number, + cancel: () => void +): Promise { + if (page.retired) { + return Promise.resolve(true) + } + if (page.retirementPromise) { + return page.retirementPromise + } + const retirement = Promise.withResolvers() + page.retirementPromise = retirement.promise + page.retiring = true + cancel() + void joinBrowserClientHostCommands( + page.queue.flatMap((record) => (record.handlerPromise ? [record.handlerPromise] : [])), + joinTimeoutMs + ).then((settled) => { + if (settled) { + page.retiring = false + page.retired = true + releaseBrowserClientPageInitialization(page) + } else if (page.retirementPromise === retirement.promise) { + page.retirementPromise = undefined + } + retirement.resolve(settled) + }) + return retirement.promise +} diff --git a/src/main/browser/client-hosts/commands/browser-client-host-command-page.ts b/src/main/browser/client-hosts/commands/browser-client-host-command-page.ts index db87ce427bd..d7efe9b7f4f 100644 --- a/src/main/browser/client-hosts/commands/browser-client-host-command-page.ts +++ b/src/main/browser/client-hosts/commands/browser-client-host-command-page.ts @@ -3,6 +3,9 @@ import type { BrowserClientHostCommandEvent } from '../../../../shared/browser/c import type { BrowserClientAutomationResult as BrowserClientHostCommandResult } from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' import { createPageState, + failedCommandResult, + resolveCommandRecord, + createCommandRecord, type CommandRecord, type PageState } from './browser-client-host-command-state' @@ -109,7 +112,8 @@ function commandsMatch( if (first.command.type === 'createPage' && second.command.type === 'createPage') { return ( first.command.browserProfileId === second.command.browserProfileId && - first.command.executionHostKey === second.command.executionHostKey + first.command.executionHostKey === second.command.executionHostKey && + first.command.initialUrl === second.command.initialUrl ) } if (first.command.type === 'restorePage' && second.command.type === 'restorePage') { @@ -168,7 +172,9 @@ export function assertNewPageCommand( ) } } - if (page.createFailed) { + const initializationCleanup = + command.command.type === 'closePage' && page.initialization !== undefined + if (page.createFailed && !initializationCleanup) { throw new Error('browser_host_command_dependency_failed') } } @@ -224,3 +230,68 @@ export function removeScheduledCommandPage( } } } + +export function enqueueBrowserClientPageCommand( + page: PageState, + acceptedCommand: BrowserClientHostCommandEvent +): CommandRecord { + const record = createCommandRecord(acceptedCommand) + page.records.set(acceptedCommand.commandSequence, record) + page.sequencesByCommandId.set(acceptedCommand.commandId, acceptedCommand.commandSequence) + page.queue.push(record) + page.nextSequence += 1 + if (acceptedCommand.command.type === 'closePage') { + if (page.initialization?.cancel(acceptedCommand)) { + record.closeInitialization = page.initialization.close + record.initializationCleanup = true + for (const pending of page.queue) { + if (pending !== record) { + pending.controller?.abort() + } + } + } + } + return record +} + +export function releaseBrowserClientPageInitialization(page: PageState): void { + page.initialization?.release() + page.initialization = undefined +} + +export function settleBrowserClientPageInitialization( + page: PageState, + record: CommandRecord, + result: { status: string } +): void { + record.closeInitialization = undefined + if ( + (record.event.command.type === 'navigate' || record.event.command.type === 'closePage') && + result.status === 'completed' + ) { + releaseBrowserClientPageInitialization(page) + } +} + +export function cancelBrowserClientPageCommands( + page: PageState, + errorCode: string, + preserveInitializationCleanup: boolean, + removeActive: (record: CommandRecord) => void +): void { + for (const record of page.queue.slice()) { + if (preserveInitializationCleanup && record.initializationCleanup) { + continue + } + if (record.status === 'running') { + record.status = 'cancelling' + record.controller?.abort() + resolveCommandRecord(record, failedCommandResult(errorCode)) + } else if (record.status === 'queued') { + record.status = 'settled' + record.closeInitialization = undefined + resolveCommandRecord(record, failedCommandResult(errorCode)) + removeActive(record) + } + } +} diff --git a/src/main/browser/client-hosts/commands/browser-client-host-command-state.ts b/src/main/browser/client-hosts/commands/browser-client-host-command-state.ts index b332592b549..3687a9edbef 100644 --- a/src/main/browser/client-hosts/commands/browser-client-host-command-state.ts +++ b/src/main/browser/client-hosts/commands/browser-client-host-command-state.ts @@ -19,7 +19,8 @@ export const DEFAULT_JOIN_TIMEOUT_MS = 5_000 export type CommandHandler = ( command: BrowserClientHostCommandEvent, - signal: AbortSignal + signal: AbortSignal, + closeInitialization?: BrowserClientInitializationOwner['close'] ) => BrowserClientHostCommandResult | Promise export type CommandRecord = { @@ -30,9 +31,19 @@ export type CommandRecord = { result?: BrowserClientHostCommandResult controller?: AbortController handlerPromise?: Promise + initializationCleanup?: boolean + closeInitialization?: BrowserClientInitializationOwner['close'] +} + +export type BrowserClientInitializationOwner = { + isRetired(): boolean + cancel(event: BrowserClientHostCommandEvent): boolean + close(signal: AbortSignal): Promise + release(): void } export type PageState = { + initialization?: BrowserClientInitializationOwner browserPageId: string generation: number nextSequence: number @@ -51,6 +62,9 @@ export type PageState = { export type DispatcherOptions = { authority: BrowserClientHostLeaseAuthority handler: CommandHandler + acquireInitialization?: ( + event: BrowserClientHostCommandEvent + ) => BrowserClientInitializationOwner | undefined maxPages?: number maxActiveCommands?: number maxConcurrentHandlers?: number @@ -111,7 +125,7 @@ export function createCommandRecord(event: BrowserClientHostCommandEvent): Comma const promise = new Promise((innerResolve) => { resolve = innerResolve }) - return { event, status: 'queued', promise, resolve } + return { event, status: 'queued', promise, resolve, controller: new AbortController() } } export function snapshotCommandEvent( diff --git a/src/main/browser/client-hosts/lifecycle/paired-runtime-browser-client-host-composition.ts b/src/main/browser/client-hosts/lifecycle/paired-runtime-browser-client-host-composition.ts index 173d5844819..d4e1a3d6ada 100644 --- a/src/main/browser/client-hosts/lifecycle/paired-runtime-browser-client-host-composition.ts +++ b/src/main/browser/client-hosts/lifecycle/paired-runtime-browser-client-host-composition.ts @@ -1,3 +1,4 @@ +import type { BrowserClientInitializationOwner } from '../commands/browser-client-host-command-state' import type { BrowserClientHostedPageInventory, BrowserClientHostCommandEvent, @@ -21,7 +22,8 @@ import { type ComposedPageExecutor = { handle( event: BrowserClientHostCommandEvent, - signal: AbortSignal + signal: AbortSignal, + closeInitialization?: BrowserClientInitializationOwner['close'] ): Promise retirePage(browserPageId: string, pageHostGeneration: number): Promise hasUnresolvedPage(browserPageId: string, pageHostGeneration: number): boolean @@ -45,7 +47,8 @@ type ComposedClientHost = { type ClientHostCallbacks = { handler( event: BrowserClientHostCommandEvent, - signal: AbortSignal + signal: AbortSignal, + closeInitialization?: BrowserClientInitializationOwner['close'] ): Promise onAuthority(authority: BrowserClientHostLeaseAuthority): void getPageInventory(): readonly BrowserClientHostedPageInventory[] @@ -187,7 +190,7 @@ export class PairedRuntimeBrowserClientHostComposition< const generation = ++this.hostGeneration let publishedInventory: readonly BrowserClientHostedPageInventory[] | null = null return this.options.createHost(input, { - handler: (event, signal) => this.handleCommand(generation, event, signal), + handler: (event, signal, close) => this.handleCommand(generation, event, signal, close), getPageInventory: () => { if (this.hostGeneration !== generation) { return [] @@ -257,7 +260,8 @@ export class PairedRuntimeBrowserClientHostComposition< private async handleCommand( generation: number, event: BrowserClientHostCommandEvent, - signal: AbortSignal + signal: AbortSignal, + closeInitialization?: BrowserClientInitializationOwner['close'] ): Promise { if (this.hostGeneration !== generation) { throw new Error('browser_client_host_command_aborted') @@ -266,7 +270,7 @@ export class PairedRuntimeBrowserClientHostComposition< if (this.closed || signal.aborted || this.hostGeneration !== generation) { throw new Error('browser_client_host_command_aborted') } - return this.executor.handle(event, signal) + return this.executor.handle(event, signal, closeInitialization) } private requestPageInventoryRefresh(): void { diff --git a/src/main/browser/client-hosts/lifecycle/paired-runtime-browser-client-host-runtime.ts b/src/main/browser/client-hosts/lifecycle/paired-runtime-browser-client-host-runtime.ts index 125cb2d078e..7c84fbfff18 100644 --- a/src/main/browser/client-hosts/lifecycle/paired-runtime-browser-client-host-runtime.ts +++ b/src/main/browser/client-hosts/lifecycle/paired-runtime-browser-client-host-runtime.ts @@ -7,7 +7,10 @@ import { BROWSER_CLIENT_EXPECT_URL_HOST_CAPABILITY } from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' import { BROWSER_CLIENT_FILE_CHANNEL_HOST_CAPABILITY } from '../../../../shared/browser/client-hosting/browser-client-file-channel-protocol' -import type { BrowserClientHostLeaseAuthority } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import { + BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY, + type BrowserClientHostLeaseAuthority +} from '../../../../shared/browser/client-hosting/browser-client-host-protocol' import type { PairedRuntimeConnection } from '../../../../shared/remote-runtime/pairing/paired-runtime-connection' import { getPreferredPairedRuntimeConnection, @@ -122,12 +125,14 @@ const browserClientHosts = browserHostClientId: getBrowserClientHostId(), hostCapabilities: [ 'webview', + BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY, BROWSER_CLIENT_PROFILE_SWITCH_CAPABILITY, BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY, BROWSER_CLIENT_EXPECT_URL_HOST_CAPABILITY, BROWSER_CLIENT_FILE_CHANNEL_HOST_CAPABILITY ], handler, + acquireInitialization: (event) => executor?.acquireInitialization(event), getPageInventory, pageReconciliationProtocolVersion: 1, fileChannelProtocolVersion: 1, diff --git a/src/main/browser/client-hosts/lifecycle/paired-runtime-browser-client-host.ts b/src/main/browser/client-hosts/lifecycle/paired-runtime-browser-client-host.ts index 937f2045a25..d9372cdc2cd 100644 --- a/src/main/browser/client-hosts/lifecycle/paired-runtime-browser-client-host.ts +++ b/src/main/browser/client-hosts/lifecycle/paired-runtime-browser-client-host.ts @@ -29,6 +29,7 @@ export type PairedRuntimeBrowserClientHostOptions = { browserHostClientId: string hostCapabilities: readonly string[] handler: CommandHandler + acquireInitialization?: DispatcherOptions['acquireInitialization'] getPageInventory?: () => readonly BrowserClientHostedPageInventory[] pageReconciliationProtocolVersion?: 1 fileChannelProtocolVersion?: 1 @@ -156,13 +157,14 @@ export class PairedRuntimeBrowserClientHost { this.options.onAuthority?.(authority) this.dispatcher = new BrowserClientHostCommandDispatcher({ authority, - handler: async (command, signal) => + handler: async (command, signal, closeInitialization) => stageBrowserHostCommandResult( command, - await this.options.handler(command, signal), + await this.options.handler(command, signal, closeInitialization), (params) => this.sendCommandResultChunk(params), signal ), + acquireInitialization: this.options.acquireInitialization, ...this.options.dispatcher }) } diff --git a/src/main/browser/client-pages/execution/browser-client-executor-page-creation.ts b/src/main/browser/client-pages/execution/browser-client-executor-page-creation.ts new file mode 100644 index 00000000000..568c392dcf6 --- /dev/null +++ b/src/main/browser/client-pages/execution/browser-client-executor-page-creation.ts @@ -0,0 +1,109 @@ +import type { + BrowserClientHostCommandEvent, + BrowserClientHostedPageInventory +} from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import type { BrowserClientRetainedPage } from '../lifecycle/browser-client-page-retained-state' +import type { + BrowserClientPageAuthorityIdentity, + BrowserClientPageCommandExecutorDependencies +} from './browser-client-page-command-executor-dependencies' +import { + BrowserClientPageCommandError, + isBrowserClientPageCleanupFailure +} from './browser-client-page-command-failure' +import type { + BrowserClientPageInitializations, + PageInitialization +} from './browser-client-page-initializations' +import { assertBrowserClientPageAdmission } from '../lifecycle/browser-client-page-admission' +import { createBrowserClientPageInventory } from '../lifecycle/browser-client-page-inventory' +import { createReservedBrowserClientPage } from '../lifecycle/browser-client-page-creation' +import { retireSupersededExecutionHostPages } from '../lifecycle/browser-client-page-execution-host-supersession' +import { publishBrowserClientRetainedPage } from '../lifecycle/browser-client-page-availability' + +export class BrowserClientExecutorPageCreation { + constructor( + private readonly dependencies: BrowserClientPageCommandExecutorDependencies, + private readonly authority: BrowserClientPageAuthorityIdentity, + private readonly pages: Map, + private readonly creatingPages: Map, + private readonly failedPages: Map, + private readonly initializers: BrowserClientPageInitializations, + private readonly maxPages: number, + private readonly assertAvailable: () => void, + private readonly retirePage: (id: string, generation: number) => Promise + ) {} + async createPage(event: BrowserClientHostCommandEvent, signal: AbortSignal): Promise { + if (event.command.type !== 'createPage') { + throw new BrowserClientPageCommandError('browser_client_page_command_invalid') + } + assertBrowserClientPageAdmission( + [this.pages, this.creatingPages, this.failedPages], + this.maxPages, + event.browserPageId + ) + const unknownInventory = createBrowserClientPageInventory(event, 'outcomeUnknown') + this.creatingPages.set(event.browserPageId, unknownInventory) + const initializer = this.initializers.begin(event) + try { + const initializationSignal = initializer?.controller?.signal + const creation = this.createReservedPage( + event, + initializationSignal ? AbortSignal.any([signal, initializationSignal]) : signal, + initializer + ) + if (initializer) { + initializer.creation = creation + } + await creation + } catch (error) { + if (initializer && !isBrowserClientPageCleanupFailure(error)) { + await this.initializers.creationFailed(initializer) + } + if (isBrowserClientPageCleanupFailure(error)) { + this.failedPages.set(event.browserPageId, unknownInventory) + } + throw error + } finally { + this.creatingPages.delete(event.browserPageId) + } + } + + private async createReservedPage( + event: BrowserClientHostCommandEvent, + signal: AbortSignal, + initializer?: PageInitialization + ): Promise { + await createReservedBrowserClientPage( + { + ...this.dependencies, + ...this.authority, + ...(initializer + ? { + retainFailedCleanup: (cleanup: () => Promise) => { + initializer.failedCleanup = cleanup + } + } + : {}), + retireSupersededExecutionHostPages: (route) => + retireSupersededExecutionHostPages(this.pages.values(), route, (id, generation) => + this.retirePage(id, generation) + ) + }, + event, + signal, + this.assertAvailable, + (page) => { + if (initializer) { + initializer.page = page + } + publishBrowserClientRetainedPage( + this.dependencies, + this.pages, + page, + initializer ? () => initializer.controller?.abort() : undefined + ) + } + ) + } +} diff --git a/src/main/browser/client-pages/execution/browser-client-page-command-executor-test-harness.ts b/src/main/browser/client-pages/execution/browser-client-page-command-executor-test-harness.ts index 5be6826a388..76659c7dc80 100644 --- a/src/main/browser/client-pages/execution/browser-client-page-command-executor-test-harness.ts +++ b/src/main/browser/client-pages/execution/browser-client-page-command-executor-test-harness.ts @@ -85,7 +85,7 @@ export function createHarness(options: { maxPages?: number } = {}) { legacyAuthorityConnectionIdentity: 'legacy-authority-record-a', storageScope: 'a'.repeat(64), maxPages: options.maxPages, - retainNetworkRoute: vi.fn(async () => { + retainNetworkRoute: vi.fn(async (_key: string, _signal: AbortSignal) => { order.push('retain-route') return route }), diff --git a/src/main/browser/client-pages/execution/browser-client-page-command-executor.test.ts b/src/main/browser/client-pages/execution/browser-client-page-command-executor.test.ts index 6d0b8c2f832..4e1f1836723 100644 --- a/src/main/browser/client-pages/execution/browser-client-page-command-executor.test.ts +++ b/src/main/browser/client-pages/execution/browser-client-page-command-executor.test.ts @@ -1,43 +1,85 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { expect, it, vi } from 'vitest' import { createCommand, createHarness, createLifecycleClaim } from './browser-client-page-command-executor-test-harness' -describe('BrowserClientPageCommandExecutor', () => { - beforeEach(() => vi.restoreAllMocks()) - - it('holds Session and route release for the exact destroyed acknowledgement', async () => { - const { dependencies, executor, order } = createHarness() - let acknowledgeDestroyed = (): void => {} - const destroyed = new Promise((resolve) => { - acknowledgeDestroyed = resolve - }) - dependencies.routeWebContents.claimGuestLifecycle.mockImplementationOnce((registration) => { - order.push('claim-guest') - return createLifecycleClaim(registration, destroyed) - }) - dependencies.routeWebContents.beginGuestRetirement.mockImplementationOnce(() => { - order.push('retire-guest') - return destroyed - }) - await executor.handle(createCommand('createPage'), new AbortController().signal) - - const retiring = executor.retirePage('page-a', 7) - await Promise.resolve() - await Promise.resolve() - expect(order.slice(-2)).toEqual(['retire-guest', 'retire-renderer-page']) - expect(executor.snapshotPageInventory()).toEqual([ - expect.objectContaining({ browserPageId: 'page-a', state: 'outcomeUnknown' }) - ]) - acknowledgeDestroyed() - await expect(retiring).resolves.toBe(true) - expect(order.slice(-4)).toEqual([ - 'retire-guest', - 'retire-renderer-page', - 'release-session', - 'release-route' - ]) +function closeCommand(generation = 7) { + return createCommand('navigate', { + pageReconciliationProtocolVersion: 1, + pageHostGeneration: generation, + command: { + type: 'closePage', + targetAuthority: { + authorityRuntimeId: 'runtime-a', + authorityEpoch: 'epoch-a', + browserHostClientId: 'client-a', + browserHostGeneration: 3, + pageHostGeneration: generation + } + } }) +} + +it('retries the exact failed renderer cleanup without releasing joined resources twice', async () => { + const h = createHarness() + const signal = new AbortController().signal + await h.executor.handle(createCommand('createPage'), signal) + h.renderer.retirePage.mockRejectedValueOnce(new Error('retirement-reply-lost')) + expect(await h.executor.handle(closeCommand(), signal)).toMatchObject({ status: 'failed' }) + expect(h.executor.snapshotPageInventory()).toMatchObject([{ state: 'outcomeUnknown' }]) + expect(h.route.release).toHaveBeenCalledTimes(1) + expect(h.routeSession.release).toHaveBeenCalledTimes(1) + expect(await h.executor.handle(createCommand('navigate'), signal)).toMatchObject({ + status: 'failed' + }) + expect(await h.executor.handle(closeCommand(8), signal)).toMatchObject({ status: 'failed' }) + expect(h.renderer.retirePage).toHaveBeenCalledTimes(1) + expect(await h.executor.handle(closeCommand(), signal)).toEqual({ status: 'completed' }) + expect(h.renderer.retirePage).toHaveBeenCalledTimes(2) + expect(h.dependencies.retireAutomation).toHaveBeenCalledTimes(1) + expect(h.route.release).toHaveBeenCalledTimes(1) + expect(h.routeSession.release).toHaveBeenCalledTimes(1) + expect(h.executor.snapshotPageInventory()).toEqual([]) + expect(await h.executor.handle(closeCommand(), signal)).toMatchObject({ status: 'failed' }) + await h.executor.close() +}) + +it('joins concurrent cleanup callers and holds resources until exact native destruction', async () => { + const h = createHarness() + const destroyed = Promise.withResolvers() + h.dependencies.routeWebContents.claimGuestLifecycle.mockImplementationOnce((registration) => + createLifecycleClaim(registration, destroyed.promise) + ) + await h.executor.handle(createCommand('createPage'), new AbortController().signal) + const first = h.executor.retirePage('page-a', 7) + const second = h.executor.retirePage('page-a', 7) + await vi.waitFor(() => expect(h.renderer.retirePage).toHaveBeenCalledTimes(1)) + expect(h.route.release).not.toHaveBeenCalled() + expect(h.routeSession.release).not.toHaveBeenCalled() + expect(h.executor.snapshotPageInventory()).toMatchObject([{ state: 'outcomeUnknown' }]) + destroyed.resolve() + expect(await Promise.all([first, second])).toEqual([true, true]) + expect(h.route.release).toHaveBeenCalledTimes(1) + expect(h.routeSession.release).toHaveBeenCalledTimes(1) + expect(h.executor.snapshotPageInventory()).toEqual([]) + await h.executor.close() +}) + +it('keeps an unsuccessful one-shot route release unresolved through retry and teardown', async () => { + const h = createHarness() + await h.executor.handle(createCommand('createPage'), new AbortController().signal) + h.route.release.mockRejectedValueOnce(new Error('route-close-failed')) + await expect(h.executor.retirePage('page-a', 7)).rejects.toThrow( + 'Browser client page cleanup failed' + ) + await expect(h.executor.retirePage('page-a', 7)).rejects.toThrow( + 'Browser client page cleanup failed' + ) + expect(h.route.release).toHaveBeenCalledTimes(1) + expect(h.renderer.retirePage).toHaveBeenCalledTimes(1) + expect(h.executor.snapshotPageInventory()).toMatchObject([{ state: 'outcomeUnknown' }]) + await expect(h.executor.close()).rejects.toThrow('Browser client page executor cleanup failed') + expect(h.route.release).toHaveBeenCalledTimes(1) }) diff --git a/src/main/browser/client-pages/execution/browser-client-page-command-executor.ts b/src/main/browser/client-pages/execution/browser-client-page-command-executor.ts index 34c9b6453e5..dc4b705c7b4 100644 --- a/src/main/browser/client-pages/execution/browser-client-page-command-executor.ts +++ b/src/main/browser/client-pages/execution/browser-client-page-command-executor.ts @@ -1,4 +1,12 @@ -import { closeBrowserClientExecutorPages } from './browser-client-page-executor-close' +import type { BrowserClientInitializationOwner } from '../../client-hosts/commands/browser-client-host-command-state' +import { publishBrowserClientRetainedPage } from '../lifecycle/browser-client-page-availability' +import { BrowserClientExecutorPageCreation } from './browser-client-executor-page-creation' +import { BrowserClientPageInitializations } from './browser-client-page-initializations' +import { BrowserClientPageMetadataParams } from '../../../../shared/browser/client-hosting/browser-client-page-metadata-protocol' +import { + closeBrowserClientExecutor, + cleanupBrowserClientExecutorPage +} from './browser-client-page-executor-close' import { BrowserClientProfileSwitches, switchBrowserClientPageProfile @@ -9,16 +17,8 @@ import { type BrowserClientHostCommandEvent } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' import type { BrowserClientAutomationResult as BrowserClientHostCommandResult } from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' -import { cleanupRetainedBrowserClientPage } from '../lifecycle/browser-client-page-cleanup' import type { BrowserClientPageRendererIdentity } from '../../../../shared/browser/client-hosting/browser-client-page-renderer-protocol' -import { assertBrowserClientPageAdmission } from '../lifecycle/browser-client-page-admission' -import { createReservedBrowserClientPage } from '../lifecycle/browser-client-page-creation' -import { retireSupersededExecutionHostPages } from '../lifecycle/browser-client-page-execution-host-supersession' -import { - browserClientPageCommandFailure, - BrowserClientPageCommandError, - isBrowserClientPageCleanupFailure -} from './browser-client-page-command-failure' +import { browserClientPageCommandFailure } from './browser-client-page-command-failure' import { BrowserClientPageNavigationFence } from '../lifecycle/browser-client-page-navigation-fence' import { executeBrowserClientPageReconciliationCommand } from '../lifecycle/browser-client-page-reconciliation' import { @@ -27,7 +27,6 @@ import { type BrowserClientRetainedPage } from '../lifecycle/browser-client-page-retained-state' import { - createBrowserClientPageInventory, recordBrowserClientPagePublishedUrl, snapshotBrowserClientPageInventoryList } from '../lifecycle/browser-client-page-inventory' @@ -35,7 +34,6 @@ import { executeBrowserClientPageAutomationCommand, navigateBrowserClientPageCommand } from './browser-client-page-command-execution' -import { publishBrowserClientRetainedPage } from '../lifecycle/browser-client-page-availability' import { pageAuthorityIdentity, type BrowserClientPageAuthorityIdentity, @@ -47,14 +45,22 @@ export class BrowserClientPageCommandExecutor { private readonly pages = new Map() private readonly creatingPages = new Map() private readonly failedPages = new Map() + private readonly initializers: BrowserClientPageInitializations private readonly profileSwitches = new BrowserClientProfileSwitches() private readonly navigationFence = new BrowserClientPageNavigationFence() private closePromise: Promise | null = null + private closingPageAttempts?: Map> private authority: BrowserClientPageAuthorityIdentity private authorityTransitioning = false private closed = false constructor(private readonly dependencies: BrowserClientPageCommandExecutorDependencies) { + this.initializers = new BrowserClientPageInitializations( + this.pages, + this.failedPages, + dependencies.routeWebContents, + (page) => this.cleanupPage(page) + ) this.authority = pageAuthorityIdentity(dependencies) this.maxPages = dependencies.maxPages ?? BROWSER_CLIENT_HOST_PAGE_INVENTORY_MAX_PAGES if ( @@ -68,7 +74,8 @@ export class BrowserClientPageCommandExecutor { async handle( event: BrowserClientHostCommandEvent, - signal: AbortSignal + signal: AbortSignal, + closeInitialization?: BrowserClientInitializationOwner['close'] ): Promise { if (this.closed || this.authorityTransitioning || this.navigationFence.isFenced) { return { @@ -77,6 +84,9 @@ export class BrowserClientPageCommandExecutor { } } try { + if (closeInitialization) { + return await closeInitialization(signal) + } const value = await this.executeCommand(event, signal) return value === undefined ? { status: 'completed' } : { status: 'completed', value } } catch (error) { @@ -107,6 +117,9 @@ export class BrowserClientPageCommandExecutor { case 'createPage': return this.createPage(event, signal) case 'navigate': + if (this.initializers.has(event.browserPageId)) { + return this.initializers.initialize(event, signal) + } return navigateBrowserClientPageCommand( this.pages, this.dependencies.routeWebContents, @@ -125,33 +138,41 @@ export class BrowserClientPageCommandExecutor { } ) case 'reclaimPage': + return this.reconcilePage(event, signal) case 'closePage': + if (this.initializers.ownsClose(event)) { + return this.initializers.close(event, signal) + } + return this.reconcilePage(event, signal) case 'restorePage': - return executeBrowserClientPageReconciliationCommand( - { - pages: this.pages, - failedPages: this.failedPages, - routeWebContents: this.dependencies.routeWebContents, - assertAvailable: () => - this.navigationFence.assertAvailable(this.closed || this.authorityTransitioning), - createPage: (command, commandSignal) => this.createPage(command, commandSignal), - navigate: (command, commandSignal) => - navigateBrowserClientPageCommand( - this.pages, - this.dependencies.routeWebContents, - command, - commandSignal - ), - retirePage: (browserPageId, generation) => this.retirePage(browserPageId, generation), - cleanupPage: (page, previousRendererPage) => - this.cleanupPage(page, previousRendererPage) - }, - event, - signal - ) + return this.reconcilePage(event, signal) } } + private reconcilePage(event: BrowserClientHostCommandEvent, signal: AbortSignal): Promise { + return executeBrowserClientPageReconciliationCommand( + { + pages: this.pages, + failedPages: this.failedPages, + routeWebContents: this.dependencies.routeWebContents, + assertAvailable: () => + this.navigationFence.assertAvailable(this.closed || this.authorityTransitioning), + createPage: (command, commandSignal) => this.createPage(command, commandSignal), + navigate: (command, commandSignal) => + navigateBrowserClientPageCommand( + this.pages, + this.dependencies.routeWebContents, + command, + commandSignal + ), + retirePage: (browserPageId, generation) => this.retirePage(browserPageId, generation), + cleanupPage: (page, previousRendererPage) => this.cleanupPage(page, previousRendererPage) + }, + event, + signal + ) + } + hasPage(browserPageId: string, pageHostGeneration: number): boolean { return this.pages.get(browserPageId)?.generation === pageHostGeneration } @@ -164,8 +185,14 @@ export class BrowserClientPageCommandExecutor { return this.failedPages.get(browserPageId)?.pageHostGeneration === pageHostGeneration } - readonly recordPublishedPageUrl = (params: unknown): void => - recordBrowserClientPagePublishedUrl(this.pages, params) + readonly recordPublishedPageUrl = (params: unknown): void => { + // Only actual initialization owns a requested URL; published pages keep ordinary metadata semantics. + const parsed = BrowserClientPageMetadataParams.safeParse(params) + if (!parsed.success || this.initializers.has(parsed.data.browserPageId)) { + return + } + recordBrowserClientPagePublishedUrl(this.pages, parsed.data) + } snapshotPageInventory(): readonly BrowserClientHostedPageInventory[] { return snapshotBrowserClientPageInventoryList( @@ -176,15 +203,34 @@ export class BrowserClientPageCommandExecutor { } close(): Promise { + if (this.closePromise) { + return this.closePromise + } this.closed = true - return (this.closePromise ??= this.navigationFence.fenceBeforeCleanup( - this.profileSwitches.withStagedPages(this.pages.values()), - (claim) => this.dependencies.routeWebContents.revokeNavigation(claim), - () => this.closePages() - )) + this.closingPageAttempts = new Map() + this.initializers.cancelAll() + return (this.closePromise ??= this.navigationFence + .fenceBeforeCleanup( + this.profileSwitches.withStagedPages(this.pages.values()), + (claim) => this.dependencies.routeWebContents.revokeNavigation(claim), + () => + closeBrowserClientExecutor({ + closeInitializations: () => this.initializers.closeAll(), + profileSwitches: this.profileSwitches, + creatingPages: this.creatingPages, + failedPages: this.failedPages, + pages: this.pages, + cleanupPage: (page) => this.cleanupPage(page), + uploadStaging: this.dependencies.uploadStaging + }) + ) + .finally(() => { + this.closingPageAttempts = undefined + })) } fenceNavigation(): void { + this.initializers.cancelAll() this.navigationFence.fence(this.profileSwitches.withStagedPages(this.pages.values()), (claim) => this.dependencies.routeWebContents.revokeNavigation(claim) ) @@ -195,6 +241,7 @@ export class BrowserClientPageCommandExecutor { throw new Error('browser_client_page_authority_transition_unavailable') } this.authorityTransitioning = true + this.initializers.cancelAll() this.navigationFence.revoke( this.profileSwitches.withStagedPages(this.pages.values()), (claim) => this.dependencies.routeWebContents.revokeNavigation(claim) @@ -205,66 +252,40 @@ export class BrowserClientPageCommandExecutor { if (this.closed || !this.authorityTransitioning || !input.authorityConnectionIdentity) { throw new Error('browser_client_page_authority_transition_unavailable') } + this.initializers.retireCleanupReceipts() this.authority = pageAuthorityIdentity(input) this.authorityTransitioning = false } retirePage(browserPageId: string, pageHostGeneration: number): Promise { - return retireBrowserClientRetainedPage( + return ( + this.initializers.retirePage(browserPageId, pageHostGeneration) ?? + retireBrowserClientRetainedPage(this.pages, browserPageId, pageHostGeneration, (page) => + this.cleanupPage(page) + ) + ) + } + + private createPage(event: BrowserClientHostCommandEvent, signal: AbortSignal): Promise { + return new BrowserClientExecutorPageCreation( + this.dependencies, + this.authority, this.pages, + this.creatingPages, this.failedPages, - browserPageId, - pageHostGeneration, - (page) => this.cleanupPage(page) - ) - } - - private async createPage( - event: BrowserClientHostCommandEvent, - signal: AbortSignal - ): Promise { - if (event.command.type !== 'createPage') { - throw new BrowserClientPageCommandError('browser_client_page_command_invalid') - } - assertBrowserClientPageAdmission( - [this.pages, this.creatingPages, this.failedPages], + this.initializers, this.maxPages, - event.browserPageId - ) - const unknownInventory = createBrowserClientPageInventory(event, 'outcomeUnknown') - this.creatingPages.set(event.browserPageId, unknownInventory) - try { - await this.createReservedPage(event, signal) - } catch (error) { - if (isBrowserClientPageCleanupFailure(error)) { - this.failedPages.set(event.browserPageId, unknownInventory) - } - throw error - } finally { - this.creatingPages.delete(event.browserPageId) - } + () => this.navigationFence.assertAvailable(this.closed || this.authorityTransitioning), + (id, generation) => this.retirePage(id, generation) + ).createPage(event, signal) } - private async createReservedPage( - event: BrowserClientHostCommandEvent, - signal: AbortSignal - ): Promise { - await createReservedBrowserClientPage( - { - ...this.dependencies, - ...this.authority, - retireSupersededExecutionHostPages: (route) => - retireSupersededExecutionHostPages(this.pages.values(), route, (id, generation) => - this.retirePage(id, generation) - ) - }, - event, - signal, - () => this.navigationFence.assertAvailable(this.closed || this.authorityTransitioning), - (page) => { - publishBrowserClientRetainedPage(this.dependencies, this.pages, page) - } - ) + acquireInitialization(event: BrowserClientHostCommandEvent) { + return this.initializers.acquire(event) + } + + cancelInitialization(event: BrowserClientHostCommandEvent): boolean { + return this.initializers.cancel(event) } findPageByWebContentsId(webContentsId: number): BrowserClientHostedPageInventory | undefined { @@ -275,37 +296,23 @@ export class BrowserClientPageCommandExecutor { )?.inventory } - private async cleanupPage( + private cleanupPage( page: BrowserClientRetainedPage, previousRendererPage?: Readonly, releaseUploads = true ): Promise { - page.releaseAvailabilityWatch?.() - page.releaseAvailabilityWatch = undefined - this.dependencies.guestBinding.release(page.registration) - return cleanupRetainedBrowserClientPage( + const closing = this.closingPageAttempts + const attempt = closing?.get(page) + if (attempt) { + return attempt + } + const cleanup = cleanupBrowserClientExecutorPage( + this.dependencies, page, - { - routeWebContents: this.dependencies.routeWebContents, - retireAutomation: this.dependencies.retireAutomation, - releaseUploadStaging: async () => { - if (releaseUploads) { - await this.dependencies.uploadStaging?.releasePage(page.inventory.browserPageId) - } - } - }, - previousRendererPage + previousRendererPage, + releaseUploads ) - } - - private closePages(): Promise { - return closeBrowserClientExecutorPages({ - profileSwitches: this.profileSwitches, - creatingPages: this.creatingPages, - failedPages: this.failedPages, - pages: this.pages, - cleanupPage: (page) => this.cleanupPage(page), - uploadStaging: this.dependencies.uploadStaging - }) + closing?.set(page, cleanup) + return cleanup } } diff --git a/src/main/browser/client-pages/execution/browser-client-page-executor-close.ts b/src/main/browser/client-pages/execution/browser-client-page-executor-close.ts index c36a32b9767..723687fcd03 100644 --- a/src/main/browser/client-pages/execution/browser-client-page-executor-close.ts +++ b/src/main/browser/client-pages/execution/browser-client-page-executor-close.ts @@ -1,3 +1,6 @@ +import type { BrowserClientPageCommandExecutorDependencies } from './browser-client-page-command-executor-dependencies' +import type { BrowserClientPageRendererIdentity } from '../../../../shared/browser/client-hosting/browser-client-page-renderer-protocol' +import { cleanupRetainedBrowserClientPage } from '../lifecycle/browser-client-page-cleanup' import type { BrowserClientRetainedPage } from '../lifecycle/browser-client-page-retained-state' import type { BrowserClientUploadStaging } from '../../client-hosts/file-transfers/browser-client-upload-staging' import { forgetBrowserClientRetainedPage } from '../lifecycle/browser-client-page-retained-state' @@ -11,7 +14,12 @@ export async function closeBrowserClientExecutorPages(input: { cleanupPage(page: BrowserClientRetainedPage): Promise uploadStaging?: BrowserClientUploadStaging }): Promise { - const retired = await Promise.allSettled(input.profileSwitches.retainedCleanups) + const retired = await Promise.allSettled( + [...input.profileSwitches.retainedCleanups].map(async (cleanup) => { + await cleanup() + input.profileSwitches.retainedCleanups.delete(cleanup) + }) + ) const failures: unknown[] = [ ...retired.filter((result) => result.status === 'rejected').map((result) => result.reason), ...(input.creatingPages.size > 0 || input.profileSwitches.stagedPages.size > 0 @@ -21,7 +29,7 @@ export async function closeBrowserClientExecutorPages(input: { ] for (const [browserPageId, page] of input.pages) { try { - page.retiring ??= input.cleanupPage(page) + page.retiring = input.cleanupPage(page) await page.retiring forgetBrowserClientRetainedPage(input.pages, browserPageId, page) } catch (error) { @@ -35,3 +43,42 @@ export async function closeBrowserClientExecutorPages(input: { throw new AggregateError(failures, 'Browser client page executor cleanup failed') } } + +export async function cleanupBrowserClientExecutorPage( + dependencies: BrowserClientPageCommandExecutorDependencies, + page: BrowserClientRetainedPage, + previousRendererPage?: Readonly, + releaseUploads = true +): Promise { + if (!page.cleanup) { + page.releaseAvailabilityWatch?.() + page.releaseAvailabilityWatch = undefined + dependencies.guestBinding.release(page.registration) + } + return cleanupRetainedBrowserClientPage( + page, + { + routeWebContents: dependencies.routeWebContents, + retireAutomation: dependencies.retireAutomation, + releaseUploadStaging: async () => { + if (releaseUploads) { + await dependencies.uploadStaging?.releasePage(page.inventory.browserPageId) + } + } + }, + previousRendererPage + ) +} + +export async function closeBrowserClientExecutor( + input: Parameters[0] & { + closeInitializations(): Promise + } +): Promise { + const failures: unknown[] = [] + await input.closeInitializations().catch((error: unknown) => failures.push(error)) + await closeBrowserClientExecutorPages(input).catch((error: unknown) => failures.push(error)) + if (failures.length) { + throw new AggregateError(failures, 'Browser client page executor cleanup failed') + } +} diff --git a/src/main/browser/client-pages/execution/browser-client-page-initialization.test.ts b/src/main/browser/client-pages/execution/browser-client-page-initialization.test.ts new file mode 100644 index 00000000000..99535ea2139 --- /dev/null +++ b/src/main/browser/client-pages/execution/browser-client-page-initialization.test.ts @@ -0,0 +1,173 @@ +import { BrowserRoutePageAvailability } from '../../routing/guest-authority/browser-route-page-availability' +import { expect, it, vi } from 'vitest' +import { BrowserClientPageCommandExecutor } from './browser-client-page-command-executor' +import { BrowserClientHostCommandDispatcher } from '../../client-hosts/commands/browser-client-host-command-dispatcher' +import { createCommand, createHarness } from './browser-client-page-command-executor-test-harness' +import type { BrowserClientHostCommandEvent } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' + +const create = (): BrowserClientHostCommandEvent => + createCommand('createPage', { + command: { + type: 'createPage', + browserProfileId: 'profile-a', + executionHostKey: 'execution-host-a', + initialUrl: 'https://example.internal/path' + } + }) +const close = (sequence: number): BrowserClientHostCommandEvent => ({ + ...create(), + commandSequence: sequence, + commandId: `close-${sequence}`, + command: { type: 'closePage', targetAuthority: create() } +}) + +it('keeps initialization inventory requested, then adopts the committed redirect without an ordinary URL overwrite', async () => { + const { dependencies } = createHarness() + const commit = Promise.withResolvers() + const executor = new BrowserClientPageCommandExecutor({ + ...dependencies, + routeWebContents: { + ...dependencies.routeWebContents, + initializeGuest: vi.fn(() => commit.promise) + } + }) + const dispatcher = new BrowserClientHostCommandDispatcher({ + authority: create(), + handler: (event, signal, close) => executor.handle(event, signal, close), + acquireInitialization: (event) => executor.acquireInitialization(event) + }) + await dispatcher.dispatch(create()) + expect(executor.snapshotPageInventory()[0]).toMatchObject({ + state: 'outcomeUnknown', + currentUrl: 'https://example.internal/path' + }) + const navigation = dispatcher.dispatch(createCommand('navigate')) + commit.resolve('https://example.internal/final') + await expect(navigation).resolves.toEqual({ + status: 'completed', + value: 'https://example.internal/final' + }) + expect(executor.snapshotPageInventory()[0]).toMatchObject({ + state: 'active', + currentUrl: 'https://example.internal/final' + }) + expect(dependencies.routeWebContents.navigateGuest).not.toHaveBeenCalled() + await dispatcher.dispatch(close(3)) + expect(executor.snapshotPageInventory()).toEqual([]) + await dispatcher.close() + await executor.close() +}) + +it.each(['create', 'gap', 'navigate'] as const)( + 'an exact terminal close cancels %s and stays ordered through cleanup', + async (phase) => { + const { dependencies, route, renderer } = createHarness() + const mount = Promise.withResolvers<{ webContentsId: number }>() + const navigating = Promise.withResolvers() + if (phase === 'create') { + renderer.mountPage.mockImplementationOnce(() => mount.promise) + } + const executor = new BrowserClientPageCommandExecutor({ + ...dependencies, + routeWebContents: { + ...dependencies.routeWebContents, + initializeGuest: vi.fn((_claim, _url, signal) => { + navigating.resolve() + return new Promise((_resolve, reject) => + signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + ) + }) + } + }) + const results: string[] = [] + const dispatcher = new BrowserClientHostCommandDispatcher({ + authority: create(), + handler: async (event, signal, close) => { + const result = await executor.handle(event, signal, close) + results.push(event.command.type) + return result + }, + acquireInitialization: (event) => executor.acquireInitialization(event) + }) + const creation = dispatcher.dispatch(create()) + if (phase !== 'create') { + await creation + } + let navigation: Promise | undefined + if (phase === 'navigate') { + navigation = dispatcher.dispatch(createCommand('navigate')) + await navigating.promise + } + const stale = close(phase === 'navigate' ? 3 : 2) + if (stale.command.type === 'closePage') { + stale.command.targetAuthority = { ...stale.command.targetAuthority, pageHostGeneration: 6 } + } + // Rejected stale targets cannot abort the initializer even when normally admitted to the terminal queue. + if (phase === 'gap') { + await expect(dispatcher.dispatch(stale)).resolves.toMatchObject({ status: 'failed' }) + } + const closing = dispatcher.dispatch(close(phase === 'navigate' || phase === 'gap' ? 3 : 2)) + if (phase === 'create') { + mount.resolve({ webContentsId: 41 }) + } + await creation + await navigation + await expect(closing).resolves.toEqual({ status: 'completed' }) + expect(results.at(-1)).toBe('closePage') + expect(executor.snapshotPageInventory()).toEqual([]) + expect(route.release).toHaveBeenCalledOnce() + await dispatcher.close() + await executor.close() + } +) + +it('a shared availability report cancels only its exact environment guest during initialization', async () => { + const availability = new BrowserRoutePageAvailability() + const signal = new AbortController().signal + const peers = ['a', 'b'].map((name, index) => { + const h = createHarness() + h.routeSession.partition = `persist:orca-browser-v1-${name.repeat(64)}` + h.renderer.rendererWebContentsId = 11 + index + h.renderer.mountPage.mockResolvedValue({ webContentsId: 41 + index }) + const started = Promise.withResolvers() + const executor = new BrowserClientPageCommandExecutor({ + ...h.dependencies, + routeWebContents: { + ...h.dependencies.routeWebContents, + watchPageAvailability: availability.watch, + initializeGuest: (_claim, _url, pending) => { + started.resolve(pending) + return new Promise((_resolve, reject) => + pending.addEventListener('abort', () => reject(new Error('guest-lost')), { once: true }) + ) + } + } + }) + const command = { ...create(), pageHostGeneration: 7 + index } + return { ...h, executor, started, command } + }) + try { + await Promise.all(peers.map((p) => p.executor.handle(p.command, signal))) + const navigations = peers.map((p) => + p.executor.handle( + { + ...p.command, + commandSequence: 2, + commandId: 'navigate', + command: { type: 'navigate', url: 'https://example.internal/path' } + }, + signal + ) + ) + const [a, b] = await Promise.all(peers.map((p) => p.started.promise)) + availability.report(peers[0].dependencies.routeWebContents.claimGuestLifecycle.mock.calls[0][0]) + await expect(navigations[0]).resolves.toMatchObject({ status: 'failed' }) + expect(a.aborted).toBe(true) + expect(b.aborted).toBe(false) + availability.report(peers[1].dependencies.routeWebContents.claimGuestLifecycle.mock.calls[0][0]) + await expect(navigations[1]).resolves.toMatchObject({ status: 'failed' }) + expect(b.aborted).toBe(true) + } finally { + await Promise.all(peers.map((p) => p.executor.close())) + } +}) diff --git a/src/main/browser/client-pages/execution/browser-client-page-initializations.ts b/src/main/browser/client-pages/execution/browser-client-page-initializations.ts new file mode 100644 index 00000000000..0061e940553 --- /dev/null +++ b/src/main/browser/client-pages/execution/browser-client-page-initializations.ts @@ -0,0 +1,262 @@ +import type { BrowserClientInitializationOwner } from '../../client-hosts/commands/browser-client-host-command-state' +import { normalizeBrowserNavigationUrl } from '../../../../shared/browser-url' +import type { + BrowserClientHostedPageInventory, + BrowserClientHostCommandEvent +} from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import { sameBrowserClientPageAuthority } from '../../client-hosts/commands/browser-client-host-command-authority' +import type { + BrowserClientRetainedPage, + BrowserClientPageLifecycleRegistry +} from '../lifecycle/browser-client-page-retained-state' +import { updateBrowserClientPageInventoryCurrentUrl } from '../lifecycle/browser-client-page-inventory' +import { assertBrowserClientPageCommandNotAborted } from './browser-client-page-command-admission' +import { BrowserClientPageCommandError } from './browser-client-page-command-failure' + +export type PageInitialization = { + identity: Pick< + BrowserClientHostCommandEvent, + | 'authorityRuntimeId' + | 'authorityEpoch' + | 'browserHostClientId' + | 'browserHostGeneration' + | 'pageHostGeneration' + | 'browserPageId' + > + event?: BrowserClientHostCommandEvent + controller?: AbortController + creation?: Promise + page?: BrowserClientRetainedPage + failedCleanup?: () => Promise + cleaned?: boolean +} + +export class BrowserClientPageInitializations { + private readonly operations = new Map() + + constructor( + private readonly pages: Map, + private readonly failedPages: Map, + private readonly routeWebContents: BrowserClientPageLifecycleRegistry, + private readonly cleanupPage: (page: BrowserClientRetainedPage) => Promise + ) {} + + has(browserPageId: string): boolean { + const initializer = this.operations.get(browserPageId) + return initializer !== undefined && !initializer.cleaned + } + + begin(event: BrowserClientHostCommandEvent): PageInitialization | undefined { + if (event.command.type !== 'createPage' || event.command.initialUrl === undefined) { + return undefined + } + const existing = this.operations.get(event.browserPageId) + if ( + existing && + sameBrowserClientPageAuthority(existing.identity, event) && + existing.identity.pageHostGeneration === event.pageHostGeneration + ) { + return existing + } + const { + authorityRuntimeId, + authorityEpoch, + browserHostClientId, + browserHostGeneration, + pageHostGeneration, + browserPageId + } = event + const initializer = { + identity: { + authorityRuntimeId, + authorityEpoch, + browserHostClientId, + browserHostGeneration, + pageHostGeneration, + browserPageId + }, + event, + controller: new AbortController() + } + this.operations.set(event.browserPageId, initializer) + return initializer + } + + ownsClose(event: BrowserClientHostCommandEvent): boolean { + const initializer = this.operations.get(event.browserPageId) + return Boolean( + event.command.type === 'closePage' && + initializer && + sameBrowserClientPageAuthority(initializer.identity, event.command.targetAuthority) && + event.pageHostGeneration === initializer.identity.pageHostGeneration + ) + } + + async creationFailed(initializer: PageInitialization): Promise { + await this.cleanupInitializer(initializer) + } + + retireCleanupReceipts(): void { + for (const [id, initializer] of this.operations) { + if (initializer.cleaned) { + this.operations.delete(id) + } + } + } + + acquire(event: BrowserClientHostCommandEvent): BrowserClientInitializationOwner | undefined { + const initializer = this.begin(event) + if (!initializer) { + return undefined + } + const browserPageId = initializer.identity.browserPageId + return { + isRetired: () => initializer.cleaned === true, + cancel: (close) => this.operations.get(browserPageId) === initializer && this.cancel(close), + close: async (signal) => { + assertBrowserClientPageCommandNotAborted(signal) + await this.cleanupInitializer(initializer) + if (this.operations.get(browserPageId) === initializer) { + this.operations.delete(browserPageId) + } + return { status: 'completed' } + }, + release: () => { + if (this.operations.get(browserPageId) === initializer) { + this.operations.delete(browserPageId) + } + } + } + } + + retirePage(browserPageId: string, pageHostGeneration: number): Promise | undefined { + const initializer = this.operations.get(browserPageId) + if (initializer?.identity.pageHostGeneration !== pageHostGeneration) { + return undefined + } + initializer.controller?.abort() + return this.cleanupInitializer(initializer).then(() => true) + } + + cancel(event: BrowserClientHostCommandEvent): boolean { + const initializer = this.operations.get(event.browserPageId) + if ( + event.command.type !== 'closePage' || + !initializer || + !sameBrowserClientPageAuthority(initializer.identity, event.command.targetAuthority) || + event.pageHostGeneration !== initializer.identity.pageHostGeneration + ) { + return false + } + initializer.controller?.abort() + return true + } + + cancelAll(): void { + for (const initializer of this.operations.values()) { + initializer.controller?.abort() + } + } + + async initialize(event: BrowserClientHostCommandEvent, signal: AbortSignal): Promise { + const initializer = this.operations.get(event.browserPageId) + const page = this.pages.get(event.browserPageId) + const controller = initializer?.controller + const bootstrap = initializer?.event + if ( + event.command.type !== 'navigate' || + bootstrap?.command.type !== 'createPage' || + !initializer || + !controller || + !page || + initializer.page !== page || + !sameBrowserClientPageAuthority(initializer.identity, event) || + event.commandSequence !== bootstrap.commandSequence + 1 || + normalizeBrowserNavigationUrl(event.command.url) !== + normalizeBrowserNavigationUrl(bootstrap.command.initialUrl ?? '') || + !this.routeWebContents.initializeGuest + ) { + throw new BrowserClientPageCommandError('browser_client_page_initialization_stale') + } + const combined = AbortSignal.any([signal, controller.signal]) + try { + const url = await this.routeWebContents.initializeGuest( + page.lifecycleClaim, + event.command.url, + combined + ) + assertBrowserClientPageCommandNotAborted(combined) + if ( + this.operations.get(event.browserPageId) !== initializer || + this.pages.get(event.browserPageId) !== page || + !page.lifecycleClaim.isCurrent() + ) { + throw new BrowserClientPageCommandError('browser_client_page_initialization_stale') + } + page.inventory = Object.freeze({ + ...updateBrowserClientPageInventoryCurrentUrl(page.inventory, url), + state: 'active' + }) + this.operations.delete(event.browserPageId) + return url + } catch (error) { + initializer.controller?.abort() + await this.cleanupInitializer(initializer) + throw error + } + } + + private async cleanupInitializer(initializer: PageInitialization): Promise { + await initializer.creation?.catch(() => undefined) + if (initializer.failedCleanup) { + await initializer.failedCleanup() + initializer.failedCleanup = undefined + } + const page = initializer.page + if (page) { + page.retiring = this.cleanupPage(page) + await page.retiring + if (this.pages.get(initializer.identity.browserPageId) === page) { + this.pages.delete(initializer.identity.browserPageId) + } + } + const failed = this.failedPages.get(initializer.identity.browserPageId) + if (failed && sameBrowserClientPageAuthority(failed, initializer.identity)) { + this.failedPages.delete(initializer.identity.browserPageId) + } + initializer.cleaned = true + initializer.event = undefined + initializer.controller = undefined + initializer.creation = undefined + initializer.page = undefined + } + + async close(event: BrowserClientHostCommandEvent, signal: AbortSignal): Promise { + const initializer = this.operations.get(event.browserPageId) + assertBrowserClientPageCommandNotAborted(signal) + if (!initializer || !this.cancel(event)) { + throw new BrowserClientPageCommandError('browser_client_page_reconciliation_authority_stale') + } + await this.cleanupInitializer(initializer) + if (this.operations.get(event.browserPageId) === initializer) { + this.operations.delete(event.browserPageId) + } + } + + async closeAll(): Promise { + const failures: unknown[] = [] + for (const [id, initializer] of this.operations) { + try { + await this.cleanupInitializer(initializer) + if (this.operations.get(id) === initializer) { + this.operations.delete(id) + } + } catch (error) { + failures.push(error) + } + } + if (failures.length) { + throw new AggregateError(failures, 'Browser client initializers cleanup failed') + } + } +} diff --git a/src/main/browser/client-pages/lifecycle/browser-client-page-availability.ts b/src/main/browser/client-pages/lifecycle/browser-client-page-availability.ts index 983d24855ed..1b65f684db8 100644 --- a/src/main/browser/client-pages/lifecycle/browser-client-page-availability.ts +++ b/src/main/browser/client-pages/lifecycle/browser-client-page-availability.ts @@ -9,13 +9,10 @@ export function markBrowserClientPageUnavailable( ): boolean { const page = pages.get(registration.browserPageId) if ( - !page || + !matchesBrowserClientRetainedPage(pages, page, registration) || page.retiring || page.reconciling || - page.inventory.state !== 'active' || - page.generation !== registration.pageHostGeneration || - page.registration.webContentsId !== registration.webContentsId || - page.registration.rendererWebContentsId !== registration.rendererWebContentsId + page.inventory.state !== 'active' ) { return false } @@ -27,7 +24,8 @@ export function markBrowserClientPageUnavailable( export function publishBrowserClientRetainedPage( dependencies: BrowserClientPageCommandExecutorDependencies, pages: Map, - page: BrowserClientRetainedPage + page: BrowserClientRetainedPage, + onInitializationUnavailable?: () => void ): void { const previous = pages.get(page.inventory.browserPageId) previous?.releaseAvailabilityWatch?.() @@ -36,10 +34,34 @@ export function publishBrowserClientRetainedPage( } page.releaseAvailabilityWatch = dependencies.routeWebContents.watchPageAvailability?.( page.inventory.browserPageId, - (registration) => - markBrowserClientPageUnavailable(pages, registration, (browserPageId, generation) => + (registration) => { + if (!matchesBrowserClientRetainedPage(pages, page, registration)) { + return false + } + if (page.inventory.state === 'outcomeUnknown') { + onInitializationUnavailable?.() + } + return markBrowserClientPageUnavailable(pages, registration, (browserPageId, generation) => dependencies.onPageUnavailable?.(browserPageId, generation) ) + } ) pages.set(page.inventory.browserPageId, page) } + +function matchesBrowserClientRetainedPage( + pages: ReadonlyMap, + page: BrowserClientRetainedPage | undefined, + registration: BrowserRoutePageGuestIdentity +): page is BrowserClientRetainedPage { + return Boolean( + page && + pages.get(registration.browserPageId) === page && + page.registration.partition === registration.partition && + page.registration.browserPageId === registration.browserPageId && + page.generation === registration.pageHostGeneration && + page.registration.pageHostGeneration === registration.pageHostGeneration && + page.registration.rendererWebContentsId === registration.rendererWebContentsId && + page.registration.webContentsId === registration.webContentsId + ) +} diff --git a/src/main/browser/client-pages/lifecycle/browser-client-page-cleanup.ts b/src/main/browser/client-pages/lifecycle/browser-client-page-cleanup.ts index f0dddd8ce5b..3fae8511c50 100644 --- a/src/main/browser/client-pages/lifecycle/browser-client-page-cleanup.ts +++ b/src/main/browser/client-pages/lifecycle/browser-client-page-cleanup.ts @@ -7,7 +7,7 @@ import type { BrowserRouteWebContentsRegistry } from '../../routing/guest-author import { browserClientPageIdentity } from '../renderer/browser-client-page-renderer-contract' import type { BrowserClientRetainedPage } from './browser-client-page-retained-state' -export async function cleanupBrowserClientPage( +export function createBrowserClientPageCleanup( routeWebContents: Pick, target: { guestMayExist: boolean @@ -17,42 +17,46 @@ export async function cleanupBrowserClientPage( routeSession: BrowserRouteSessionHandle | null route: BrowserClientPageNetworkRoute | null } -): Promise { - const failures: unknown[] = [] - const guestDestruction = target.lifecycleClaim?.whenDestroyed ?? null - let guestDestroyed = !target.guestMayExist - if (target.guestMayExist && !target.lifecycleClaim) { - failures.push(new Error('Browser client page guest destruction was not observable')) - } - if (target.lifecycleClaim) { - try { - if (!routeWebContents.beginGuestRetirement(target.lifecycleClaim)) { - failures.push(new Error('Browser client page guest retirement was not admitted')) - } - } catch (error) { - failures.push(error) +): BrowserClientPageCleanup { + const claim = target.lifecycleClaim + const beginRetirement = cleanupStep(() => { + if (target.guestMayExist && !claim) { + throw new Error('Browser client page guest destruction was not observable') } - } - const renderer = target.renderer - if (renderer) { - for (const rendererPage of target.rendererPages) { - await collectCleanupFailure(() => renderer.retirePage(rendererPage), failures) + if (claim && !routeWebContents.beginGuestRetirement(claim)) { + throw new Error('Browser client page guest retirement was not admitted') } - } - if (guestDestruction) { - guestDestroyed = await collectCleanupFailure(() => guestDestruction, failures) - } - const routeSession = target.routeSession - if (routeSession && guestDestroyed) { - await collectCleanupFailure(() => routeSession.release(), failures) - } - const route = target.route - if (route && guestDestroyed) { - await collectCleanupFailure(() => route.release(), failures) - } - if (failures.length > 0) { - throw new AggregateError(failures, 'Browser client page cleanup failed') - } + }) + const retireRenderer = target.rendererPages.map((identity) => + cleanupStep(() => target.renderer?.retirePage(identity)) + ) + const joinDestruction = cleanupStep(() => claim?.whenDestroyed, false) + // Resource releases may consume their reference before rejecting; preserve that exact result. + const releaseSession = cleanupStep(() => target.routeSession?.release(), false) + const releaseRoute = cleanupStep(() => target.route?.release(), false) + const cleanup = joinCleanupAttempt(async () => { + const failures: unknown[] = [] + await collectCleanupFailure(beginRetirement, failures) + for (const retire of retireRenderer) { + await collectCleanupFailure(retire, failures) + } + const guestDestroyed = + !target.guestMayExist || (claim && (await collectCleanupFailure(joinDestruction, failures))) + if (guestDestroyed) { + await collectCleanupFailure(releaseSession, failures) + await collectCleanupFailure(releaseRoute, failures) + } + if (failures.length > 0) { + throw new AggregateError(failures, 'Browser client page cleanup failed') + } + }) + return Object.assign(cleanup, { + nativeNetworkRetired: () => + beginRetirement.succeeded() && + (!target.guestMayExist || joinDestruction.succeeded()) && + releaseSession.succeeded() && + releaseRoute.succeeded() + }) } export async function cleanupRetainedBrowserClientPage( @@ -68,41 +72,76 @@ export async function cleanupRetainedBrowserClientPage( }, previousRendererPage?: Readonly ): Promise { - const failures: unknown[] = [] - try { - await dependencies.retireAutomation({ - browserPageId: page.inventory.browserPageId, - pageHostGeneration: page.generation, - registration: page.registration - }) - } catch (error) { - failures.push(error) - } - const currentRendererPage = browserClientPageIdentity( - page.registration, - page.registration.partition - ) - try { - await cleanupBrowserClientPage(dependencies.routeWebContents, { + if (!page.cleanup) { + const { registration, generation, inventory, lifecycleClaim, renderer, route, routeSession } = + page + const retireAutomation = cleanupStep(() => + dependencies.retireAutomation({ + browserPageId: inventory.browserPageId, + pageHostGeneration: generation, + registration + }) + ) + const currentRendererPage = browserClientPageIdentity(registration, registration.partition) + const cleanupNative = createBrowserClientPageCleanup(dependencies.routeWebContents, { guestMayExist: true, - lifecycleClaim: page.lifecycleClaim, - renderer: browserClientPageRendererIsCurrent(page.renderer) ? page.renderer : null, + lifecycleClaim, + renderer: browserClientPageRendererIsCurrent(renderer) ? renderer : null, rendererPages: previousRendererPage ? [currentRendererPage, previousRendererPage] : [currentRendererPage], - route: page.route, - routeSession: page.routeSession + route, + routeSession }) - } catch (error) { - failures.push(error) + const releaseUploads = cleanupStep(() => dependencies.releaseUploadStaging?.(), false) + page.cleanup = Object.assign( + joinCleanupAttempt(async () => { + const failures: unknown[] = [] + await collectCleanupFailure(retireAutomation, failures) + await collectCleanupFailure(cleanupNative, failures) + await collectCleanupFailure(releaseUploads, failures) + if (failures.length > 0) { + throw new AggregateError(failures, 'Browser client page cleanup failed') + } + }), + { nativeNetworkRetired: cleanupNative.nativeNetworkRetired } + ) } - // Remove upload staging after guest teardown; open temp files must not block other cleanup. - await collectCleanupFailure(() => dependencies.releaseUploadStaging?.(), failures) - if (failures.length > 0) { - throw new AggregateError(failures, 'Browser client page cleanup failed') + page.retiring = page.cleanup() + await page.retiring +} + +function joinCleanupAttempt(run: () => Promise): () => Promise { + let attempt: Promise | undefined + return () => { + attempt ??= run().finally(() => { + attempt = undefined + }) + return attempt } } +type CleanupStep = (() => Promise) & { succeeded(): boolean } +export type BrowserClientPageCleanup = (() => Promise) & { nativeNetworkRetired(): boolean } + +function cleanupStep(run: () => void | Promise, retryable = true): CleanupStep { + let succeeded = false + let result: Promise | undefined + const step = async (): Promise => { + result ??= Promise.resolve().then(run) + try { + await result + succeeded = true + } catch (error) { + if (retryable) { + result = undefined + } + throw error + } + } + return Object.assign(step, { succeeded: () => succeeded }) +} + function browserClientPageRendererIsCurrent(renderer: BrowserClientPageRenderer): boolean { try { return renderer.isCurrent() diff --git a/src/main/browser/client-pages/lifecycle/browser-client-page-creation.ts b/src/main/browser/client-pages/lifecycle/browser-client-page-creation.ts index 63d6a2ce0d4..c7c1660e396 100644 --- a/src/main/browser/client-pages/lifecycle/browser-client-page-creation.ts +++ b/src/main/browser/client-pages/lifecycle/browser-client-page-creation.ts @@ -1,5 +1,5 @@ import type { BrowserClientHostCommandEvent } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' -import { cleanupBrowserClientPage } from './browser-client-page-cleanup' +import { createBrowserClientPageCleanup } from './browser-client-page-cleanup' import type { BrowserClientPageNetworkRoute as RetainedNetworkRoute } from '../../network/client-routes/browser-client-page-network-route' import type { BrowserClientPageRenderer } from '../renderer/browser-client-page-renderer-contract' import { @@ -31,6 +31,7 @@ type BrowserClientPageCreationDependencies = BrowserClientPageAuthorityIdentity routeSessions: Pick routeWebContents: BrowserClientPageLifecycleRegistry guestBinding: BrowserClientPageGuestBinding + retainFailedCleanup?: (cleanup: () => Promise) => void } export async function createReservedBrowserClientPage( @@ -56,7 +57,9 @@ export async function createReservedBrowserClientPage( if (route.key !== event.command.executionHostKey) { throw new BrowserClientPageCommandError('browser_client_page_execution_host_stale') } - await dependencies.retireSupersededExecutionHostPages(route) + if (event.command.initialUrl === undefined) { + await dependencies.retireSupersededExecutionHostPages(route) + } assertBrowserClientPageCommandNotAborted(signal) assertAvailable() renderer = dependencies.selectRenderer() @@ -120,7 +123,10 @@ export async function createReservedBrowserClientPage( assertAvailable() const retainedPage = { generation: event.pageHostGeneration, - inventory: createBrowserClientPageInventory(event, 'active'), + inventory: createBrowserClientPageInventory( + event, + event.command.initialUrl === undefined ? 'active' : 'outcomeUnknown' + ), registration, lifecycleClaim, renderer, @@ -135,18 +141,19 @@ export async function createReservedBrowserClientPage( if (guestBound && registration) { dependencies.guestBinding.release(registration) } + const target = { + guestMayExist: mountAttempted, + lifecycleClaim, + renderer, + rendererPages: routeSession ? [browserClientPageIdentity(event, routeSession.partition)] : [], + route, + routeSession + } + const cleanup = createBrowserClientPageCleanup(dependencies.routeWebContents, target) try { - await cleanupBrowserClientPage(dependencies.routeWebContents, { - guestMayExist: mountAttempted, - lifecycleClaim, - renderer, - rendererPages: routeSession - ? [browserClientPageIdentity(event, routeSession.partition)] - : [], - route, - routeSession - }) + await cleanup() } catch (cleanupError) { + dependencies.retainFailedCleanup?.(cleanup) throw new BrowserClientPageCommandError('browser_client_page_cleanup_failed', { cause: new AggregateError([error, cleanupError], 'Browser client page creation failed') }) diff --git a/src/main/browser/client-pages/lifecycle/browser-client-page-execution-host-supersession.test.ts b/src/main/browser/client-pages/lifecycle/browser-client-page-execution-host-supersession.test.ts index f60d7db724d..5fd047bfd91 100644 --- a/src/main/browser/client-pages/lifecycle/browser-client-page-execution-host-supersession.test.ts +++ b/src/main/browser/client-pages/lifecycle/browser-client-page-execution-host-supersession.test.ts @@ -28,7 +28,8 @@ const storageIdentityByKey: Record = { [OTHER_HOST]: OTHER_HOST_STORAGE } -function createHarness() { +function createHarness(options: { failedRouteRelease?: string } = {}) { + const retireRenderer = vi.fn(async () => {}) const releasedRoutes: string[] = [] const proxyRulesByPartition = new Map() const sessionsByPartition = new Map() @@ -79,6 +80,9 @@ function createHarness() { proxyEndpoint: { host: '127.0.0.1' as const, port: proxyPortByKey[executionHostKey] }, release: vi.fn(() => { releasedRoutes.push(executionHostKey) + if (options.failedRouteRelease === executionHostKey) { + throw new Error('route-release-failed') + } }) })), selectRenderer: vi.fn(() => ({ @@ -88,7 +92,7 @@ function createHarness() { nextWebContentsId += 1 return { webContentsId: nextWebContentsId } }), - retirePage: vi.fn(async () => {}) + retirePage: retireRenderer })), routeSessions, executeAutomation: vi.fn(async () => ({})), @@ -108,6 +112,8 @@ function createHarness() { return { executor: new BrowserClientPageCommandExecutor(dependencies), + dependencies, + retireRenderer, proxyRulesFor: (storageIdentity: string) => proxyRulesByPartition.get(`persist:${storageIdentity}`), releasedRoutes @@ -181,3 +187,58 @@ describe('client-hosted pages across an execution host generation change', () => expect(harness.proxyRulesFor(SSH_HOST_STORAGE)).toBe('socks5://127.0.0.1:43124') }) }) + +it('allows a different legacy page to retarget only after exact native and network retirement despite a missing renderer acknowledgement', async () => { + const h = createHarness() + const destroyed = Promise.withResolvers() + let pending: ReturnType | undefined + h.dependencies.routeWebContents.claimGuestLifecycle.mockImplementationOnce((registration) => + createLifecycleClaim(registration, destroyed.promise) + ) + h.retireRenderer.mockRejectedValue(new Error('renderer-ack-unavailable')) + try { + await createPage(h.executor, 'old', GENERATION_1) + pending = createPage(h.executor, 'new', GENERATION_2) + await vi.waitFor(() => expect(h.retireRenderer).toHaveBeenCalledTimes(1)) + expect(h.proxyRulesFor(SSH_HOST_STORAGE)).toBe('socks5://127.0.0.1:43123') + expect( + h.executor.snapshotPageInventory().filter((p) => p.browserPageId === 'old') + ).toMatchObject([{ browserPageId: 'old', state: 'outcomeUnknown' }]) + destroyed.resolve() + expect(await pending).toEqual({ status: 'completed' }) + expect(h.proxyRulesFor(SSH_HOST_STORAGE)).toBe('socks5://127.0.0.1:43124') + expect(h.executor.snapshotPageInventory()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ browserPageId: 'old', state: 'outcomeUnknown' }), + expect.objectContaining({ browserPageId: 'new', state: 'active' }) + ]) + ) + expect(h.releasedRoutes).toEqual([GENERATION_1]) + await expect(h.executor.retirePage('old', 7)).rejects.toThrow( + 'Browser client page cleanup failed' + ) + expect(h.releasedRoutes).toEqual([GENERATION_1]) + expect(await createPage(h.executor, 'old', GENERATION_2)).toMatchObject({ status: 'failed' }) + expect(await createPage(h.executor, 'sibling', GENERATION_2)).toEqual({ status: 'completed' }) + expect(h.retireRenderer).toHaveBeenCalledTimes(2) + } finally { + destroyed.resolve() + h.retireRenderer.mockResolvedValue(undefined) + await pending + await h.executor.close() + } +}) + +it('does not retarget a legacy session after the original route release failed', async () => { + const h = createHarness({ failedRouteRelease: GENERATION_1 }) + await createPage(h.executor, 'old', GENERATION_1) + expect(await createPage(h.executor, 'new', GENERATION_2)).toMatchObject({ status: 'failed' }) + expect(await createPage(h.executor, 'retry', GENERATION_2)).toMatchObject({ status: 'failed' }) + expect(h.proxyRulesFor(SSH_HOST_STORAGE)).not.toBe('socks5://127.0.0.1:43124') + expect(h.releasedRoutes.filter((key) => key === GENERATION_1)).toEqual([GENERATION_1]) + expect(h.executor.snapshotPageInventory()).toMatchObject([ + { browserPageId: 'old', state: 'outcomeUnknown' } + ]) + await expect(h.executor.close()).rejects.toThrow('Browser client page executor cleanup failed') + expect(h.releasedRoutes.filter((key) => key === GENERATION_1)).toEqual([GENERATION_1]) +}) diff --git a/src/main/browser/client-pages/lifecycle/browser-client-page-execution-host-supersession.ts b/src/main/browser/client-pages/lifecycle/browser-client-page-execution-host-supersession.ts index 8e552551e7e..bcbc21b8662 100644 --- a/src/main/browser/client-pages/lifecycle/browser-client-page-execution-host-supersession.ts +++ b/src/main/browser/client-pages/lifecycle/browser-client-page-execution-host-supersession.ts @@ -15,14 +15,17 @@ export async function retireSupersededExecutionHostPages( for (const page of retained) { if ( page.route.executionHostIdentity !== route.executionHostIdentity || - page.route.key === route.key + page.route.key === route.key || + page.cleanup?.nativeNetworkRetired?.() ) { continue } try { await retirePage(page.inventory.browserPageId, page.generation) } catch (error) { - failures.push(error) + if (!page.cleanup?.nativeNetworkRetired?.()) { + failures.push(error) + } } } if (failures.length > 0) { diff --git a/src/main/browser/client-pages/lifecycle/browser-client-page-inventory.ts b/src/main/browser/client-pages/lifecycle/browser-client-page-inventory.ts index a25911e50a4..1f949ed32f6 100644 --- a/src/main/browser/client-pages/lifecycle/browser-client-page-inventory.ts +++ b/src/main/browser/client-pages/lifecycle/browser-client-page-inventory.ts @@ -29,6 +29,10 @@ export function createBrowserClientPageInventory( browserProfileId: event.command.browserProfileId, executionHostKey: event.command.executionHostKey, state, + ...(event.command.initialUrl === undefined || + event.command.initialUrl.length > BROWSER_CLIENT_HOST_PAGE_INVENTORY_URL_MAX_LENGTH + ? {} + : { currentUrl: event.command.initialUrl }), ...(event.command.workspaceId ? { workspaceId: event.command.workspaceId } : {}) }) } diff --git a/src/main/browser/client-pages/lifecycle/browser-client-page-profile-switch.test.ts b/src/main/browser/client-pages/lifecycle/browser-client-page-profile-switch.test.ts index 284c7ec9833..f53e97b29ee 100644 --- a/src/main/browser/client-pages/lifecycle/browser-client-page-profile-switch.test.ts +++ b/src/main/browser/client-pages/lifecycle/browser-client-page-profile-switch.test.ts @@ -1,3 +1,5 @@ +import { BrowserClientUploadStaging } from '../../client-hosts/file-transfers/browser-client-upload-staging' +import type { BrowserClientPageRendererIdentity } from '../../../../shared/browser/client-hosting/browser-client-page-renderer-protocol' import { BrowserRoutePageAvailability } from '../../routing/guest-authority/browser-route-page-availability' import { describe, expect, it, vi } from 'vitest' import { @@ -49,6 +51,93 @@ async function fixture( } describe('staged client page profile switching', () => { + it('finishes failed old-generation renderer cleanup on teardown without repeating its releases', async () => { + const h = await fixture() + h.renderer.retirePage.mockRejectedValueOnce(new Error('old-retirement-reply-lost')) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + expect(await h.executor.handle(switchCommand(), new AbortController().signal)).toMatchObject({ + status: 'completed' + }) + expect(h.executor.snapshotPageInventory()).toMatchObject([ + { pageHostGeneration: 8, state: 'active' } + ]) + expect(h.routeSession.release).toHaveBeenCalledTimes(1) + await h.executor.close() + expect(h.renderer.retirePage).toHaveBeenCalledTimes(3) + expect(h.routeSession.release).toHaveBeenCalledTimes(1) + } finally { + warning.mockRestore() + } + }) + + it('finishes independent pages, retained profile cleanup and uploads after one initializer cleanup fails', async () => { + const h = await fixture() + const signal = new AbortController().signal + h.renderer.retirePage.mockRejectedValueOnce(new Error('old-profile-retirement')) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const removeDirectory = vi.fn(async () => {}) + const uploads = new BrowserClientUploadStaging('/virtual/pr41490-uploads', { + mkdir: async () => {}, + writeFile: async () => {}, + removeDirectory, + removeDirectorySync: () => {} + }) + Object.assign(h.dependencies, { uploadStaging: uploads }) + try { + expect(await h.executor.handle(switchCommand(), signal)).toMatchObject({ + status: 'completed' + }) + for (const [index, id] of ['initializer-first', 'initializer-second'].entries()) { + expect( + await h.executor.handle( + createCommand('createPage', { + browserPageId: id, + pageHostGeneration: 9 + index, + command: { + type: 'createPage', + browserProfileId: 'remote-isolated', + executionHostKey: 'execution-host-a', + initialUrl: 'https://example.internal/held' + } + }), + signal + ) + ).toMatchObject({ status: 'completed' }) + } + await uploads.stage({ + browserPageId: 'independent-upload', + pageHostGeneration: 1, + files: [{ remotePath: 'proof.txt', contents: Buffer.from('proof') }] + }) + const retired: string[] = [] + Object.assign(h.renderer, { + retirePage: vi.fn(async (page: BrowserClientPageRendererIdentity) => { + retired.push(`${page.browserPageId}:${page.pageHostGeneration}`) + if (page.browserPageId === 'initializer-first') { + throw new Error('first-cleanup-failed') + } + }) + }) + await expect(h.executor.close()).rejects.toThrow( + 'Browser client page executor cleanup failed' + ) + expect(retired).toEqual([ + 'initializer-first:9', + 'initializer-second:10', + 'page-a:7', + 'page-a:8' + ]) + expect(removeDirectory).toHaveBeenCalledOnce() + await expect(h.executor.close()).rejects.toThrow( + 'Browser client page executor cleanup failed' + ) + expect(retired).toHaveLength(4) + } finally { + warning.mockRestore() + } + }) + it('restores the old guest when replacement navigation fails', async () => { const h = await fixture() await h.executor.handle( diff --git a/src/main/browser/client-pages/lifecycle/browser-client-page-profile-switch.ts b/src/main/browser/client-pages/lifecycle/browser-client-page-profile-switch.ts index 7951215a28b..71b472f3c95 100644 --- a/src/main/browser/client-pages/lifecycle/browser-client-page-profile-switch.ts +++ b/src/main/browser/client-pages/lifecycle/browser-client-page-profile-switch.ts @@ -59,6 +59,7 @@ export async function switchBrowserClientPageProfile(input: { } } let staged: BrowserClientRetainedPage | undefined + let stagedCleanup: (() => Promise) | undefined let bindingAttempted = false old.reconciling = true switches.stagedPages.set(event.browserPageId, null) @@ -66,6 +67,9 @@ export async function switchBrowserClientPageProfile(input: { await createReservedBrowserClientPage( { ...dependencies, + retainFailedCleanup: (cleanup) => { + stagedCleanup = cleanup + }, // A profile switch cannot change the execution route or supersede the old guest. retireSupersededExecutionHostPages: async () => {}, guestBinding: { bind: () => {}, release: () => {} } @@ -128,14 +132,18 @@ export async function switchBrowserClientPageProfile(input: { }) } if (staged) { - const cleanup = input.cleanup(staged, false) + const page = staged + const cleanup = () => input.cleanup(page, false) switches.retainedCleanups.add(cleanup) - await cleanup + await cleanup() switches.retainedCleanups.delete(cleanup) + } else if (stagedCleanup) { + switches.retainedCleanups.add(stagedCleanup) } else if (isBrowserClientPageCleanupFailure(error)) { // Retain staging failures so close() reports unreleased resources. - const unresolved = Promise.reject(error) - unresolved.catch(() => {}) + const unresolved = async () => { + throw error + } switches.retainedCleanups.add(unresolved) } } finally { @@ -156,7 +164,7 @@ export async function switchBrowserClientPageProfile(input: { } export class BrowserClientProfileSwitches { - readonly retainedCleanups = new Set>() + readonly retainedCleanups = new Set<() => Promise>() /** Null reserves a switch before its staged guest mounts. */ readonly stagedPages = new Map() @@ -169,10 +177,10 @@ export class BrowserClientProfileSwitches { page: BrowserClientRetainedPage, cleanupPage: (page: BrowserClientRetainedPage) => Promise ): Promise { - const cleanup = cleanupPage(page) + const cleanup = () => cleanupPage(page) this.retainedCleanups.add(cleanup) try { - await cleanup + await cleanup() this.retainedCleanups.delete(cleanup) } catch (error) { console.warn('[browser-client-page] Replaced guest cleanup remains unresolved', error) diff --git a/src/main/browser/client-pages/lifecycle/browser-client-page-reconciliation.ts b/src/main/browser/client-pages/lifecycle/browser-client-page-reconciliation.ts index e46fb501b5c..3d23380bb42 100644 --- a/src/main/browser/client-pages/lifecycle/browser-client-page-reconciliation.ts +++ b/src/main/browser/client-pages/lifecycle/browser-client-page-reconciliation.ts @@ -154,7 +154,6 @@ async function closeReconciledPage( const page = context.pages.get(event.browserPageId) if ( !page || - page.retiring || page.reconciling || event.pageHostGeneration !== event.command.targetAuthority.pageHostGeneration || !sameBrowserClientPageAuthority(page.inventory, event.command.targetAuthority) @@ -203,15 +202,14 @@ async function failClosedRekeyedPage( previousRendererPage?: Readonly ): Promise { page.reconciling = false - page.retiring ??= context.cleanupPage(page, previousRendererPage) + page.retiring = context.cleanupPage(page, previousRendererPage) try { await page.retiring if (context.pages.get(page.inventory.browserPageId) === page) { context.pages.delete(page.inventory.browserPageId) } } catch (cleanupError) { - context.pages.delete(page.inventory.browserPageId) - context.failedPages.set(page.inventory.browserPageId, page.inventory) + page.inventory = Object.freeze({ ...page.inventory, state: 'outcomeUnknown' }) throw new BrowserClientPageCommandError('browser_client_page_cleanup_failed', { cause: new AggregateError([error, cleanupError], 'Browser page reclaim failed') }) diff --git a/src/main/browser/client-pages/lifecycle/browser-client-page-retained-state.ts b/src/main/browser/client-pages/lifecycle/browser-client-page-retained-state.ts index 4669535d9a3..8a43d3a2bc6 100644 --- a/src/main/browser/client-pages/lifecycle/browser-client-page-retained-state.ts +++ b/src/main/browser/client-pages/lifecycle/browser-client-page-retained-state.ts @@ -18,7 +18,7 @@ export type BrowserClientPageLifecycleRegistry = Pick< | 'beginGuestRetirement' > & Partial> & - Partial> + Partial> export type BrowserClientRetainedPage = { generation: number @@ -28,6 +28,7 @@ export type BrowserClientRetainedPage = { renderer: BrowserClientPageRenderer route: BrowserClientPageNetworkRoute routeSession: BrowserRouteSessionHandle + cleanup?: (() => Promise) & { nativeNetworkRetired?(): boolean } retiring: Promise | null reconciling: boolean releaseAvailabilityWatch?: () => void @@ -57,7 +58,6 @@ export function forgetBrowserClientRetainedPage( export async function retireBrowserClientRetainedPage( pages: Map, - failedPages: Map, browserPageId: string, pageHostGeneration: number, cleanupPage: (page: BrowserClientRetainedPage) => Promise @@ -66,12 +66,11 @@ export async function retireBrowserClientRetainedPage( if (!page || page.generation !== pageHostGeneration) { return false } - page.retiring ??= cleanupPage(page) + page.retiring = cleanupPage(page) try { await page.retiring } catch (error) { - forgetBrowserClientRetainedPage(pages, browserPageId, page) - failedPages.set(browserPageId, Object.freeze({ ...page.inventory, state: 'outcomeUnknown' })) + page.inventory = Object.freeze({ ...page.inventory, state: 'outcomeUnknown' }) throw error } forgetBrowserClientRetainedPage(pages, browserPageId, page) diff --git a/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-dispatch.test.ts b/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-dispatch.test.ts new file mode 100644 index 00000000000..df2e0bc987a --- /dev/null +++ b/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-dispatch.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi } from 'vitest' +import { + forwardGuestShortcutInput, + type GuestShortcutForwardContext +} from './browser-guest-shortcut-dispatch' + +const input = { + key: 'Y', + code: 'KeyY', + meta: process.platform === 'darwin', + control: process.platform !== 'darwin', + shift: true +} +const primaryDoubleTap = process.platform === 'darwin' ? 'Cmd' : 'Ctrl' + +function fixture(local = true) { + const send = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dispatch only uses send on this renderer. + const renderer = { send } as unknown as Electron.WebContents + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dispatch only calls preventDefault on this event. + const event = { preventDefault: vi.fn() } as unknown as Electron.Event + const context: GuestShortcutForwardContext & { isLocalRenderer: () => boolean } = { + browserTabId: 'page', + resolveRenderer: () => renderer, + isLocalRenderer: () => local, + getKeybindings: () => ({ 'view.plugins': ['Mod+Shift+Y'] }), + forwardBrowserPageZoom: vi.fn() + } + return { context, event, send } +} + +function withPlatform(platform: NodeJS.Platform, run: () => void): void { + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform') + if (!descriptor) { + throw new Error('Missing process.platform descriptor') + } + Object.defineProperty(process, 'platform', { ...descriptor, value: platform }) + try { + run() + } finally { + Object.defineProperty(process, 'platform', descriptor) + } +} + +describe('Plugins shortcut from a browser guest', () => { + it('replays the configured chord once, consuming repeats', () => { + const { context, event, send } = fixture() + expect(forwardGuestShortcutInput(context, event, input)).toBe(true) + expect(send).toHaveBeenCalledWith('ui:browserPageTabShortcut', expect.objectContaining(input)) + expect(forwardGuestShortcutInput(context, event, { ...input, isAutoRepeat: true })).toBe(true) + expect(send).toHaveBeenCalledOnce() + }) + + it('leaves disabled or remapped chords with the page', () => { + const { context, event, send } = fixture() + context.getKeybindings = () => ({ 'view.plugins': [] }) + expect(forwardGuestShortcutInput(context, event, input)).toBe(false) + context.getKeybindings = () => ({ 'view.plugins': ['Mod+Shift+Z'] }) + expect(forwardGuestShortcutInput(context, event, input)).toBe(false) + expect(send).not.toHaveBeenCalled() + }) + + it('leaves text-producing custom bindings to the guest', () => { + const { context, event, send } = fixture() + context.getKeybindings = () => ({ 'view.plugins': ['Alt+A'] }) + expect(forwardGuestShortcutInput(context, event, { key: 'å', code: 'KeyA', alt: true })).toBe( + false + ) + expect(forwardGuestShortcutInput(context, event, { key: 'a', code: 'KeyA', alt: true })).toBe( + false + ) + expect(event.preventDefault).not.toHaveBeenCalled() + expect(send).not.toHaveBeenCalled() + }) + + it('leaves an explicit AltGraph chord in the guest', () => { + withPlatform('darwin', () => { + const { context, event, send } = fixture() + context.getKeybindings = () => ({ 'view.plugins': ['Cmd+Alt+Q'] }) + const input = { key: 'q', code: 'KeyQ', meta: true, alt: true } + expect(forwardGuestShortcutInput(context, event, { ...input, altGraph: true })).toBe(false) + expect( + forwardGuestShortcutInput(context, event, { + ...input, + getModifierState: (key) => key === 'AltGraph' + }) + ).toBe(false) + expect(event.preventDefault).not.toHaveBeenCalled() + expect(send).not.toHaveBeenCalled() + }) + }) + + it.each(['win32', 'linux'] as const)( + 'leaves Electron Ctrl+Alt input to the page on %s', + (platform) => { + withPlatform(platform, () => { + const { context, event, send } = fixture() + context.getKeybindings = () => ({ 'view.plugins': ['Ctrl+Alt+Q'] }) + const input = { key: 'q', code: 'KeyQ', control: true, alt: true } + expect(forwardGuestShortcutInput(context, event, { ...input, altGraph: true })).toBe(false) + expect(forwardGuestShortcutInput(context, event, input)).toBe(false) + expect(event.preventDefault).not.toHaveBeenCalled() + expect(send).not.toHaveBeenCalled() + }) + } + ) + + it('opens from the default macOS chord when Option turns X into a symbol, not when it is a dead key', () => { + withPlatform('darwin', () => { + const { context, event, send } = fixture() + context.getKeybindings = () => ({}) + const chord = { code: 'KeyX', meta: true, alt: true } + expect(forwardGuestShortcutInput(context, event, { ...chord, key: 'Dead' })).toBe(false) + expect(send).not.toHaveBeenCalled() + expect(forwardGuestShortcutInput(context, event, { ...chord, key: '≈' })).toBe(true) + expect(send).toHaveBeenCalledWith( + 'ui:browserPageTabShortcut', + expect.objectContaining({ key: '≈', code: 'KeyX', meta: true, alt: true }) + ) + }) + }) + + it('follows the typed letter on a German layout where Y and Z swap keys on Linux', () => { + withPlatform('linux', () => { + const { context, event, send } = fixture() + context.getKeybindings = () => ({}) + const chord = { control: true, shift: true } + expect(forwardGuestShortcutInput(context, event, { ...chord, key: 'Z', code: 'KeyY' })).toBe( + false + ) + expect(send).not.toHaveBeenCalled() + expect(forwardGuestShortcutInput(context, event, { ...chord, key: 'Y', code: 'KeyZ' })).toBe( + true + ) + expect(send).toHaveBeenCalledOnce() + }) + }) + + it('replays a configured modifier double tap', () => { + const { context, event, send } = fixture() + context.getKeybindings = () => ({ 'view.plugins': ['DoubleTap+Mod'] }) + expect(forwardGuestShortcutInput(context, event, { doubleTapModifier: primaryDoubleTap })).toBe( + true + ) + expect(send).toHaveBeenCalledWith( + 'ui:browserPageTabShortcut', + expect.objectContaining({ doubleTapModifier: primaryDoubleTap }) + ) + }) + + it('leaves composing input and nonlocal renderers alone', () => { + const { context, event, send } = fixture(false) + expect(forwardGuestShortcutInput(context, event, input)).toBe(false) + const local = fixture() + expect(forwardGuestShortcutInput(local.context, local.event, { ...input, key: 'Dead' })).toBe( + false + ) + expect(send).not.toHaveBeenCalled() + expect(local.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-dispatch.ts b/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-dispatch.ts index 9ddbc677729..3f6c2c29e39 100644 --- a/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-dispatch.ts +++ b/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-dispatch.ts @@ -3,6 +3,7 @@ import { type WindowShortcutInput } from '../../../../../shared/window-shortcut-policy' import { keybindingMatchesAction } from '../../../../../shared/keybindings/matching' +import { hasModifier } from '../../../../../shared/keybindings/parser' import type { KeybindingOverrides } from '../../../../../shared/keybindings/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../shared/constants' import type { BrowserPageZoomDirection } from '../../../../../shared/browser-page-zoom' @@ -17,11 +18,14 @@ export type IsMobileEmulatorEnabled = () => boolean export type GuestShortcutInput = WindowShortcutInput & { isAutoRepeat?: boolean isComposing?: boolean + altGraph?: boolean + getModifierState?: (key: string) => boolean } export type GuestShortcutForwardContext = { browserTabId: string resolveRenderer: ResolveRenderer + isLocalRenderer?: (renderer: Electron.WebContents) => boolean shouldForwardDictationShortcut?: ShouldForwardDictationShortcut isMobileEmulatorEnabled?: IsMobileEmulatorEnabled getKeybindings?: () => KeybindingOverrides | undefined @@ -42,6 +46,7 @@ export function forwardGuestShortcutInput( const { browserTabId, resolveRenderer, + isLocalRenderer, shouldForwardDictationShortcut, isMobileEmulatorEnabled, getKeybindings, @@ -56,6 +61,23 @@ export function forwardGuestShortcutInput( action?.type === 'dictationKeyDown' && !textInputOwnsComposition(input) && shouldForwardDictationShortcut?.() === true + // Why: Ctrl+Alt can be AltGr on Windows/Linux; leave those chords with the page. + const mayBeAltGraph = + process.platform !== 'darwin' && hasModifier(input, 'control') && hasModifier(input, 'alt') + // Why: non-primary guest chords stay on the page; primary app chords retain guest precedence. + const primaryModifier = process.platform === 'darwin' ? 'meta' : 'control' + const primaryDoubleTap = process.platform === 'darwin' ? 'Cmd' : 'Ctrl' + const matchesPlugins = + !mayBeAltGraph && + (hasModifier(input, primaryModifier) || input.doubleTapModifier === primaryDoubleTap) && + !textInputOwnsComposition(input) && + keybindingMatchesAction('view.plugins', input, process.platform, keybindings, { + context: 'app' + }) && + (() => { + const renderer = resolveRenderer(browserTabId) + return renderer !== null && isLocalRenderer?.(renderer) === true + })() if (action?.type === 'zoom') { if (!page) { // Why: a plugin frame has no page zoom; Orca zooms what it would with focus outside a pane. @@ -69,6 +91,7 @@ export function forwardGuestShortcutInput( if (input.isAutoRepeat) { if ( forwardsDictation || + matchesPlugins || // Why: Orca claims a held tab chord on every repeat but acts once, so repeats never reach the page. matchesGuestTabShortcut( input, @@ -215,6 +238,7 @@ export function forwardGuestShortcutInput( } renderer.send('ui:dictationKeyDown') } else if ( + matchesPlugins || matchesGuestTabShortcut( input, process.platform, diff --git a/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-forwarding.test.ts b/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-forwarding.test.ts new file mode 100644 index 00000000000..a0b1ca9126e --- /dev/null +++ b/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-forwarding.test.ts @@ -0,0 +1,36 @@ +import { EventEmitter } from 'node:events' +import { expect, it, vi } from 'vitest' +import { setupGuestShortcutForwarding } from './browser-guest-shortcut-forwarding' + +vi.mock('../../../cdp/input/agent-input-injection', () => ({ isAgentInjectingInput: () => false })) + +it('forwards a modifier double tap without arming it during composition', () => { + const guest = new EventEmitter() + const send = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: forwarding uses only the guest event emitter. + const webContents = guest as unknown as Electron.WebContents + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: forwarding uses only send on this renderer. + const renderer = { send } as unknown as Electron.WebContents + setupGuestShortcutForwarding({ + browserTabId: 'page', + guest: webContents, + resolveRenderer: () => renderer, + isLocalRenderer: () => true, + getKeybindings: () => ({ 'view.plugins': ['DoubleTap+Mod'] }) + }) + const event = { preventDefault: vi.fn() } + const shift = + process.platform === 'darwin' + ? { key: 'Meta', code: 'MetaLeft', meta: true } + : { key: 'Control', code: 'ControlLeft', control: true } + guest.emit('before-input-event', event, { ...shift, type: 'keyDown', isComposing: true }) + guest.emit('before-input-event', event, { ...shift, type: 'keyUp', isComposing: true }) + guest.emit('before-input-event', event, { ...shift, type: 'keyDown' }) + expect(send).not.toHaveBeenCalled() + guest.emit('before-input-event', event, { ...shift, type: 'keyUp' }) + guest.emit('before-input-event', event, { ...shift, type: 'keyDown' }) + expect(send).toHaveBeenCalledWith( + 'ui:browserPageTabShortcut', + expect.objectContaining({ doubleTapModifier: process.platform === 'darwin' ? 'Cmd' : 'Ctrl' }) + ) +}) diff --git a/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-forwarding.ts b/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-forwarding.ts index a51f774436b..7d8a66255fd 100644 --- a/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-forwarding.ts +++ b/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-forwarding.ts @@ -1,3 +1,4 @@ +import { textInputOwnsComposition } from '../../../../../shared/text-input-shortcut-policy' import { isAgentInjectingInput } from '../../../cdp/input/agent-input-injection' import { isRecentTabSwitcherCommitRelease, @@ -26,6 +27,7 @@ export function setupGuestShortcutForwarding(args: { browserTabId: string guest: Electron.WebContents resolveRenderer: ResolveRenderer + isLocalRenderer?: (renderer: Electron.WebContents) => boolean shouldForwardDictationShortcut?: ShouldForwardDictationShortcut isMobileEmulatorEnabled?: IsMobileEmulatorEnabled getKeybindings?: () => KeybindingOverrides | undefined @@ -38,6 +40,7 @@ export function setupGuestShortcutForwarding(args: { browserTabId, guest, resolveRenderer, + isLocalRenderer, shouldForwardDictationShortcut, isMobileEmulatorEnabled, getKeybindings, @@ -61,6 +64,7 @@ export function setupGuestShortcutForwarding(args: { const forwardContext: GuestShortcutForwardContext = { browserTabId, resolveRenderer, + isLocalRenderer, shouldForwardDictationShortcut, isMobileEmulatorEnabled, getKeybindings, @@ -95,7 +99,9 @@ export function setupGuestShortcutForwarding(args: { return } - if (input.type === 'keyDown' || input.type === 'keyUp') { + if (textInputOwnsComposition(input)) { + doubleTapDetector.reset() + } else if (input.type === 'keyDown' || input.type === 'keyUp') { const detected = doubleTapDetector.process( toModifierDoubleTapEvent({ type: input.type, diff --git a/src/main/browser/guest-lifecycle/manager/browser-manager-bindings.ts b/src/main/browser/guest-lifecycle/manager/browser-manager-bindings.ts index c78a8c9ca12..c2d42b9ba9c 100644 --- a/src/main/browser/guest-lifecycle/manager/browser-manager-bindings.ts +++ b/src/main/browser/guest-lifecycle/manager/browser-manager-bindings.ts @@ -1,3 +1,4 @@ +import { isTrustedBrowserRenderer } from '../../../ipc/browser/browser-renderer-trust' import { resolveRendererWebContents } from '../browser-guest-renderer-target' // oxlint-disable-next-line import/no-cycle -- browser subsystem singleton hub (browserManager); breaking it needs DI and test-mock updates, left for a human import { setupGuestContextMenu } from '../input/browser-guest-context-menu' @@ -53,6 +54,7 @@ export abstract class BrowserManagerBindings extends BrowserManagerGrab { guest, resolveRenderer: (tabId) => resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), + isLocalRenderer: isTrustedBrowserRenderer, shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false, isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false, getKeybindings: () => this.settingsResolver?.().keybindings, diff --git a/src/main/browser/routing/guest-authority/browser-route-guest-initialization.test.ts b/src/main/browser/routing/guest-authority/browser-route-guest-initialization.test.ts new file mode 100644 index 00000000000..c7137012e44 --- /dev/null +++ b/src/main/browser/routing/guest-authority/browser-route-guest-initialization.test.ts @@ -0,0 +1,68 @@ +import { EventEmitter } from 'node:events' +import { expect, it, vi } from 'vitest' +import { initializeBrowserRouteGuest } from './browser-route-guest-initialization' + +it.each([ + ['about:blank', 'data:text/html,'], + ['https://example.test/same', 'https://example.test/same'] +])( + 'accepts the requested main-frame commit before load finishes: %s', + async (url, committedUrl) => { + const load = Promise.withResolvers() + const guest = Object.assign(new EventEmitter(), { + loadURL: vi.fn(() => load.promise), + stop: vi.fn() + }) + const initialized = initializeBrowserRouteGuest( + guest, + url, + new AbortController().signal, + () => true + ) + guest.emit('did-fail-load', {}, -2, 'failure', url, true) + guest.emit('did-navigate-in-page', {}, 'https://subframe.test/', false) + guest.emit('did-navigate', {}, url) + await expect(initialized).resolves.toBe(committedUrl) + load.reject(new Error('later JS redirect aborted the full load')) + await Promise.resolve() + expect(guest.eventNames()).toEqual([]) + } +) + +it('uses a committed error destination and rejects cancellation or lost authority', async () => { + const guest = Object.assign(new EventEmitter(), { + loadURL: vi.fn(() => new Promise(() => {})), + stop: vi.fn() + }) + const controller = new AbortController() + const error = initializeBrowserRouteGuest( + guest, + 'https://example.test/', + controller.signal, + () => true + ) + guest.emit( + 'did-fail-provisional-load', + {}, + -105, + 'DNS failed', + 'https://example.test/redirect', + true + ) + await expect(error).resolves.toBe('https://example.test/redirect') + const cancelled = initializeBrowserRouteGuest(guest, 'about:blank', controller.signal, () => true) + controller.abort() + await expect(cancelled).rejects.toThrow('browser_client_page_initialization_failed') + expect(guest.stop).toHaveBeenCalledOnce() + let current = true + const stale = initializeBrowserRouteGuest( + guest, + 'about:blank', + new AbortController().signal, + () => current + ) + current = false + guest.emit('did-navigate', {}, 'about:blank') + await expect(stale).rejects.toThrow('browser_client_page_initialization_failed') + expect(guest.eventNames()).toEqual([]) +}) diff --git a/src/main/browser/routing/guest-authority/browser-route-guest-initialization.ts b/src/main/browser/routing/guest-authority/browser-route-guest-initialization.ts new file mode 100644 index 00000000000..a609ee50aba --- /dev/null +++ b/src/main/browser/routing/guest-authority/browser-route-guest-initialization.ts @@ -0,0 +1,117 @@ +import type { EventEmitter } from 'node:events' +import type { BrowserRouteGuestLifecycleClaim } from './browser-route-page-authority' +import type { BrowserRouteGuestState } from './browser-route-webcontents-state' +import type { WebContents } from 'electron' +import { normalizeBrowserNavigationUrl } from '../../../../shared/browser-url' + +/** The controlled bootstrap document has finished and stopped before this owner starts. */ +export function initializeBrowserRouteGuest( + guest: Pick & Pick, + rawUrl: string, + signal: AbortSignal, + isCurrent: () => boolean +): Promise { + const url = normalizeBrowserNavigationUrl(rawUrl) + if (!url || url.startsWith('file:') || signal.aborted || !isCurrent()) { + return Promise.reject(new Error('browser_client_page_initialization_stale')) + } + return new Promise((resolve, reject) => { + let settled = false + const remove = (): void => { + guest.removeListener('did-navigate', committed) + guest.removeListener('did-navigate-in-page', inPage) + guest.removeListener('did-fail-provisional-load', failedDocument) + guest.removeListener('destroyed', lost) + guest.removeListener('render-process-gone', lost) + signal.removeEventListener('abort', abort) + } + const finish = (destination?: string): void => { + if (settled) { + return + } + settled = true + remove() + const normalized = + destination === undefined ? null : normalizeBrowserNavigationUrl(destination) + if (normalized && !normalized.startsWith('file:') && !signal.aborted && isCurrent()) { + resolve(normalized) + } else { + reject(new Error('browser_client_page_initialization_failed')) + } + } + const committed = (_event: Electron.Event, destination: string): void => finish(destination) + const inPage = (_event: Electron.Event, destination: string, mainFrame: boolean): void => { + if (mainFrame) { + finish(destination) + } + } + const failedDocument = ( + _event: Electron.Event, + code: number, + _description: string, + destination: string, + mainFrame: boolean + ): void => { + // Electron emits this only for committed error documents; did-fail-load also covers noncommits. + if (mainFrame && code !== -3) { + finish(destination) + } + } + const lost = (): void => finish() + const abort = (): void => { + const owned = isCurrent() + finish() + if (owned) { + try { + guest.stop() + } catch { + /* Native teardown may already have started. */ + } + } + } + guest.on('did-navigate', committed) + guest.on('did-navigate-in-page', inPage) + guest.on('did-fail-provisional-load', failedDocument) + guest.on('destroyed', lost) + guest.on('render-process-gone', lost) + signal.addEventListener('abort', abort, { once: true }) + try { + void guest.loadURL(url).then( + () => { + if (!settled) { + finish() + } + }, + () => { + if (!settled) { + finish() + } + } + ) + } catch { + finish() + } + }) +} + +export function initializeClaimedBrowserRouteGuest( + claim: BrowserRouteGuestLifecycleClaim, + url: string, + signal: AbortSignal, + guests: Map, + hasAuthority: (state: BrowserRouteGuestState) => boolean +): Promise { + const state = guests.get(claim.registration.webContentsId) + const isCurrent = (): boolean => + Boolean( + state && + guests.get(claim.registration.webContentsId) === state && + state.guestAuthority === claim.guestAuthority && + state.navigationGranted && + hasAuthority(state) + ) + if (!state || !isCurrent()) { + return Promise.reject(new Error('browser_client_page_initialization_stale')) + } + return initializeBrowserRouteGuest(state.guest, url, signal, isCurrent) +} diff --git a/src/main/browser/routing/guest-authority/browser-route-guest-lifecycle-claim.ts b/src/main/browser/routing/guest-authority/browser-route-guest-lifecycle-claim.ts index 68416d2f577..3cfa65229a8 100644 --- a/src/main/browser/routing/guest-authority/browser-route-guest-lifecycle-claim.ts +++ b/src/main/browser/routing/guest-authority/browser-route-guest-lifecycle-claim.ts @@ -28,3 +28,20 @@ export function claimBrowserRouteGuestLifecycle( isCurrent: () => !state.retirementRequested && registrationMatches() }) } + +export function claimRegisteredBrowserRouteGuestLifecycle( + registration: BrowserRoutePageGuestIdentity, + state: BrowserRouteGuestState | undefined, + registrationMatches: (state: BrowserRouteGuestState) => boolean, + hasAuthority: (state: BrowserRouteGuestState) => boolean +): BrowserRouteGuestLifecycleClaim | null { + return claimBrowserRouteGuestLifecycle(registration, state, () => + Boolean( + state && + (!state.registration || + (browserRoutePageKey(state.registration) === browserRoutePageKey(registration) && + hasAuthority(state))) && + registrationMatches(state) + ) + ) +} diff --git a/src/main/browser/routing/guest-authority/browser-route-guest-lifecycle.ts b/src/main/browser/routing/guest-authority/browser-route-guest-lifecycle.ts index 7686e59ee9d..c8a6a0fb953 100644 --- a/src/main/browser/routing/guest-authority/browser-route-guest-lifecycle.ts +++ b/src/main/browser/routing/guest-authority/browser-route-guest-lifecycle.ts @@ -1,3 +1,4 @@ +import type { BrowserRouteWebContentsRegistryDependencies } from './browser-route-webcontents-registry-dependencies' import { normalizeBrowserNavigationUrl } from '../../../../shared/browser-url' import '../../../../shared/constants' import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/browser/browser-blank-page' @@ -172,3 +173,38 @@ export function retireBrowserRouteGuest(input: { } return isRouteGuestDestroyed(input.state.guest) ? 'retired' : 'retiring' } + +export function retireBrowserRouteGuestPage( + state: BrowserRouteGuestState, + dependencies: BrowserRouteWebContentsRegistryDependencies, + revoke: () => void +): void { + if (state.retirementRequested) { + return + } + state.retirementRequested = true + state.navigationGranted = false + // Fence popups before the logical retirement round trip. + state.popups?.closeAll() + const registration = state.registration + const pageAuthority = state.pageAuthority + if (!registration || pageAuthority === null) { + revoke() + return + } + let started = false + try { + started = dependencies.retirePreparedPage({ + partition: registration.partition, + browserPageId: registration.browserPageId, + pageHostGeneration: registration.pageHostGeneration, + rendererWebContentsId: registration.rendererWebContentsId, + pageAuthority + }) + } catch { + // Exact guest stays revoked even if logical retirement cannot start. + } + if (!started) { + revoke() + } +} diff --git a/src/main/browser/routing/guest-authority/browser-route-webcontents-registry.ts b/src/main/browser/routing/guest-authority/browser-route-webcontents-registry.ts index 4aeded40a01..d83e2dbcb6b 100644 --- a/src/main/browser/routing/guest-authority/browser-route-webcontents-registry.ts +++ b/src/main/browser/routing/guest-authority/browser-route-webcontents-registry.ts @@ -1,4 +1,5 @@ import type { WebContents } from 'electron' +import { initializeClaimedBrowserRouteGuest } from './browser-route-guest-initialization' import { browserRoutePageKey, type BrowserRouteGuestLifecycleClaim, @@ -14,7 +15,7 @@ import { isValidBlankRouteGuest, isValidRoutePageRetirement } from './browser-route-guest-guard' -import { claimBrowserRouteGuestLifecycle } from './browser-route-guest-lifecycle-claim' +import { claimRegisteredBrowserRouteGuestLifecycle } from './browser-route-guest-lifecycle-claim' import { releaseBrowserRouteGuest } from './browser-route-guest-release' import { grantBrowserRouteGuestNavigation, @@ -22,6 +23,7 @@ import { } from './browser-route-navigation-authority' import { beginBrowserRouteGuestRetirement, + retireBrowserRouteGuestPage, createBrowserRouteGuestState, installBrowserRouteGuestQuarantine, isBrowserRouteGuestNavigationAllowed, @@ -114,15 +116,11 @@ export class BrowserRouteWebContentsRegistry { } claimGuestLifecycle(registration: GuestIdentity): BrowserRouteGuestLifecycleClaim | null { - const state = this.guests.get(registration.webContentsId) - return claimBrowserRouteGuestLifecycle(registration, state, () => - Boolean( - state && - (!state.registration || - (browserRoutePageKey(state.registration) === browserRoutePageKey(registration) && - this.hasLivePageAuthority(state))) && - this.registrationMatchesGuest(state, registration) - ) + return claimRegisteredBrowserRouteGuestLifecycle( + registration, + this.guests.get(registration.webContentsId), + (state) => this.registrationMatchesGuest(state, registration), + (state) => this.hasLivePageAuthority(state) ) } @@ -190,6 +188,21 @@ export class BrowserRouteWebContentsRegistry { return navigateBrowserRouteGuest(registration, rawUrl, state, () => isCurrent) } + initializeGuest( + claim: BrowserRouteGuestLifecycleClaim, + url: string, + signal: AbortSignal + ): Promise { + return initializeClaimedBrowserRouteGuest( + claim, + url, + signal, + this.guests, + (state) => + this.registrationMatchesGuest(state, claim.registration) && this.hasLivePageAuthority(state) + ) + } + beginGuestRetirement(claim: BrowserRouteGuestLifecycleClaim): Promise | null { const registration = claim.registration const state = this.guests.get(registration.webContentsId) @@ -288,34 +301,7 @@ export class BrowserRouteWebContentsRegistry { } private retireGuestPage(state: GuestState): void { - if (state.retirementRequested) { - return - } - state.retirementRequested = true - state.navigationGranted = false - // Fence popups before the logical retirement round trip. - state.popups?.closeAll() - const registration = state.registration - const pageAuthority = state.pageAuthority - if (!registration || pageAuthority === null) { - this.revokeGuest(state) - return - } - let started = false - try { - started = this.dependencies.retirePreparedPage({ - partition: registration.partition, - browserPageId: registration.browserPageId, - pageHostGeneration: registration.pageHostGeneration, - rendererWebContentsId: registration.rendererWebContentsId, - pageAuthority - }) - } catch { - // Exact guest stays revoked even if logical retirement cannot start. - } - if (!started) { - this.revokeGuest(state) - } + retireBrowserRouteGuestPage(state, this.dependencies, () => this.revokeGuest(state)) } private releaseGuest(state: GuestState): void { diff --git a/src/main/git/command-runner/admission/git-subprocess-admission.ts b/src/main/git/command-runner/admission/git-subprocess-admission.ts index 17e3b3442d7..7554520d3e7 100644 --- a/src/main/git/command-runner/admission/git-subprocess-admission.ts +++ b/src/main/git/command-runner/admission/git-subprocess-admission.ts @@ -14,6 +14,18 @@ export function acquireGitAdmission(request: GitAdmissionRequest): Promise | null, + grant: Pick +): void { + if (termination) { + void termination.then(grant.release) + } else { + grant.release() + } +} + export function _resetGitAdmissionForTests(replacement = new GitAdmissionScheduler()): void { scheduler = replacement } diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts index 5b86fa99c9e..7500f661e11 100644 --- a/src/main/git/command-runner/git-exec-file.ts +++ b/src/main/git/command-runner/git-exec-file.ts @@ -7,8 +7,7 @@ import { retryGitFetchRefRace } from '../../../shared/git/fetch/git-fetch-ref-ra import { pendingWslDriveCheckoutGitRouting } from './host-routing/wsl-drive-checkout-git-routing' import type { ResolvedCommand } from './host-routing/wsl-command-resolution' import { annotateWslHostFailure, isTransientWslFailure } from './host-routing/wsl-host-failure' -import type { GitAdmissionTier } from '../../../shared/rpc-contract/source-control/git-admission-tier-params' -import type { GitExecOptions } from './git-exec-options' +import type { GitBufferExecOptions, GitExecOptions } from './git-exec-options' import { execFileCapture, execFileCaptureToTermination } from './process-capture/exec-file-capture' import { pendingWslDirectGitReadEnvironment, @@ -21,7 +20,7 @@ import { import { prepareWindowsHostGitEnvironment } from './host-routing/windows-host-git-environment' import { buildNetworkSshPolicyEnv } from './git-environment/git-ssh-policy-env' import { nonInteractiveGitEnv, untranslatedGitOutputEnv } from './git-environment/git-process-env' -import { acquireGitAdmission } from './admission/git-subprocess-admission' +import { acquireGitAdmission, releaseGitAdmissionAfter } from './admission/git-subprocess-admission' import { GitCommandTimeoutError, gitCommandTimeoutMs @@ -175,12 +174,7 @@ async function gitExecFileAsyncUnlocked( options.canStart ) } finally { - const termination = terminationState.current - if (termination) { - void termination.then(grant.release) - } else { - grant.release() - } + releaseGitAdmissionAfter(terminationState.current, grant) } } ) @@ -196,10 +190,7 @@ function withCapturedErrorStdout(error: unknown, command: ResolvedCommand): unkn return error } -export function gitExecFileAsync( - args: string[], - callerOptions: GitExecOptions -): Promise<{ stdout: string; stderr: string }> { +export function gitExecFileAsync(args: string[], callerOptions: GitExecOptions) { const options = routeLocalGitOptions(callerOptions) const releaseMutation = trackGitWorktreeMutation(args, options) const releaseRefMutation = trackGitRefMutation(args) @@ -232,18 +223,7 @@ export function gitExecFileAsync( async function gitExecFileAsyncBufferInSpan( args: string[], - options: { - cwd: string - maxBuffer?: number - timeout?: number - timeoutMsForTest?: number - env?: NodeJS.ProcessEnv - wslDistro?: string - preferWslDirectGit?: boolean - admissionTier?: GitAdmissionTier - signal?: AbortSignal - stdin?: string - } + options: GitBufferExecOptions ): Promise<{ stdout: Buffer }> { return withGitSpan({ args, cwd: options.cwd }, async (span) => { const checkoutRoutingReady = pendingWslDriveCheckoutGitRouting(args, options) @@ -296,11 +276,7 @@ async function gitExecFileAsyncBufferInSpan( } return { stdout: readCapturedGitBuffer(stdout, resolved) } } finally { - if (termination) { - void termination.then(grant.release) - } else { - grant.release() - } + releaseGitAdmissionAfter(termination, grant) } }) } diff --git a/src/main/git/command-runner/git-exec-options.ts b/src/main/git/command-runner/git-exec-options.ts index 4bcb2e3dd13..3995251dff8 100644 --- a/src/main/git/command-runner/git-exec-options.ts +++ b/src/main/git/command-runner/git-exec-options.ts @@ -35,3 +35,16 @@ export type GitExecOptions = { /** Observe stderr without changing capture/errors; incompatible with terminationBarrier. */ onStderr?: (chunk: string) => void } + +export type GitBufferExecOptions = { + cwd: string + maxBuffer?: number + timeout?: number + timeoutMsForTest?: number + env?: NodeJS.ProcessEnv + wslDistro?: string + preferWslDirectGit?: boolean + admissionTier?: GitAdmissionTier + signal?: AbortSignal + stdin?: string +} diff --git a/src/main/ipc/filesystem/filesystem-git-at-risk-local-files-handlers.test.ts b/src/main/ipc/filesystem/filesystem-git-at-risk-local-files-handlers.test.ts new file mode 100644 index 00000000000..ef107966f07 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-at-risk-local-files-handlers.test.ts @@ -0,0 +1,126 @@ +import { EventEmitter } from 'node:events' +import type { IpcMainInvokeEvent } from 'electron' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createSenderScopedRequestCancellations } from '../sender-scoped-request-cancellation' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +const { handlers, readAtRiskMock } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + readAtRiskMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: IpcMainInvokeEvent, args: unknown) => unknown) => { + handlers.set(channel, handler) + } + } +})) +vi.mock('../../providers/execution-host-provider-dispatch', () => ({ + requireReachableGitRoute: vi.fn() +})) +vi.mock('./wsl-gated-worktree-git-read', () => ({ + readWorktreeGitUnlessWslStopped: ( + _store: unknown, + worktreePath: string, + _wake: unknown, + read: (path: string, gitOptions: { wslDistro?: string }) => Promise + ) => read(worktreePath, { wslDistro: undefined }) +})) +vi.mock('../local-worktree-runtime-options', () => ({ + getLocalRepoForRegisteredWorktree: vi.fn(() => ({ path: '/repo' })) +})) +vi.mock('../../git/worktrees/removal/worktree-at-risk-local-files', () => ({ + readWorktreeAtRiskLocalFiles: readAtRiskMock +})) + +import { registerFilesystemGitAtRiskLocalFilesHandlers } from './filesystem-git-at-risk-local-files-handlers' + +function requestEvent(id = 1): IpcMainInvokeEvent { + const sender = Object.assign(new EventEmitter(), { id }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cancellation registry only reads sender.id and its EventEmitter lifetime methods. + return { sender } as unknown as IpcMainInvokeEvent +} + +function invoke(channel: string, event: IpcMainInvokeEvent, args: unknown): unknown { + const handler = handlers.get(channel) + if (!handler) { + throw new Error(`no handler for ${channel}`) + } + return handler(event, args) +} + +describe('git:atRiskLocalFiles cancellation', () => { + beforeEach(() => { + handlers.clear() + readAtRiskMock.mockReset() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers only read store and the git status cancellation registry. + const context = { + store: {}, + gitStatusCancellations: createSenderScopedRequestCancellations() + } as unknown as FilesystemHandlerContext + registerFilesystemGitAtRiskLocalFilesHandlers(context) + }) + + it('aborts the scan when the renderer cancels its request token', async () => { + let scanSignal: AbortSignal | undefined + readAtRiskMock.mockImplementation( + (args: { gitOptions?: { signal?: AbortSignal } }) => + new Promise((_resolve, reject) => { + scanSignal = args.gitOptions?.signal + scanSignal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + }) + ) + const event = requestEvent() + + const scan = invoke('git:atRiskLocalFiles', event, { + worktreePath: '/repo-feature', + requestToken: 'at-risk-1' + }) + await vi.waitFor(() => expect(readAtRiskMock).toHaveBeenCalled()) + expect(scanSignal?.aborted).toBe(false) + + await invoke('git:cancelAtRiskLocalFiles', event, { + requestToken: 'at-risk-1' + }) + + expect(scanSignal?.aborted).toBe(true) + await expect(scan).rejects.toThrow('aborted') + }) + + it('does not let another window cancel the scan', async () => { + let scanSignal: AbortSignal | undefined + let startScan!: () => void + const scanStarted = new Promise((resolve) => { + startScan = resolve + }) + readAtRiskMock.mockImplementation( + (args: { gitOptions?: { signal?: AbortSignal } }) => + new Promise((_resolve, reject) => { + scanSignal = args.gitOptions?.signal + scanSignal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + startScan() + }) + ) + const owner = requestEvent(1) + + const scan = invoke('git:atRiskLocalFiles', owner, { + worktreePath: '/repo-feature', + requestToken: 'at-risk-1' + }) + const cancelled = expect(scan).rejects.toThrow('aborted') + await scanStarted + await invoke('git:cancelAtRiskLocalFiles', requestEvent(2), { + requestToken: 'at-risk-1' + }) + + const abortedByOtherWindow = scanSignal?.aborted + await invoke('git:cancelAtRiskLocalFiles', owner, { + requestToken: 'at-risk-1' + }) + + expect(scanSignal?.aborted).toBe(true) + await cancelled + expect(abortedByOtherWindow).toBe(false) + }) +}) diff --git a/src/main/ipc/filesystem/filesystem-git-at-risk-local-files-handlers.ts b/src/main/ipc/filesystem/filesystem-git-at-risk-local-files-handlers.ts new file mode 100644 index 00000000000..d239eccb004 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-at-risk-local-files-handlers.ts @@ -0,0 +1,51 @@ +import { ipcMain } from 'electron' +import { readWorktreeAtRiskLocalFiles } from '../../git/worktrees/removal/worktree-at-risk-local-files' +import type { WorktreeAtRiskLocalFiles } from '../../../shared/worktree/removal/at-risk-local-files' +import { getLocalRepoForRegisteredWorktree } from '../local-worktree-runtime-options' +import { requireReachableGitRoute } from '../../providers/execution-host-provider-dispatch' +import { getConnectionExecutionHostId } from '../../../shared/execution-host' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { readWorktreeGitUnlessWslStopped } from './wsl-gated-worktree-git-read' + +/** Delete-preview ignored-file scans; a closed or retargeted preview cancels its queued or running scan. */ +export function registerFilesystemGitAtRiskLocalFilesHandlers( + context: FilesystemHandlerContext +): void { + // Request tokens are unique per scan, so these share the sender-scoped status registry. + const { store, gitStatusCancellations: cancellations } = context + ipcMain.handle( + 'git:atRiskLocalFiles', + async ( + event, + args: { + worktreePath: string + connectionId?: string + requestToken?: string + } + ): Promise => { + const controller = cancellations.begin(event, args.requestToken) + try { + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) + return await readWorktreeGitUnlessWslStopped( + store, + args.worktreePath, + false, + (worktreePath, gitOptions) => + readWorktreeAtRiskLocalFiles({ + worktreePath, + mainCheckoutPath: + getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath)?.path ?? + null, + gitOptions: controller ? { ...gitOptions, signal: controller.signal } : gitOptions + }) + ) + } finally { + cancellations.finish(event, args.requestToken, controller) + } + } + ) + + ipcMain.handle('git:cancelAtRiskLocalFiles', (event, args: { requestToken: string }): void => { + cancellations.cancel(event, args.requestToken) + }) +} diff --git a/src/main/ipc/filesystem/filesystem-git-status-handlers.ts b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts index 81779a5a33b..495556f449b 100644 --- a/src/main/ipc/filesystem/filesystem-git-status-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts @@ -16,11 +16,10 @@ import { } from '../../git/source-control/mutations/git-conflict-operation' import { getDiff } from '../../git/source-control/diffs/file-diff' import { registerFilesystemGitHistoryHandlers } from './filesystem-git-history-handlers' +import { registerFilesystemGitAtRiskLocalFilesHandlers } from './filesystem-git-at-risk-local-files-handlers' import { isGitWorktreeMutationInFlight } from '../../git/command-runner/git-worktree-mutation-state' import type { GitMutationInFlight } from '../../../shared/git/commands/git-mutation-in-flight' import { checkIgnoredPaths } from '../../git/source-control/ignores/check-ignored-paths' -import { readWorktreeAtRiskLocalFiles } from '../../git/worktrees/removal/worktree-at-risk-local-files' -import type { WorktreeAtRiskLocalFiles } from '../../../shared/worktree/removal/at-risk-local-files' import { appendFolderToGitignore, findKnownHugeFolderPathsToIgnore @@ -48,6 +47,7 @@ import { export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerContext): void { const { store, gitStatusCancellations } = context registerFilesystemGitHistoryHandlers(context) + registerFilesystemGitAtRiskLocalFilesHandlers(context) ipcMain.handle( 'git:status', async ( @@ -173,29 +173,6 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo } ) - ipcMain.handle( - 'git:atRiskLocalFiles', - async ( - _event, - args: { worktreePath: string; connectionId?: string } - ): Promise => { - requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - return readWorktreeGitUnlessWslStopped( - store, - args.worktreePath, - false, - (worktreePath, gitOptions) => - readWorktreeAtRiskLocalFiles({ - worktreePath, - mainCheckoutPath: - getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath)?.path ?? - null, - gitOptions - }) - ) - } - ) - ipcMain.handle( 'git:findHugeFoldersToIgnore', async (_event, args: { worktreePath: string }): Promise => diff --git a/src/main/ipc/workspace-cleanup/workspace-cleanup-candidate.ts b/src/main/ipc/workspace-cleanup/workspace-cleanup-candidate.ts index d6ed55f0e99..66ed055bc2c 100644 --- a/src/main/ipc/workspace-cleanup/workspace-cleanup-candidate.ts +++ b/src/main/ipc/workspace-cleanup/workspace-cleanup-candidate.ts @@ -3,7 +3,7 @@ import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' import { getWorktreeExecutionHostId } from '../../../shared/execution-host' import type { PRInfo } from '../../../shared/github/pull-requests/pull-request-types' -import type { PRCacheView } from '../../../shared/sidebar-list/workspace-review-facts' +import type { PRCacheView } from '../../../shared/sidebar-list/feed-facts/workspace-review-facts' import { createWorkspaceCleanupFingerprint, getWorkspaceCleanupAutomationRunReasons, diff --git a/src/main/ipc/workspace-cleanup/workspace-cleanup-git-evidence.ts b/src/main/ipc/workspace-cleanup/workspace-cleanup-git-evidence.ts index 07fca96ca0c..b0a591d7cef 100644 --- a/src/main/ipc/workspace-cleanup/workspace-cleanup-git-evidence.ts +++ b/src/main/ipc/workspace-cleanup/workspace-cleanup-git-evidence.ts @@ -4,7 +4,7 @@ import type { GitStatusResult } from '../../../shared/git/status/git-status-type import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' import type { PRInfo } from '../../../shared/github/pull-requests/pull-request-types' -import type { PRCacheView } from '../../../shared/sidebar-list/workspace-review-facts' +import type { PRCacheView } from '../../../shared/sidebar-list/feed-facts/workspace-review-facts' import { isWorkspaceCleanupHeadLanded, type WorkspaceCleanupBlocker, diff --git a/src/main/ipc/workspace-cleanup/workspace-cleanup-merge-evidence.ts b/src/main/ipc/workspace-cleanup/workspace-cleanup-merge-evidence.ts index 0bcec01113c..1ec5c0695b0 100644 --- a/src/main/ipc/workspace-cleanup/workspace-cleanup-merge-evidence.ts +++ b/src/main/ipc/workspace-cleanup/workspace-cleanup-merge-evidence.ts @@ -7,7 +7,7 @@ import type { Repo } from '../../../shared/repo-types' import { readWorkspaceReviewPR, type PRCacheView -} from '../../../shared/sidebar-list/workspace-review-facts' +} from '../../../shared/sidebar-list/feed-facts/workspace-review-facts' import { isFullGitObjectId } from '../../git/refs/resolution/commit-object-ref' import { resolveWorktreeAddBaseRef, diff --git a/src/main/ipc/workspace-cleanup/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup/workspace-cleanup-scan.ts index 44b80c4cfc5..910eb5deb0a 100644 --- a/src/main/ipc/workspace-cleanup/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup/workspace-cleanup-scan.ts @@ -1,7 +1,7 @@ import type { RuntimeStore } from '../../runtime/construction/runtime-store-contract' import { isFolderRepo } from '../../../shared/repo-kind' import type { PRInfo } from '../../../shared/github/pull-requests/pull-request-types' -import type { PRCacheView } from '../../../shared/sidebar-list/workspace-review-facts' +import type { PRCacheView } from '../../../shared/sidebar-list/feed-facts/workspace-review-facts' import { readWorktreeMetaForHost } from '../../persistence/host-qualified-worktree-meta' import type { Repo } from '../../../shared/repo-types' import type { GitWorktreeInfo } from '../../../shared/worktree/types' diff --git a/src/main/orcad/browser-providers/orcad-client-page-commands.ts b/src/main/orcad/browser-providers/orcad-client-page-commands.ts index edfa1a8117b..7d2bfd79ac8 100644 --- a/src/main/orcad/browser-providers/orcad-client-page-commands.ts +++ b/src/main/orcad/browser-providers/orcad-client-page-commands.ts @@ -42,6 +42,18 @@ const LOGICAL_TAB_QUERIES = [ 'browserTabSwitch' ] as const +export function orcadClientPageCommand( + host: RuntimeBrowserCommandHost, + name: K, + forward: Forward, + engineAvailable: () => boolean +): RuntimeBrowserCommands[K] | undefined +export function orcadClientPageCommand( + host: RuntimeBrowserCommandHost, + name: string, + forward: Forward, + engineAvailable: () => boolean +): ((...args: never[]) => unknown) | undefined export function orcadClientPageCommand( host: RuntimeBrowserCommandHost, name: string, @@ -56,7 +68,7 @@ export function orcadClientPageCommand( ) if (name === 'browserOpenUrlOnClient') { return (...args: Parameters) => - openBrowserUrlOnClient(host, args[0]) + openBrowserUrlOnClient(host, args[0], args[1]?.signal) } if (name === 'browserTabCreate') { return (...args: Parameters) => @@ -138,7 +150,8 @@ function createTab( host, params ?? {}, orcadBrowserProfileId(params?.profileId), - hasEngine ? async () => requireCreatedPage(await createOnEngine()) : undefined + hasEngine ? async () => requireCreatedPage(await createOnEngine()) : undefined, + caller?.signal ) } return createOnEngine() diff --git a/src/main/orcad/serve-startup/orcad-cli-launcher.integration.test.ts b/src/main/orcad/serve-startup/orcad-cli-launcher.integration.test.ts index b94833cdaad..42263cf77de 100644 --- a/src/main/orcad/serve-startup/orcad-cli-launcher.integration.test.ts +++ b/src/main/orcad/serve-startup/orcad-cli-launcher.integration.test.ts @@ -112,7 +112,7 @@ it.skipIf(skip)( cwd: home, timeoutMs: 15_000 }) - expect(result.code, `${result.stdout}\n${result.stderr}`).toBe(0) + expect(result.code, JSON.stringify({ stdout: result.stdout, stderr: result.stderr })).toBe(0) expect(JSON.parse(result.stdout)).toMatchObject({ ok: true, result: { diff --git a/src/main/runtime/browser-host/client-pages/browser-host-client-page-adoption.ts b/src/main/runtime/browser-host/client-pages/browser-host-client-page-adoption.ts index 6b8200d9dd7..f9d0d56e491 100644 --- a/src/main/runtime/browser-host/client-pages/browser-host-client-page-adoption.ts +++ b/src/main/runtime/browser-host/client-pages/browser-host-client-page-adoption.ts @@ -1,7 +1,10 @@ import type { BrowserClientHostedPageInventory } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' import type { BrowserClientPageExecutionHostGrant } from './browser-host-client-page-creation' import type { BrowserHostLeaseState } from '../leases/browser-host-lease-records' -import type { RuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement' +import type { + RuntimeBrowserClientPlacement, + RuntimeBrowserPlacement +} from '../../../../shared/runtime-browser-placement' import type { BrowserHostRuntimePageIntent } from '../reconciliation/browser-host-page-reconciliation-plan' /** Restart-adoptable inventory requires the workspace needed by its runtime record. */ @@ -14,6 +17,7 @@ export type ClientHostedPageAdoptionCandidacy = { /** This process's id. An entry already naming it was placed by us, not by a predecessor. */ authorityRuntimeId: string hasRuntimePage: (browserPageId: string) => boolean + hasSavedPage?: (page: BrowserClientHostedPageInventory) => boolean } /** Adopt only untracked predecessor pages; pages this process closed must not be resurrected. */ @@ -21,7 +25,7 @@ export function selectAdoptableClientHostedPages( input: ClientHostedPageAdoptionCandidacy ): readonly AdoptableClientHostedPage[] { return input.inventory.filter((page): page is AdoptableClientHostedPage => { - if (page.state !== 'active') { + if (page.state !== 'active' && !input.hasSavedPage?.(page)) { return false } if (page.workspaceId === undefined) { @@ -30,7 +34,7 @@ export function selectAdoptableClientHostedPages( if (page.browserHostClientId !== input.browserHostClientId) { return false } - if (page.authorityRuntimeId === input.authorityRuntimeId) { + if (page.authorityRuntimeId === input.authorityRuntimeId && !input.hasSavedPage?.(page)) { return false } return !input.hasRuntimePage(page.browserPageId) @@ -56,6 +60,7 @@ export function buildClientPageAdoptionIntents(input: { lease: ClientHostedPageAdoptionLease /** Current workspace key, not the predecessor's key. */ executionHostKeyByWorkspaceId: ReadonlyMap + savedUrlByPageId?: ReadonlyMap }): readonly BrowserHostRuntimePageIntent[] { const ordered = [...input.pages].sort( (left, right) => left.pageHostGeneration - right.pageHostGeneration @@ -79,16 +84,18 @@ export function buildClientPageAdoptionIntents(input: { browserPageId: page.browserPageId, browserProfileId: page.browserProfileId, executionHostKey, - workspaceId: page.workspaceId + workspaceId: page.workspaceId, + url: page.currentUrl ?? input.savedUrlByPageId?.get(page.browserPageId) }) ] }) } -type ReconciliationOptions = { +export type BrowserHostClientPageAdoptionOptions = { maxConcurrency?: number actionTimeoutMs?: number signal?: AbortSignal + onAdopted?(id: string, placement: RuntimeBrowserClientPlacement): void } export type BrowserHostClientPageAdoptionDependencies = { @@ -97,7 +104,7 @@ export type BrowserHostClientPageAdoptionDependencies = { adopt( state: BrowserHostLeaseState, intents: readonly BrowserHostRuntimePageIntent[], - options: ReconciliationOptions + options: BrowserHostClientPageAdoptionOptions ): Promise } placements: { getPlacement(browserPageId: string): RuntimeBrowserPlacement | undefined } @@ -107,7 +114,7 @@ export type BrowserHostClientPageAdoptionDependencies = { /** Retain grants for committed placements even after partial reconciliation failure; revocation would strand live pages. */ export async function adoptBrowserHostClientPages( intents: readonly BrowserHostRuntimePageIntent[], - options: ReconciliationOptions, + options: BrowserHostClientPageAdoptionOptions, dependencies: BrowserHostClientPageAdoptionDependencies ): Promise { if (intents.length === 0) { @@ -133,6 +140,7 @@ export async function adoptBrowserHostClientPages( executionHostKey: intent.executionHostKey, release: grant.release }) + options.onAdopted?.(intent.browserPageId, placement) adopted.push(intent.browserPageId) } else { grant.release() diff --git a/src/main/runtime/browser-host/client-pages/browser-host-client-page-connection.ts b/src/main/runtime/browser-host/client-pages/browser-host-client-page-connection.ts index 445e6642ad6..124443928f1 100644 --- a/src/main/runtime/browser-host/client-pages/browser-host-client-page-connection.ts +++ b/src/main/runtime/browser-host/client-pages/browser-host-client-page-connection.ts @@ -1,3 +1,12 @@ +export type BrowserClientPageConnectionInput = { + browserPageId: string + placement: RuntimeBrowserClientPlacement + pairedDeviceId: string + connectionId: string + /** File transfers only: a staged profile-switch guest downloads before its generation commits. */ + admitPendingProfileSwitch?: boolean +} + import type { BrowserHostLeaseState } from '../leases/browser-host-lease-records' import type { BrowserHostPagePlacementRegistry } from '../reconciliation/browser-host-page-placement' import { @@ -8,14 +17,7 @@ import { requireBrowserHostLeaseState } from '../leases/browser-host-lease-state import { pendingBrowserHostProfileSwitches } from '../profiles/browser-host-client-profile-switch' export function requireBrowserClientPageConnection( - input: { - browserPageId: string - placement: RuntimeBrowserClientPlacement - pairedDeviceId: string - connectionId: string - /** File transfers only: a staged profile-switch guest downloads before its generation commits. */ - admitPendingProfileSwitch?: boolean - }, + input: BrowserClientPageConnectionInput, dependencies: { authorityRuntimeId: string authorityEpoch: string diff --git a/src/main/runtime/browser-host/client-pages/browser-host-client-page-creation.ts b/src/main/runtime/browser-host/client-pages/browser-host-client-page-creation.ts index 6af4ece3a1d..316909e8a8b 100644 --- a/src/main/runtime/browser-host/client-pages/browser-host-client-page-creation.ts +++ b/src/main/runtime/browser-host/client-pages/browser-host-client-page-creation.ts @@ -1,3 +1,15 @@ +import { + closeUncommittedClientPage, + retireUncommittedClientPage +} from './browser-host-uncommitted-client-page' +import type { BrowserHostLegacyPageRecoveryClaim } from './browser-host-page-legacy-recovery' +import type { BrowserHostPageInitializations } from './browser-host-page-initialization' +import { createBrowserHostClientPageRequest } from './browser-host-client-page-request' +import type { + BrowserClientPagePlacementReservation, + BrowserHostPagePlacementRegistry +} from '../reconciliation/browser-host-page-placement' +import { BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' import { BROWSER_HOST_WEBVIEW_CAPABILITY } from '../leases/browser-host-capability-selection' import { browserHostClientProfileFor } from '../profiles/browser-host-client-profile-metadata' import type { BrowserClientAutomationResult as BrowserClientHostCommandResult } from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' @@ -7,7 +19,6 @@ import { } from '../../../../shared/browser/client-hosting/browser-client-page-creation-timeouts' import { assertBrowserHostPageCommandAdmission } from '../commands/browser-host-page-command-admission' import type { BrowserHostLease, BrowserHostLeaseState } from '../leases/browser-host-lease-records' -import type { BrowserHostPagePlacementRegistry } from '../reconciliation/browser-host-page-placement' import type { RuntimeBrowserClientPlacement } from '../../../../shared/runtime-browser-placement' export type BrowserClientPageExecutionHostGrant = { @@ -25,6 +36,9 @@ export type BrowserHostClientPageCreateOptions = { requiredCapabilities?: readonly string[] timeoutMs?: number workspaceId?: string + signal?: AbortSignal + legacyRecovery?: BrowserHostLegacyPageRecoveryClaim + committed?(placement: RuntimeBrowserClientPlacement): void } export async function createBrowserHostClientPage( @@ -36,80 +50,191 @@ export async function createBrowserHostClientPage( ): BrowserHostLease requireLeaseState(lease: BrowserHostLease): BrowserHostLeaseState pagePlacements: BrowserHostPagePlacementRegistry + initializations: Pick< + BrowserHostPageInitializations, + 'whenPrepared' | 'retireUnpublished' | 'assertCreationAdmission' + > executionHostGrants: Map } ): Promise { - const timeoutMs = clientPageCreationTimeout(options.timeoutMs) - const lease = dependencies.selectLease(options.browserHostClientId, [ - BROWSER_HOST_WEBVIEW_CAPABILITY, - ...(options.requiredCapabilities ?? []) - ]) - if (lease.pairedDeviceId !== options.pairedDeviceId) { - throw new Error('browser_host_lease_stale') - } - if (lease.pageCommandProtocolVersion !== 1 || lease.pageReconciliationProtocolVersion !== 1) { - throw new Error('browser_host_reconciliation_protocol_required') - } - if (dependencies.executionHostGrants.has(options.browserPageId)) { - throw new Error('browser_page_replacement_requires_retirement') - } - const state = dependencies.requireLeaseState(lease) - const ledger = state.commandLedger - if (!ledger) { - throw new Error('browser_host_command_protocol_required') - } - const reservation = dependencies.pagePlacements.reserveNewClientPage(options.browserPageId, { - browserHostClientId: lease.browserHostClientId, - browserHostGeneration: lease.browserHostGeneration - }) - const grant = state.executionHostGrants.retain(options.executionHostKey) + const request = createBrowserHostClientPageRequest( + clientPageCreationTimeout(options.timeoutMs), + options.signal + ) + let reservation: BrowserClientPagePlacementReservation | undefined + let grant: { release(): void } | undefined let createIssued = false + let placement: RuntimeBrowserClientPlacement | undefined + let committedGrant: BrowserClientPageExecutionHostGrant | undefined + let state: BrowserHostLeaseState | undefined + let modern = false try { - const command = { - type: 'createPage' as const, - browserProfileId: options.browserProfileId, - ...browserHostClientProfileFor(lease, options.browserProfileId), - executionHostKey: options.executionHostKey, - ...(options.workspaceId ? { workspaceId: options.workspaceId } : {}) + request.current() + const lease = dependencies.selectLease(options.browserHostClientId, [ + BROWSER_HOST_WEBVIEW_CAPABILITY, + ...(options.requiredCapabilities ?? []) + ]) + if (lease.pairedDeviceId !== options.pairedDeviceId) { + throw new Error('browser_host_lease_stale') } - assertBrowserHostPageCommandAdmission(lease, command, (executionHostKey) => - state.executionHostGrants.require(executionHostKey) - ) - const issued = ledger.issue({ - browserPageId: options.browserPageId, - pageHostGeneration: reservation.placement.pageHostGeneration, - command, - resultAdmission: 'reserved-page' - }) - createIssued = true - const result = await waitForClientPageCreationResult(issued.result, timeoutMs) - if (result.status === 'failed') { - throw new Error(result.errorCode) + if (lease.pageCommandProtocolVersion !== 1 || lease.pageReconciliationProtocolVersion !== 1) { + throw new Error('browser_host_reconciliation_protocol_required') } + state = dependencies.requireLeaseState(lease) + const currentState = state + const ledger = state.commandLedger + if (!ledger) { + throw new Error('browser_host_command_protocol_required') + } + modern = lease.hostCapabilities.includes(BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY) + let result: Promise | undefined + const admit = (): void => { + request.current() + if ( + dependencies.requireLeaseState(lease) !== currentState || + currentState.commandLedger !== ledger || + currentState.lease.pairedDeviceId !== options.pairedDeviceId + ) { + throw new Error('browser_host_lease_stale') + } + if (dependencies.executionHostGrants.has(options.browserPageId)) { + throw new Error('browser_page_replacement_requires_retirement') + } + if (options.legacyRecovery) { + options.legacyRecovery.admit(options.browserPageId, currentState, request.cancel) + } else { + dependencies.initializations.assertCreationAdmission(options.browserPageId) + } + dependencies.pagePlacements.assertPlacementAdmission(options.browserPageId) + reservation = dependencies.pagePlacements.reserveNewClientPage(options.browserPageId, lease) + grant = currentState.executionHostGrants.retain(options.executionHostKey) + const command = { + type: 'createPage' as const, + ...(modern ? { initialUrl: 'about:blank' } : {}), + browserProfileId: options.browserProfileId, + ...browserHostClientProfileFor(currentState.lease, options.browserProfileId), + executionHostKey: options.executionHostKey, + ...(options.workspaceId ? { workspaceId: options.workspaceId } : {}) + } + assertBrowserHostPageCommandAdmission(currentState.lease, command, (key) => + currentState.executionHostGrants.require(key) + ) + result = ledger.issue( + { + browserPageId: options.browserPageId, + pageHostGeneration: reservation.placement.pageHostGeneration, + command, + resultAdmission: 'reserved-page' + }, + () => { + createIssued = true + } + ).result + } + if (modern) { + await dependencies.initializations.whenPrepared( + currentState, + options.browserProfileId, + options.executionHostKey, + request.signal, + admit + ) + } else { + admit() + } + const created = await request.wait(result!) + request.current() + if (created.status === 'failed') { + throw new Error(created.errorCode) + } + if (modern) { + const initialized = await request.wait( + ledger.issue({ + browserPageId: options.browserPageId, + pageHostGeneration: reservation!.placement.pageHostGeneration, + command: { type: 'navigate', url: 'about:blank' }, + resultAdmission: 'reserved-page' + }).result + ) + if (initialized.status === 'failed') { + throw new Error(initialized.errorCode) + } + } + request.current() dependencies.requireLeaseState(lease) - const placement = dependencies.pagePlacements.commitClientPageReservation(reservation) - dependencies.executionHostGrants.set(options.browserPageId, { + options.legacyRecovery?.validate(state) + placement = dependencies.pagePlacements.commitClientPageReservation(reservation!) + committedGrant = { placement, executionHostKey: options.executionHostKey, - release: grant.release - }) + release: grant!.release + } + dependencies.executionHostGrants.set(options.browserPageId, committedGrant) + if (options.legacyRecovery) { + options.legacyRecovery.commit(state, placement, () => options.committed?.(placement!)) + } else { + options.committed?.(placement) + } return placement } catch (error) { - try { - dependencies.pagePlacements.cancelClientPageReservation(reservation) - if (createIssued) { + if (reservation && state) { + if ( + committedGrant && + dependencies.executionHostGrants.get(options.browserPageId) === committedGrant + ) { + dependencies.executionHostGrants.delete(options.browserPageId) + } + if (createIssued && options.legacyRecovery) { + if ( + options.legacyRecovery.failed( + reservation.placement, + options.executionHostKey, + grant!.release + ) + ) { + grant = undefined + } + } else if (createIssued && modern) { + // Exact cleanup takes the slot and grant before the request gives up its reservation. + dependencies.initializations.retireUnpublished( + state, + { + browserPageId: options.browserPageId, + browserProfileId: options.browserProfileId, + workspaceId: options.workspaceId ?? '', + expected: reservation.placement, + url: 'about:blank', + isCurrent: () => false, + resolveExecutionHostKey: async () => null, + publish: () => {} + }, + { executionHostKey: options.executionHostKey, releaseGrant: grant!.release } + ) + grant = undefined + } else if (createIssued) { closeUncommittedClientPage(state, options.browserPageId, reservation.placement) - } else { + } else if (state.commandLedger) { retireUncommittedClientPage( - ledger, + state.commandLedger, options.browserPageId, reservation.placement.pageHostGeneration ) } - } finally { - grant.release() + if ( + placement && + dependencies.pagePlacements.getPlacement(options.browserPageId) === placement + ) { + dependencies.pagePlacements.completePageRetirement( + dependencies.pagePlacements.beginPageRetirement(options.browserPageId, placement) + ) + } + dependencies.pagePlacements.cancelClientPageReservation(reservation) } + grant?.release() throw error + } finally { + request.dispose() + options.legacyRecovery?.finish() } } @@ -146,49 +271,3 @@ export async function waitForClientPageCreationResult( } } } - -export function closeUncommittedClientPage( - state: BrowserHostLeaseState, - browserPageId: string, - placement: RuntimeBrowserClientPlacement -): void { - const ledger = state.commandLedger - if (!ledger) { - return - } - try { - const close = ledger.issue({ - browserPageId, - pageHostGeneration: placement.pageHostGeneration, - command: { - type: 'closePage', - targetAuthority: { - authorityRuntimeId: state.lease.authorityRuntimeId, - authorityEpoch: state.lease.authorityEpoch, - browserHostClientId: placement.browserHostClientId, - browserHostGeneration: placement.browserHostGeneration, - pageHostGeneration: placement.pageHostGeneration - } - }, - resultAdmission: 'reserved-page' - }) - void close.result.then( - () => retireUncommittedClientPage(ledger, browserPageId, placement.pageHostGeneration), - () => retireUncommittedClientPage(ledger, browserPageId, placement.pageHostGeneration) - ) - } catch { - retireUncommittedClientPage(ledger, browserPageId, placement.pageHostGeneration) - } -} - -function retireUncommittedClientPage( - ledger: NonNullable, - browserPageId: string, - pageHostGeneration: number -): void { - try { - ledger.retirePage(browserPageId, pageHostGeneration) - } catch { - // Best-effort close converges through authenticated inventory reconciliation. - } -} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-client-page-placement.ts b/src/main/runtime/browser-host/client-pages/browser-host-client-page-placement.ts index 1615908965d..18442c87c46 100644 --- a/src/main/runtime/browser-host/client-pages/browser-host-client-page-placement.ts +++ b/src/main/runtime/browser-host/client-pages/browser-host-client-page-placement.ts @@ -1,3 +1,4 @@ +import type { BrowserClientPageExecutionHostGrant } from './browser-host-client-page-creation' import type { BrowserHostLease } from '../leases/browser-host-lease-records' import type { BrowserHostPagePlacementRegistry } from '../reconciliation/browser-host-page-placement' import type { RuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement' @@ -23,3 +24,14 @@ export function placeBrowserHostClientPage(options: { browserHostGeneration: lease.browserHostGeneration }) } + +export function selectPageExecutionHostKey( + id: string, + grants: Map, + placements: BrowserHostPagePlacementRegistry +): string | undefined { + const grant = grants.get(id) + return grant && placements.getPlacement(id) === grant.placement + ? grant.executionHostKey + : undefined +} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-client-page-request.ts b/src/main/runtime/browser-host/client-pages/browser-host-client-page-request.ts new file mode 100644 index 00000000000..b05dde7f48f --- /dev/null +++ b/src/main/runtime/browser-host/client-pages/browser-host-client-page-request.ts @@ -0,0 +1,43 @@ +export function createBrowserHostClientPageRequest(timeoutMs: number, caller?: AbortSignal) { + const controller = new AbortController() + const deadline = Date.now() + timeoutMs + const abort = (): void => controller.abort(new Error('browser_host_page_creation_cancelled')) + const expire = (): void => controller.abort(new Error('browser_host_page_creation_timeout')) + const timer = setTimeout(expire, timeoutMs) + caller?.addEventListener('abort', abort, { once: true }) + if (caller?.aborted) { + abort() + } + const current = (): void => { + if (Date.now() >= deadline && !controller.signal.aborted) { + expire() + } + controller.signal.throwIfAborted() + } + return { + signal: controller.signal, + cancel: abort, + current, + async wait(result: Promise): Promise { + let remove = (): void => {} + const cancelled = new Promise((_resolve, reject) => { + const rejectAbort = (): void => reject(controller.signal.reason) + if (controller.signal.aborted) { + rejectAbort() + } else { + controller.signal.addEventListener('abort', rejectAbort, { once: true }) + remove = () => controller.signal.removeEventListener('abort', rejectAbort) + } + }) + try { + return await Promise.race([result, cancelled]) + } finally { + remove() + } + }, + dispose(): void { + clearTimeout(timer) + caller?.removeEventListener('abort', abort) + } + } +} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-admission.ts b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-admission.ts new file mode 100644 index 00000000000..9191244fb32 --- /dev/null +++ b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-admission.ts @@ -0,0 +1,193 @@ +import { BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import type { BrowserHostLeaseState } from '../leases/browser-host-lease-records' +import type { BrowserHostPagePlacementRegistry } from '../reconciliation/browser-host-page-placement' +import type { + Initialization, + InitializationRecord, + InitializationAdmission, + BrowserHostPageInitializationInput, + InitializationDisposition +} from './browser-host-page-initialization-records' +import { + sameInitializationPlacement, + currentInitializationRequest +} from './browser-host-page-initialization-records' + +export type InitializationAdmissionOwner = { + records: Map + removeRecord(record: InitializationRecord): void + join(state: BrowserHostLeaseState, record: InitializationRecord): void + placements: BrowserHostPagePlacementRegistry + requireState(state: BrowserHostLeaseState): void + observer( + state: BrowserHostLeaseState, + input: BrowserHostPageInitializationInput + ): InitializationAdmission + cancelOperation(operation: Initialization): void + releaseAdmission(admission: InitializationAdmission): void + enqueue(record: InitializationRecord, admission: InitializationAdmission): void + resume(state: BrowserHostLeaseState): void + startClosedCleanup( + record: InitializationRecord, + state: BrowserHostLeaseState, + releaseGrant?: () => void + ): void +} + +export function admitInitialization( + owner: InitializationAdmissionOwner, + state: BrowserHostLeaseState, + input: BrowserHostPageInitializationInput +): InitializationDisposition { + owner.requireState(state) + if (!state.lease.hostCapabilities.includes(BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY)) { + throw new Error('browser_client_page_initialization_unsupported') + } + if (input.signal?.aborted || !input.isCurrent()) { + return 'stale' + } + let record = owner.records.get(input.browserPageId) + if (record && record.pairedDeviceId !== state.lease.pairedDeviceId) { + return 'stale' + } + if (record?.legacyAttempt) { + return 'stale' + } + if (record?.closed) { + owner.join(state, record) + record.observer = owner.observer(state, input) + owner.resume(state) + return 'closed' + } + if (record && !sameInitializationPlacement(record.expected, input.expected)) { + return 'stale' + } + const requested = !input.replace && currentInitializationRequest(record, state) + if (requested) { + input = { ...input, url: requested.input.url } + } + if (record) { + owner.join(state, record) + } + if (record?.stopped && record.scope === state && !record.authorityRetired && !input.replace) { + owner.join(state, record) + return 'joined-existing' + } + const operation = record?.operation + if ( + operation && + operation.admission.state === state && + operation.admission.connectionToken === state.connectionToken && + !input.replace && + !operation.controller.signal.aborted + ) { + record!.observer = owner.observer(state, input) + return 'joined-existing' + } + if (operation) { + owner.cancelOperation(operation) + } + if (record?.successor) { + owner.releaseAdmission(record.successor) + } + const admission = owner.observer(state, input) + admission.reservation = owner.placements.reserveNewClientPage( + input.browserPageId, + state.lease, + input.pageHostGeneration + ) + if (!record) { + record = { + browserPageId: input.browserPageId, + browserProfileId: input.browserProfileId, + workspaceId: input.workspaceId, + expected: input.expected, + pairedDeviceId: state.lease.pairedDeviceId, + browserHostClientId: state.lease.browserHostClientId, + closed: false, + authorityRetired: false, + scope: state, + target: input.previous + } + owner.records.set(input.browserPageId, record) + } + record.releaseCleanup ??= owner.placements.claimClientPageCleanup(input.browserPageId) + owner.join(state, record) + const owned = record + const abort = (): void => { + if (owned.successor === admission) { + owned.successor = undefined + owner.releaseAdmission(admission) + } + if (owned.operation?.admission === admission) { + owner.cancelOperation(owned.operation) + } + } + admission.removeAbort = () => input.signal?.removeEventListener('abort', abort) + input.signal?.addEventListener('abort', abort, { once: true }) + record.observer = admission + record.stopped = false + try { + input.claim?.() + } catch (error) { + owner.releaseAdmission(admission) + if (!operation) { + record.releaseCleanup?.() + owner.removeRecord(record) + } + throw error + } + if (operation) { + record.successor = admission + input.changed?.() + owner.resume(state) + return 'successor-pending' + } + if (record.authorityRetired) { + record.target = undefined + } + record.browserHostClientId = state.lease.browserHostClientId + record.authorityRetired = false + record.target ??= input.previous + record.targetExecutionHostKey ??= input.previous?.executionHostKey + owner.enqueue(record, admission) + input.changed?.() + return 'queued' +} + +export function retireUnpublishedInitialization( + owner: InitializationAdmissionOwner, + state: BrowserHostLeaseState, + input: BrowserHostPageInitializationInput, + cleanup?: { executionHostKey: string; releaseGrant(): void } +): void { + const placement = input.expected + const record: InitializationRecord = { + browserPageId: input.browserPageId, + browserProfileId: input.browserProfileId, + workspaceId: input.workspaceId, + expected: placement, + pairedDeviceId: state.lease.pairedDeviceId, + browserHostClientId: state.lease.browserHostClientId, + closed: true, + authorityRetired: false, + scope: state, + targetExecutionHostKey: cleanup?.executionHostKey, + target: { + ...placement, + authorityRuntimeId: state.lease.authorityRuntimeId, + authorityEpoch: state.lease.authorityEpoch + } + } + if (owner.records.has(input.browserPageId)) { + throw new Error('browser_page_reconciliation_reservation_pending') + } + if (state.commandLedger?.isClosed()) { + cleanup?.releaseGrant() + return + } + record.releaseCleanup = owner.placements.claimClientPageCleanup(input.browserPageId) + owner.records.set(input.browserPageId, record) + owner.join(state, record) + owner.startClosedCleanup(record, state, cleanup?.releaseGrant) +} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-attachment.ts b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-attachment.ts new file mode 100644 index 00000000000..45b255dee73 --- /dev/null +++ b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-attachment.ts @@ -0,0 +1,66 @@ +import { BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import type { BrowserClientHostedPageInventory } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import type { BrowserHostLeaseState } from '../leases/browser-host-lease-records' +import type { + Initialization, + InitializationRecord +} from './browser-host-page-initialization-records' +import { + sameInitializationTarget, + currentInitializationRequest +} from './browser-host-page-initialization-records' +import { forgetAbsentInitializationTarget } from './browser-host-page-initialization-commands' + +export function observeInitializationAttach( + records: Map, + state: BrowserHostLeaseState, + inventory: readonly BrowserClientHostedPageInventory[] | undefined, + cancel: (operation: Initialization) => void, + removeRecord: (record: InitializationRecord) => void +): void { + const byPage = inventory && new Map(inventory.map((page) => [page.browserPageId, page])) + for (const record of records.values()) { + if ( + record.browserHostClientId !== state.lease.browserHostClientId || + record.pairedDeviceId !== state.lease.pairedDeviceId + ) { + continue + } + const operation = record.operation + if ( + operation && + operation.admission.connectionToken !== state.connectionToken && + !( + state.lease.hostCapabilities.includes(BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY) && + currentInitializationRequest(record, state) + ) + ) { + cancel(operation) + } + const target = record.target + if (!target || !byPage || sameInitializationTarget(target, byPage.get(record.browserPageId))) { + continue + } + // A dispatcher-queued create is absent from inventory but can still allocate on replay. + if ( + operation && + state.commandLedger?.hasOutstandingPageCreation( + record.browserPageId, + target.pageHostGeneration + ) + ) { + continue + } + if (operation) { + forgetAbsentInitializationTarget(operation, target) + if (operation.admission.state === state) { + state.commandLedger?.retirePage(record.browserPageId, target.pageHostGeneration) + } + } else { + record.target = undefined + record.releaseCleanup?.() + record.releaseCleanup = undefined + removeRecord(record) + } + } +} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-closed-cleanup.ts b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-closed-cleanup.ts new file mode 100644 index 00000000000..234cc293c4e --- /dev/null +++ b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-closed-cleanup.ts @@ -0,0 +1,37 @@ +import type { + Initialization, + InitializationRecord, + InitializationAdmission, + BrowserHostPageInitializationInput +} from './browser-host-page-initialization-records' + +export function createInitializationCleanup( + record: InitializationRecord, + observer: (input: BrowserHostPageInitializationInput) => InitializationAdmission, + releaseGrant?: () => void, + preserveContinuation?: () => boolean +): Initialization { + const admission = observer({ + browserPageId: record.browserPageId, + browserProfileId: record.browserProfileId, + workspaceId: record.workspaceId, + expected: record.expected, + url: '', + isCurrent: () => false, + resolveExecutionHostKey: async () => null, + publish: () => {} + }) + const operation: Initialization = { + record, + admission, + controller: new AbortController(), + running: false, + executing: false, + detached: false, + phase: 'cleanup', + preserveContinuation, + releaseGrant + } + operation.controller.abort() + return operation +} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-commands.ts b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-commands.ts new file mode 100644 index 00000000000..2aa7559e34c --- /dev/null +++ b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-commands.ts @@ -0,0 +1,149 @@ +import type { BrowserClientHostCommandEvent } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import type { + Initialization, + InitializationTarget +} from './browser-host-page-initialization-records' +import { + sameInitializationTarget, + notifyInitialization +} from './browser-host-page-initialization-records' +import { assertBrowserHostPageCommandAdmission } from '../commands/browser-host-page-command-admission' + +export function issueInitializationCommand( + operation: Initialization, + current: () => void, + command: BrowserClientHostCommandEvent['command'] +) { + const { state, input, reservation } = operation.admission + current() + if (!reservation) { + throw new Error('browser_page_reconciliation_reservation_stale') + } + assertBrowserHostPageCommandAdmission(state.lease, command, (key) => + state.executionHostGrants.require(key) + ) + const issued = state.commandLedger!.issue( + { + browserPageId: input.browserPageId, + pageHostGeneration: reservation.placement.pageHostGeneration, + command, + resultAdmission: 'reserved-page' + }, + () => { + if (command.type === 'createPage') { + operation.record.target = { + authorityRuntimeId: state.lease.authorityRuntimeId, + authorityEpoch: state.lease.authorityEpoch, + ...reservation.placement + } + operation.record.targetExecutionHostKey = command.executionHostKey + } + } + ) + return awaitInitializationResult( + issued.result.then((result) => { + if (result.status === 'failed') { + throw new Error(result.errorCode) + } + return result + }), + operation.controller.signal + ) +} + +export function closeInitializationGuest(operation: Initialization): Promise { + const { record, admission } = operation + const target = record.target + if (!target || operation.detached) { + return Promise.resolve() + } + if (operation.closing && sameInitializationTarget(operation.closing.target, target)) { + return operation.closing.result + } + const { state, input } = admission + const issued = state.commandLedger!.issue( + { + browserPageId: record.browserPageId, + pageHostGeneration: target.pageHostGeneration, + command: { type: 'closePage', targetAuthority: target }, + resultAdmission: 'reconciliation' + }, + () => { + operation.cleanupAttempt = { + target, + connectionToken: state.connectionToken, + closed: record.closed, + stopped: Boolean(record.stopped) + } + if (operation.cleanupRequested === state.connectionToken) { + operation.cleanupRequested = undefined + } + } + ) + const absent = Promise.withResolvers() + const timer = setTimeout(() => { + if (!operation.detached && record.operation === operation) { + notifyInitialization(record, 'cleanup-unknown') + } + }, input.closeTimeoutMs ?? 15_000) + const raw = issued.result.then((result) => { + if (result.status === 'failed') { + throw new Error(result.errorCode) + } + if (!operation.detached) { + state.commandLedger!.retirePage(record.browserPageId, target.pageHostGeneration) + } + }) + const result = Promise.race([raw, absent.promise]) + .then(() => { + if (!operation.detached && sameInitializationTarget(record.target, target)) { + operation.cleanupAttempt = undefined + operation.cleanupRequested = undefined + operation.retiredTarget = target + record.target = undefined + record.targetExecutionHostKey = undefined + } + }) + .finally(() => { + clearTimeout(timer) + if (operation.closing?.result === result) { + operation.closing = undefined + } + }) + operation.closing = { target, absent: () => absent.resolve(), result } + return result +} + +export function forgetAbsentInitializationTarget( + operation: Initialization, + target: InitializationTarget +): void { + if (sameInitializationTarget(operation.record.target, target)) { + operation.retiredTarget = target + operation.record.target = undefined + } + if (operation.closing && sameInitializationTarget(operation.closing.target, target)) { + operation.closing.absent() + } +} + +export async function awaitInitializationResult( + result: Promise, + signal: AbortSignal +): Promise { + let remove = (): void => {} + const aborted = new Promise((_resolve, reject) => { + const abort = (): void => reject(new Error('browser_client_page_initialization_cancelled')) + if (signal.aborted) { + abort() + } else { + signal.addEventListener('abort', abort, { once: true }) + remove = () => signal.removeEventListener('abort', abort) + } + }) + try { + return await Promise.race([result, aborted]) + } finally { + remove() + } +} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-completion.ts b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-completion.ts new file mode 100644 index 00000000000..44a0780a32b --- /dev/null +++ b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-completion.ts @@ -0,0 +1,141 @@ +import type { BrowserHostLeaseState } from '../leases/browser-host-lease-records' +import type { + InitializationRecord, + InitializationOutcome +} from './browser-host-page-initialization-records' + +type Completion = { + state: BrowserHostLeaseState + members: Set + sealed: boolean + blocked: boolean + complete: () => void +} + +export class BrowserHostInitializationCompletion { + private readonly contexts = new Map() + private readonly contextsByDevice = new Map>() + private readonly contextsByRecord = new WeakMap>() + + begin( + state: BrowserHostLeaseState, + records: Iterable, + complete: () => void + ): void { + const previous = this.contexts.get(state.token) + if (previous) { + this.remove(previous) + } + const members = new Set() + for (const record of records) { + if ( + !isCleanStoppedInitialization(record) && + (record.scope === state || + record.operation?.admission.state === state || + record.successor?.state === state) + ) { + members.add(record) + } + } + const context = { state, members, sealed: false, blocked: false, complete } + this.contexts.set(state.token, context) + const device = state.lease.pairedDeviceId + const contexts = this.contextsByDevice.get(device) ?? new Set() + contexts.add(context) + this.contextsByDevice.set(device, contexts) + for (const record of members) { + this.link(context, record) + } + } + + join(state: BrowserHostLeaseState, record: InitializationRecord): void { + const context = this.contexts.get(state.token) + if (context) { + this.link(context, record) + } + } + + pendingRecords(state: BrowserHostLeaseState): Iterable { + return this.contexts.get(state.token)?.members ?? [] + } + + seal(state: BrowserHostLeaseState, blocked: boolean): void { + const context = this.contexts.get(state.token) + if (!context) { + return + } + context.sealed = true + context.blocked = blocked + this.publish(state.lease.pairedDeviceId) + } + + settle(record: InitializationRecord, outcome: InitializationOutcome): void { + if (outcome !== 'published' && outcome !== 'retained-clean' && outcome !== 'disposed') { + return + } + this.publishDevices(this.unlink(record)) + } + + unlink(record: InitializationRecord): Set { + const devices = new Set() + const contexts = this.contextsByRecord.get(record) + this.contextsByRecord.delete(record) + for (const context of contexts ?? []) { + context.members.delete(record) + devices.add(context.state.lease.pairedDeviceId) + } + return devices + } + + retire(state: BrowserHostLeaseState): void { + const context = this.contexts.get(state.token) + if (context) { + this.remove(context) + } + } + + publishDevices(devices: Iterable): void { + for (const device of devices) { + this.publish(device) + } + } + + private link(context: Completion, record: InitializationRecord): void { + if (isCleanStoppedInitialization(record)) { + return + } + context.members.add(record) + const contexts = this.contextsByRecord.get(record) ?? new Set() + contexts.add(context) + this.contextsByRecord.set(record, contexts) + } + + private remove(context: Completion): void { + this.contexts.delete(context.state.token) + const device = context.state.lease.pairedDeviceId + const contexts = this.contextsByDevice.get(device) + contexts?.delete(context) + if (!contexts?.size) { + this.contextsByDevice.delete(device) + } + for (const record of context.members) { + this.contextsByRecord.get(record)?.delete(context) + } + context.members.clear() + } + + private publish(device: string): void { + let completed: Completion | undefined + for (const context of this.contextsByDevice.get(device) ?? []) { + if (!context.sealed || context.blocked || context.members.size) { + return + } + completed = context + } + completed?.complete() + } +} + +function isCleanStoppedInitialization(record: InitializationRecord): boolean { + return Boolean(record.stopped && !record.target && !record.operation && !record.successor) +} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-controls.ts b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-controls.ts new file mode 100644 index 00000000000..bf4ba5605e7 --- /dev/null +++ b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-controls.ts @@ -0,0 +1,289 @@ +import { BrowserHostPagePartitionPreparation } from './browser-host-page-partition-preparation' +import { BrowserHostInitializationCompletion } from './browser-host-page-initialization-completion' +import type { RuntimeBrowserClientPlacement } from '../../../../shared/runtime-browser-placement' +import type { BrowserHostLeaseState } from '../leases/browser-host-lease-records' +import { + sameInitializationPlacement, + initializationCleanupServesCurrentDemand, + type InitializationAdmission, + type BrowserHostPageInitializationInput, + type Initialization, + type InitializationRecord +} from './browser-host-page-initialization-records' + +/** Presentation and controls refer to the same current initializer records as transaction scheduling. */ +export abstract class BrowserHostPageInitializationControls { + protected readonly preparation = new BrowserHostPagePartitionPreparation() + protected readonly completion = new BrowserHostInitializationCompletion() + protected readonly attachments = new Map() + protected readonly records = new Map() + constructor( + protected readonly requireState: (state: BrowserHostLeaseState) => void, + protected readonly changed: (workspaceId: string) => void + ) {} + protected removeRecord(record: InitializationRecord, affected?: Set): void { + if (this.records.get(record.browserPageId) !== record) { + return + } + this.records.delete(record.browserPageId) + const devices = this.completion.unlink(record) + if (affected) { + for (const device of devices) { + affected.add(device) + } + } else { + this.completion.publishDevices(devices) + } + } + + protected abstract cancelOperation(operation: Initialization): void + protected abstract clean(operation: Initialization): void + resume(state: BrowserHostLeaseState): void { + for (const record of this.records.values()) { + const operation = record.operation + if (operation?.admission.state === state && operation.controller.signal.aborted) { + if (initializationCleanupServesCurrentDemand(operation)) { + continue + } + if (operation.executing) { + operation.cleanupRequested = state.connectionToken + } else { + this.clean(operation) + } + } else if ( + !operation && + record.closed && + record.target && + record.browserHostClientId === state.lease.browserHostClientId + ) { + this.startClosedCleanup(record, state) + } + } + } + + protected abstract releaseAdmission(admission: InitializationAdmission): void + protected abstract startClosedCleanup( + record: InitializationRecord, + state: BrowserHostLeaseState, + releaseGrant?: () => void + ): void + + protected observer( + state: BrowserHostLeaseState, + input: BrowserHostPageInitializationInput + ): InitializationAdmission { + return { state, input, connectionToken: state.connectionToken, removeAbort: () => {} } + } + + assertCreationAdmission(id: string): void { + if (this.records.has(id)) { + throw new Error('browser_page_replacement_requires_retirement') + } + } + + whenPrepared( + state: BrowserHostLeaseState, + profile: string, + key: string, + signal: AbortSignal, + admit: () => void + ): Promise { + return new Promise((resolve, reject) => { + let generation = 0 + let settled = false + let remove = (): void => {} + const finish = (error?: unknown): void => { + if (settled) { + return + } + settled = true + signal.removeEventListener('abort', abort) + remove() + if (error === undefined) { + resolve() + } else { + reject(error) + } + } + const abort = (): void => finish(signal.reason) + const register = (): void => { + const ticket = ++generation + const unregister = this.preparation.wait( + state, + profile, + key, + () => { + try { + this.requireState(state) + signal.throwIfAborted() + // Earlier ready listeners can synchronously replace/unseal this connection. + if (!this.preparation.isPrepared(state, profile, key)) { + register() + return + } + admit() + finish() + } catch (error) { + finish(error) + } + }, + () => finish(new Error('browser_host_lease_stale')) + ) + if (settled || ticket !== generation) { + unregister() + } else { + remove = unregister + } + } + signal.addEventListener('abort', abort, { once: true }) + if (signal.aborted) { + abort() + } else { + register() + } + }) + } + + status(id: string, expected: RuntimeBrowserClientPlacement): 'pending' | 'stopped' | undefined { + const record = this.records.get(id) + return record && sameInitializationPlacement(record.expected, expected) + ? this.presentation(record) + : undefined + } + + lease(id: string, expected: RuntimeBrowserClientPlacement) { + const record = this.records.get(id) + if ( + !record || + record.closed || + !sameInitializationPlacement(record.expected, expected) || + !record.scope + ) { + return undefined + } + try { + this.requireState(record.scope) + return record.scope.lease + } catch { + return undefined + } + } + + capture( + id: string, + expected: RuntimeBrowserClientPlacement + ): ((placement: RuntimeBrowserClientPlacement) => boolean) | undefined { + const record = this.records.get(id) + if (!record || !this.lease(id, expected)) { + return undefined + } + return (placement) => + !record.closed && + !record.authorityRetired && + sameInitializationPlacement(record.expected, placement) + } + + stop(id: string, expected: RuntimeBrowserClientPlacement): boolean { + const record = this.records.get(id) + if (!record || !this.lease(id, expected)) { + return false + } + if (record.stopped && !record.successor) { + return true + } + record.stopped = true + if (record.successor) { + this.releaseAdmission(record.successor) + record.successor = undefined + } + if (record.operation) { + this.cancelOperation(record.operation) + this.resume(record.operation.admission.state) + } + this.changed(record.workspaceId) + return true + } + + cancel(id: string, expected: RuntimeBrowserClientPlacement): boolean { + const record = this.records.get(id) + if (!record || !sameInitializationPlacement(record.expected, expected)) { + return false + } + if (record.legacyAttempt) { + record.closed = true + record.legacyAttempt.cancel() + return true + } + if (record.authorityRetired && !record.operation && !record.successor) { + this.removeRecord(record) + return true + } + record.closed = true + if (record.successor) { + this.releaseAdmission(record.successor) + record.successor = undefined + } + if (record.operation) { + this.cancelOperation(record.operation) + this.resume(record.operation.admission.state) + } else { + const state = this.attachments.get(record.browserHostClientId) + if (state && record.target) { + this.startClosedCleanup(record, state) + } else if (!record.target) { + record.releaseCleanup?.() + this.removeRecord(record) + } + } + return true + } + + fence(state: BrowserHostLeaseState): void { + const changed = new Set() + for (const record of this.records.values()) { + if (record.operation?.admission.state === state) { + if (this.presentation(record)) { + changed.add(record.workspaceId) + } + this.cancelOperation(record.operation) + } + } + for (const workspace of changed) { + this.changed(workspace) + } + } + + protected presentation( + record: InitializationRecord | undefined + ): 'pending' | 'stopped' | undefined { + if (!record || record.closed || record.authorityRetired || !record.scope) { + return undefined + } + try { + this.requireState(record.scope) + } catch { + return undefined + } + if (record.stopped) { + return 'stopped' + } + const operation = record.operation + const admission = record.successor ?? operation?.admission + if ( + !admission || + admission.input.signal?.aborted || + admission.connectionToken !== admission.state.connectionToken + ) { + return undefined + } + if (record.successor) { + return 'pending' + } + return operation && + !operation.controller.signal.aborted && + !operation.cleanupFailed && + operation.phase !== 'published' + ? 'pending' + : undefined + } +} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-lifetime.ts b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-lifetime.ts new file mode 100644 index 00000000000..6710058abb3 --- /dev/null +++ b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-lifetime.ts @@ -0,0 +1,77 @@ +import type { BrowserHostLeaseState } from '../leases/browser-host-lease-records' +import type { + InitializationAdmission, + InitializationRecord, + InitializationQueue +} from './browser-host-page-initialization-records' + +export function retireInitializationAuthority( + records: Map, + queues: Map, + attachments: Map, + releaseAdmission: (admission: InitializationAdmission) => void, + enqueue: (record: InitializationRecord, admission: InitializationAdmission) => void, + claimCleanup: (id: string) => () => void, + removeRecord: (record: InitializationRecord) => void, + state: BrowserHostLeaseState +): void { + if (attachments.get(state.lease.browserHostClientId) === state) { + attachments.delete(state.lease.browserHostClientId) + } + for (const record of records.values()) { + if (record.successor?.state === state) { + releaseAdmission(record.successor) + record.successor = undefined + } + if (record.scope !== state) { + continue + } + record.legacyAttempt?.cancel() + record.legacyAttempt = undefined + const operation = record.operation + if (operation?.admission.state === state) { + operation.removePreparation?.() + operation.removePreparation = undefined + operation.detached = true + operation.cleanupRequested = undefined + operation.cleanupAttempt = undefined + operation.controller.abort() + releaseAdmission(operation.admission) + operation.releaseGrant?.() + operation.releaseGrant = undefined + operation.closing?.absent() + record.operation = undefined + } + record.releaseCleanup?.() + record.releaseCleanup = undefined + record.target = undefined + record.targetExecutionHostKey = undefined + record.scope = undefined + record.observer = undefined + record.authorityRetired = true + const successor = record.successor + record.successor = undefined + if ( + successor && + !record.closed && + !successor.input.signal?.aborted && + successor.connectionToken === successor.state.connectionToken && + successor.input.isCurrent() + ) { + record.browserHostClientId = successor.state.lease.browserHostClientId + record.authorityRetired = false + record.target = successor.input.previous + record.observer = successor + record.releaseCleanup = claimCleanup(record.browserPageId) + enqueue(record, successor) + } else { + if (successor) { + releaseAdmission(successor) + } + if (record.closed || record.stopped) { + removeRecord(record) + } + } + } + queues.delete(state.token) +} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-queue.ts b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-queue.ts new file mode 100644 index 00000000000..e70328efdfa --- /dev/null +++ b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-queue.ts @@ -0,0 +1,87 @@ +import type { BrowserHostPagePartitionPreparation } from './browser-host-page-partition-preparation' +import type { + Initialization, + InitializationOutcome, + InitializationQueue +} from './browser-host-page-initialization-records' + +export function scheduleInitializationQueue( + queues: Map, + token: symbol, + queue: InitializationQueue, + run: (operation: Initialization) => Promise, + settle: (operation: Initialization, outcome: InitializationOutcome) => void +): void { + if (queue.scheduled) { + return + } + queue.scheduled = true + queueMicrotask(() => { + queue.scheduled = false + if (queues.get(token) !== queue) { + return + } + while (queue.active < 4 && queue.pending.length) { + const operation = queue.pending.shift()! + if (operation.detached || operation.record.operation !== operation) { + continue + } + operation.running = true + operation.executing = true + queue.active += 1 + void run(operation).then((outcome) => settle(operation, outcome)) + } + if (!queue.active && !queue.pending.length) { + queues.delete(token) + } + }) +} + +/** Waiting for exact predecessor joins holds no shared execution permit. */ +export function waitForInitializationPartition( + operation: Initialization, + preparation: BrowserHostPagePartitionPreparation, + queues: Map, + schedule: (token: symbol, queue: InitializationQueue) => void +): void { + operation.executing = false + operation.phase = 'waiting' + const { record, admission } = operation + const queue = queues.get(admission.state.token) + if (operation.running && queue) { + queue.active -= 1 + operation.running = false + } + preparation.joined(admission.state, record) + operation.removePreparation = preparation.wait( + admission.state, + record.browserProfileId, + operation.executionHostKey!, + () => { + if (operation.detached || record.operation !== operation) { + return + } + operation.removePreparation = undefined + operation.phase = 'create' + const readyQueue = initializationQueue(queues, admission.state.token) + readyQueue.pending.push(operation) + schedule(admission.state.token, readyQueue) + }, + () => {} + ) + if (queue) { + schedule(admission.state.token, queue) + } +} + +export function initializationQueue( + queues: Map, + token: symbol +): InitializationQueue { + let queue = queues.get(token) + if (!queue) { + queue = { pending: [], active: 0, scheduled: false } + queues.set(token, queue) + } + return queue +} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-records.ts b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-records.ts new file mode 100644 index 00000000000..92f6eef5828 --- /dev/null +++ b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-records.ts @@ -0,0 +1,213 @@ +import type { + BrowserClientHostedPageInventory, + BrowserClientHostLeaseAuthority +} from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import type { RuntimeBrowserClientPlacement } from '../../../../shared/runtime-browser-placement' +import type { BrowserHostLeaseState } from '../leases/browser-host-lease-records' +import type { BrowserClientPagePlacementReservation } from '../reconciliation/browser-host-page-placement' + +export type InitializationOutcome = + | 'prepared' + | 'published' + | 'retained-clean' + | 'disposed' + | 'cleanup-unknown' + | 'route-parked' + +export type InitializationDisposition = + | 'queued' + | 'joined-existing' + | 'successor-pending' + | 'closed' + | 'stale' + +export type InitializationTarget = BrowserClientHostLeaseAuthority & { + pageHostGeneration: number +} + +export type BrowserHostPageInitializationInput = { + browserPageId: string + browserProfileId: string + workspaceId: string + expected: RuntimeBrowserClientPlacement + url: string + pageHostGeneration?: number + previous?: BrowserClientHostedPageInventory + creationTimeoutMs?: number + closeTimeoutMs?: number + signal?: AbortSignal + replace?: boolean + changed?(): void + claim?(): void + isCurrent(): boolean + resolveExecutionHostKey(): Promise + publish(placement: RuntimeBrowserClientPlacement, url: string, executionHostKey: string): void + settled?(outcome: InitializationOutcome): void + completed?(url?: string): void +} + +export type InitializationAdmission = { + state: BrowserHostLeaseState + connectionToken: symbol + input: BrowserHostPageInitializationInput + reservation?: BrowserClientPagePlacementReservation + removeAbort(): void +} + +export type Initialization = { + admission: InitializationAdmission + record: InitializationRecord + controller: AbortController + running: boolean + executing: boolean + detached: boolean + phase: 'cleanup' | 'waiting' | 'create' | 'published' + executionHostKey?: string + removePreparation?: () => void + cleanupFailed?: boolean + cleanupRequested?: symbol + cleanupAttempt?: { + target: InitializationTarget + connectionToken: symbol + closed: boolean + stopped: boolean + } + preserveContinuation?(): boolean + retiredTarget?: InitializationTarget + releaseGrant?: () => void + closing?: { target: InitializationTarget; absent: () => void; result: Promise } +} + +export type InitializationRecord = { + browserPageId: string + browserProfileId: string + workspaceId: string + expected: RuntimeBrowserClientPlacement + pairedDeviceId: string + browserHostClientId: string + closed: boolean + authorityRetired: boolean + legacyAttempt?: { state: BrowserHostLeaseState; cancel(): void } + stopped?: boolean + scope?: BrowserHostLeaseState + releaseCleanup?: () => void + target?: InitializationTarget + targetExecutionHostKey?: string + operation?: Initialization + successor?: InitializationAdmission + observer?: InitializationAdmission +} + +export type InitializationQueue = { + pending: Initialization[] + active: number + scheduled: boolean +} + +export function sameInitializationTarget( + left: InitializationTarget | undefined, + right: InitializationTarget | undefined +): boolean { + return Boolean( + left && + right && + left.authorityRuntimeId === right.authorityRuntimeId && + left.authorityEpoch === right.authorityEpoch && + left.browserHostClientId === right.browserHostClientId && + left.browserHostGeneration === right.browserHostGeneration && + left.pageHostGeneration === right.pageHostGeneration + ) +} + +export function sameInitializationPlacement( + left: { + kind: string + browserHostClientId?: string + browserHostGeneration?: number + pageHostGeneration?: number + }, + right: RuntimeBrowserClientPlacement +): left is RuntimeBrowserClientPlacement { + return ( + left.kind === 'client' && + left.browserHostClientId === right.browserHostClientId && + left.browserHostGeneration === right.browserHostGeneration && + left.pageHostGeneration === right.pageHostGeneration + ) +} + +export function notifyInitialization( + record: InitializationRecord, + outcome: InitializationOutcome +): void { + const observer = record.observer + if ( + observer && + !observer.input.signal?.aborted && + observer.connectionToken === observer.state.connectionToken + ) { + observer.input.settled?.(outcome) + } +} + +export function canContinueInitialization( + record: InitializationRecord | undefined, + expected: RuntimeBrowserClientPlacement, + lease: { pairedDeviceId: string }, + live: unknown +): boolean { + return Boolean( + record?.authorityRetired && + !record.closed && + record.pairedDeviceId === lease.pairedDeviceId && + sameInitializationPlacement(record.expected, expected) && + !live + ) +} + +export function initializationCleanupServesCurrentDemand(operation: Initialization): boolean { + const attempt = operation.cleanupAttempt + return Boolean( + attempt && + sameInitializationTarget(attempt.target, operation.record.target) && + attempt.connectionToken === operation.admission.state.connectionToken && + attempt.closed === operation.record.closed && + attempt.stopped === Boolean(operation.record.stopped) + ) +} + +/** A reconnect can transfer command intent, never a predecessor's cleanup input. */ +export function currentInitializationRequest( + record: InitializationRecord | undefined, + state: BrowserHostLeaseState +): InitializationAdmission | undefined { + if ( + !record || + record.closed || + record.stopped || + record.authorityRetired || + record.pairedDeviceId !== state.lease.pairedDeviceId + ) { + return undefined + } + const operation = record.operation + const admission = + record.successor ?? + (operation && + !operation.detached && + !operation.controller.signal.aborted && + operation.phase !== 'published' + ? operation.admission + : undefined) + const input = admission?.input + return admission && + input && + !input.signal?.aborted && + input.isCurrent() && + input.workspaceId === record.workspaceId && + input.browserProfileId === record.browserProfileId && + admission.state.lease.pairedDeviceId === record.pairedDeviceId && + sameInitializationPlacement(input.expected, record.expected) + ? admission + : undefined +} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-settlement.ts b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-settlement.ts new file mode 100644 index 00000000000..ee616168e5d --- /dev/null +++ b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-settlement.ts @@ -0,0 +1,105 @@ +import type { BrowserHostLeaseState } from '../leases/browser-host-lease-records' +import type { InitializationTransactionAuthority } from './browser-host-page-initialization-transaction' +import type { + Initialization, + InitializationOutcome, + InitializationAdmission, + InitializationRecord, + InitializationQueue +} from './browser-host-page-initialization-records' +import { + sameInitializationPlacement, + initializationCleanupServesCurrentDemand, + sameInitializationTarget, + notifyInitialization +} from './browser-host-page-initialization-records' + +export function settleInitialization( + operation: Initialization, + outcome: InitializationOutcome, + owner: { + authority: Omit + removeRecord(record: InitializationRecord): void + completed(record: InitializationRecord, outcome: InitializationOutcome): void + queues: Map + retireLease(state: BrowserHostLeaseState): void + clean(operation: Initialization): void + releaseAdmission(admission: InitializationAdmission): void + enqueue(record: InitializationRecord, admission: InitializationAdmission): void + schedule(token: symbol, queue: InitializationQueue): void + } +): void { + operation.executing = false + const { record, admission } = operation + if (operation.detached || record.operation !== operation) { + return + } + if (admission.state.commandLedger?.isClosed()) { + owner.retireLease(admission.state) + return + } + if (outcome === 'cleanup-unknown') { + notifyInitialization(record, outcome) + if ( + operation.cleanupRequested === admission.state.connectionToken && + !initializationCleanupServesCurrentDemand(operation) + ) { + owner.clean(operation) + } + return + } + if (record.closed) { + const placement = owner.authority.placements.getPlacement(record.browserPageId) + if (placement && sameInitializationPlacement(placement, record.expected)) { + owner.authority.retirePlacement(record.browserPageId, placement) + } + } + owner.releaseAdmission(admission) + operation.releaseGrant?.() + operation.releaseGrant = undefined + record.operation = undefined + const queue = owner.queues.get(admission.state.token) + if (operation.running && queue) { + queue.active -= 1 + } + const successor = record.successor + record.successor = undefined + if ( + successor && + !record.closed && + !successor.input.signal?.aborted && + successor.connectionToken === successor.state.connectionToken && + successor.input.isCurrent() + ) { + record.browserHostClientId = successor.state.lease.browserHostClientId + record.authorityRetired = false + if ( + !record.target && + !sameInitializationTarget(successor.input.previous, operation.retiredTarget) + ) { + record.target = successor.input.previous + } + owner.enqueue(record, successor) + } else { + if (successor) { + owner.releaseAdmission(successor) + } + const terminalOutcome = record.closed ? 'disposed' : outcome + owner.completed(record, terminalOutcome) + notifyInitialization(record, terminalOutcome) + record.observer = undefined + if (!record.target) { + record.releaseCleanup?.() + record.releaseCleanup = undefined + if (!record.closed && operation.preserveContinuation?.()) { + record.authorityRetired = true + record.scope = undefined + } else if (!record.stopped) { + owner.removeRecord(record) + } + } + } + if (queue) { + owner.schedule(admission.state.token, queue) + } +} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-transaction.ts b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-transaction.ts new file mode 100644 index 00000000000..e6ff2e86125 --- /dev/null +++ b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization-transaction.ts @@ -0,0 +1,138 @@ +import type { RuntimeBrowserClientPlacement } from '../../../../shared/runtime-browser-placement' +import type { BrowserHostPagePlacementRegistry } from '../reconciliation/browser-host-page-placement' +import type { BrowserClientPageExecutionHostGrant } from './browser-host-client-page-creation' +import type { + Initialization, + InitializationOutcome +} from './browser-host-page-initialization-records' +import { sameInitializationPlacement } from './browser-host-page-initialization-records' +import { + awaitInitializationResult, + closeInitializationGuest, + issueInitializationCommand +} from './browser-host-page-initialization-commands' +import { + clientPageCreationTimeout, + waitForClientPageCreationResult +} from './browser-host-client-page-creation' +import { browserHostClientProfileFor } from '../profiles/browser-host-client-profile-metadata' + +export type InitializationTransactionAuthority = { + placements: BrowserHostPagePlacementRegistry + grants: Map + retirePlacement(id: string, placement: RuntimeBrowserClientPlacement): void + current(operation: Initialization): void +} + +export async function runInitializationTransaction( + operation: Initialization, + authority: InitializationTransactionAuthority +): Promise { + const { record, admission } = operation + const { state, input, reservation } = admission + let published = false + try { + authority.current(operation) + if (operation.phase === 'cleanup') { + operation.executionHostKey = + (await awaitInitializationResult( + input.resolveExecutionHostKey(), + operation.controller.signal + )) ?? undefined + authority.current(operation) + if (record.target) { + try { + await closeInitializationGuest(operation) + } catch { + return 'cleanup-unknown' + } + } + authority.current(operation) + return operation.executionHostKey === undefined ? 'route-parked' : 'prepared' + } + const executionHostKey = operation.executionHostKey + if (!executionHostKey) { + return 'route-parked' + } + operation.releaseGrant = state.executionHostGrants.retain(executionHostKey).release + authority.current(operation) + const previous = authority.placements.getPlacement(record.browserPageId) + if (previous) { + if (!sameInitializationPlacement(previous, input.expected)) { + throw new Error('browser_page_placement_stale') + } + authority.retirePlacement(record.browserPageId, previous) + } + await waitForClientPageCreationResult( + issueInitializationCommand(operation, () => authority.current(operation), { + type: 'createPage', + browserProfileId: input.browserProfileId, + ...browserHostClientProfileFor(state.lease, input.browserProfileId), + executionHostKey, + workspaceId: input.workspaceId, + initialUrl: input.url + }), + clientPageCreationTimeout(input.creationTimeoutMs) + ) + authority.current(operation) + const result = await issueInitializationCommand(operation, () => authority.current(operation), { + type: 'navigate', + url: input.url + }) + authority.current(operation) + if (typeof result.value !== 'string' || !reservation) { + throw new Error('browser_client_page_initialization_result_invalid') + } + const placement = authority.placements.commitClientPageReservation(reservation) + const grant = { placement, executionHostKey, release: operation.releaseGrant } + authority.grants.set(record.browserPageId, grant) + try { + record.expected = placement + operation.phase = 'published' + input.publish(placement, result.value, executionHostKey) + if ( + record.closed || + operation.detached || + operation.controller.signal.aborted || + admission.connectionToken !== state.connectionToken + ) { + throw new Error('browser_client_page_initialization_stale') + } + published = true + input.completed?.(result.value) + operation.releaseGrant = undefined + record.target = undefined + } catch (error) { + if (authority.grants.get(record.browserPageId) === grant) { + authority.grants.delete(record.browserPageId) + } + const current = authority.placements.getPlacement(record.browserPageId) + if (current === placement) { + authority.placements.completePageRetirement( + authority.placements.beginPageRetirement(record.browserPageId, placement) + ) + } + throw error + } + return 'published' + } catch { + if (!operation.detached) { + try { + await closeInitializationGuest(operation) + } catch { + return 'cleanup-unknown' + } + } + return record.closed ? 'disposed' : 'retained-clean' + } finally { + if (!published && operation.phase !== 'cleanup') { + if (reservation) { + authority.placements.cancelClientPageReservation(reservation) + } + if (!record.target || operation.detached) { + operation.releaseGrant?.() + operation.releaseGrant = undefined + } + } + } +} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-page-initialization.ts b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization.ts new file mode 100644 index 00000000000..94dcc75a97a --- /dev/null +++ b/src/main/runtime/browser-host/client-pages/browser-host-page-initialization.ts @@ -0,0 +1,318 @@ +import { createInitializationCleanup } from './browser-host-page-initialization-closed-cleanup' +import { claimLegacyPageRecovery } from './browser-host-page-legacy-recovery' +import { BrowserHostPageInitializationControls } from './browser-host-page-initialization-controls' +import { + initializationQueue, + scheduleInitializationQueue, + waitForInitializationPartition +} from './browser-host-page-initialization-queue' +import { settleInitialization } from './browser-host-page-initialization-settlement' +import { + admitInitialization, + retireUnpublishedInitialization, + type InitializationAdmissionOwner +} from './browser-host-page-initialization-admission' +import { retireInitializationAuthority } from './browser-host-page-initialization-lifetime' +import type { BrowserClientHostedPageInventory } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import type { RuntimeBrowserClientPlacement } from '../../../../shared/runtime-browser-placement' +import type { + BrowserHostLeaseIdentity, + BrowserHostLeaseState +} from '../leases/browser-host-lease-records' +import { + canContinueInitialization, + type InitializationAdmission, + type Initialization, + type InitializationRecord, + type InitializationQueue, + type InitializationDisposition, + type InitializationOutcome, + type BrowserHostPageInitializationInput +} from './browser-host-page-initialization-records' +import { closeInitializationGuest } from './browser-host-page-initialization-commands' +import { observeInitializationAttach } from './browser-host-page-initialization-attachment' +import { + runInitializationTransaction, + type InitializationTransactionAuthority +} from './browser-host-page-initialization-transaction' +export type { BrowserHostPageInitializationInput } from './browser-host-page-initialization-records' + +export class BrowserHostPageInitializations extends BrowserHostPageInitializationControls { + private readonly queues = new Map() + + constructor( + private readonly authority: Omit, + requireState: (state: BrowserHostLeaseState) => void, + private readonly retireLease: (state: BrowserHostLeaseState) => void, + changed: (workspaceId: string) => void = () => {} + ) { + super(requireState, changed) + } + + canContinue( + id: string, + expected: RuntimeBrowserClientPlacement, + lease: BrowserHostLeaseIdentity + ): boolean { + return canContinueInitialization( + this.records.get(id), + expected, + lease, + this.authority.placements.getPlacement(id) + ) + } + + claimLegacyRecovery( + state: BrowserHostLeaseState, + input: Parameters[2] + ) { + return claimLegacyPageRecovery( + { + records: this.records, + placements: this.authority.placements, + requireState: this.requireState, + removeRecord: (record) => this.removeRecord(record), + cleanup: (record, current, release, isCurrent) => + this.startClosedCleanup(record, current, release, isCurrent) + }, + state, + input + ) + } + + sealAttach(state: BrowserHostLeaseState, blocked: boolean, connectionId?: string): void { + if (connectionId && state.lease.connectionId !== connectionId) { + return + } + // Unclaimed requests end at this attach; claimed requests already transferred before cancellation. + for (const record of this.completion.pendingRecords(state)) { + const operation = record.operation + if ( + operation?.admission.state === state && + operation.admission.connectionToken !== state.connectionToken + ) { + this.cancelOperation(operation) + } + } + this.preparation.seal(state) + this.completion.seal(state, blocked) + } + + observeAttach( + state: BrowserHostLeaseState, + inventory: readonly BrowserClientHostedPageInventory[] | undefined, + complete: () => void = () => {} + ): void { + this.preparation.observe(state) + this.completion.begin(state, this.records.values(), complete) + this.attachments.set(state.lease.browserHostClientId, state) + observeInitializationAttach( + this.records, + state, + inventory, + (operation) => this.cancelOperation(operation), + (record) => this.removeRecord(record) + ) + } + + admit( + state: BrowserHostLeaseState, + input: BrowserHostPageInitializationInput + ): InitializationDisposition { + return admitInitialization(this.admissionOwner(), state, input) + } + + retireUnpublished( + state: BrowserHostLeaseState, + input: BrowserHostPageInitializationInput, + cleanup?: { executionHostKey: string; releaseGrant(): void } + ): void { + retireUnpublishedInitialization(this.admissionOwner(), state, input, cleanup) + } + + retireAuthority(state: BrowserHostLeaseState): void { + const changed = new Set( + [...this.records.values()] + .filter((record) => record.scope === state && this.presentation(record)) + .map((record) => record.workspaceId) + ) + const affected = new Set([state.lease.pairedDeviceId]) + this.completion.retire(state) + retireInitializationAuthority( + this.records, + this.queues, + this.attachments, + (admission) => this.releaseAdmission(admission), + (record, admission) => this.enqueue(record, admission), + (id) => this.authority.placements.claimClientPageCleanup(id), + (record) => this.removeRecord(record, affected), + state + ) + this.preparation.retire(state) + this.completion.publishDevices(affected) + for (const workspace of changed) { + this.changed(workspace) + } + } + + private admissionOwner(): InitializationAdmissionOwner { + return { + records: this.records, + removeRecord: (record) => this.removeRecord(record), + placements: this.authority.placements, + join: (state, record) => this.completion.join(state, record), + requireState: this.requireState, + observer: (state, input) => this.observer(state, input), + cancelOperation: (operation) => this.cancelOperation(operation), + releaseAdmission: (admission) => this.releaseAdmission(admission), + enqueue: (record, admission) => this.enqueue(record, admission), + resume: (state) => this.resume(state), + startClosedCleanup: (record, state, releaseGrant) => + this.startClosedCleanup(record, state, releaseGrant) + } + } + + private enqueue(record: InitializationRecord, admission: InitializationAdmission): void { + const operation: Initialization = { + record, + admission, + controller: new AbortController(), + running: false, + executing: false, + detached: false, + phase: 'cleanup' + } + record.scope = admission.state + record.operation = operation + this.completion.join(admission.state, record) + this.preparation.register(admission.state, record) + const queue = initializationQueue(this.queues, admission.state.token) + queue.pending.push(operation) + this.schedule(admission.state.token, queue) + } + + private schedule(token: symbol, queue: InitializationQueue): void { + scheduleInitializationQueue( + this.queues, + token, + queue, + (operation) => + runInitializationTransaction(operation, { + ...this.authority, + current: (op) => this.current(op) + }), + (operation, outcome) => this.settle(operation, outcome) + ) + } + + private current(operation: Initialization): void { + const { admission, record } = operation + this.requireState(admission.state) + if ( + operation.detached || + record.operation !== operation || + record.closed || + operation.controller.signal.aborted || + admission.connectionToken !== admission.state.connectionToken || + !admission.input.isCurrent() + ) { + throw new Error('browser_client_page_initialization_stale') + } + } + + protected override cancelOperation(operation: Initialization): void { + if (operation.detached) { + return + } + operation.removePreparation?.() + operation.removePreparation = undefined + operation.controller.abort() + operation.admission.input.completed?.() + if (operation.admission.reservation) { + this.authority.placements.cancelClientPageReservation(operation.admission.reservation) + } + if (!operation.running && !operation.record.target) { + const queue = this.queues.get(operation.admission.state.token) + const index = queue?.pending.indexOf(operation) ?? -1 + if (index >= 0) { + queue?.pending.splice(index, 1) + } + this.clean(operation) + } + } + + protected override clean(operation: Initialization): void { + if (operation.executing || operation.detached) { + return + } + operation.executing = true + void Promise.resolve() + .then(() => closeInitializationGuest(operation)) + .then( + () => this.settle(operation, operation.record.closed ? 'disposed' : 'retained-clean'), + () => this.settle(operation, 'cleanup-unknown') + ) + } + + protected override startClosedCleanup( + record: InitializationRecord, + state: BrowserHostLeaseState, + releaseGrant?: () => void, + preserveContinuation?: () => boolean + ): void { + const operation = createInitializationCleanup( + record, + (input) => this.observer(state, input), + releaseGrant, + preserveContinuation + ) + record.scope = state + record.operation = operation + this.completion.join(state, record) + this.preparation.register(state, record) + // A rebound native close is charged to the current dispatcher; no replacement is created. + const queue = initializationQueue(this.queues, state.token) + queue.pending.push(operation) + this.schedule(state.token, queue) + } + + private settle(operation: Initialization, outcome: InitializationOutcome): void { + if (operation.detached || operation.record.operation !== operation) { + return + } + const { record, admission } = operation + const before = this.presentation(record) + if (outcome === 'prepared') { + waitForInitializationPartition(operation, this.preparation, this.queues, (token, queue) => + this.schedule(token, queue) + ) + return + } + operation.cleanupFailed = outcome === 'cleanup-unknown' + if (!record.target) { + this.preparation.joined(admission.state, record) + } + settleInitialization(operation, outcome, { + authority: this.authority, + completed: (record, outcome) => this.completion.settle(record, outcome), + removeRecord: (record) => this.removeRecord(record), + queues: this.queues, + retireLease: this.retireLease, + clean: (op) => this.clean(op), + releaseAdmission: (admission) => this.releaseAdmission(admission), + enqueue: (record, admission) => this.enqueue(record, admission), + schedule: (token, queue) => this.schedule(token, queue) + }) + if (before !== this.presentation(this.records.get(record.browserPageId))) { + this.changed(record.workspaceId) + } + } + + protected override releaseAdmission(admission: InitializationAdmission): void { + admission.removeAbort() + admission.input.completed?.() + if (admission.reservation) { + this.authority.placements.cancelClientPageReservation(admission.reservation) + } + } +} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-page-legacy-recovery.ts b/src/main/runtime/browser-host/client-pages/browser-host-page-legacy-recovery.ts new file mode 100644 index 00000000000..2346a4b63d4 --- /dev/null +++ b/src/main/runtime/browser-host/client-pages/browser-host-page-legacy-recovery.ts @@ -0,0 +1,144 @@ +import type { RuntimeBrowserClientPlacement } from '../../../../shared/runtime-browser-placement' +import type { BrowserHostLeaseState } from '../leases/browser-host-lease-records' +import type { BrowserHostPagePlacementRegistry } from '../reconciliation/browser-host-page-placement' +import { + sameInitializationPlacement, + type InitializationRecord +} from './browser-host-page-initialization-records' + +export type BrowserHostLegacyPageRecoveryInput = { + browserPageId: string + browserProfileId: string + workspaceId: string + expected: RuntimeBrowserClientPlacement + isCurrent(): boolean +} + +export type BrowserHostLegacyPageRecoveryClaim = { + admit(id: string, state: BrowserHostLeaseState, cancel: () => void): void + validate(state: BrowserHostLeaseState): void + commit( + state: BrowserHostLeaseState, + placement: RuntimeBrowserClientPlacement, + replace: () => void + ): void + failed( + placement: RuntimeBrowserClientPlacement, + executionHostKey: string, + releaseGrant: () => void + ): boolean + finish(): void +} + +export function claimLegacyPageRecovery( + owner: { + records: Map + placements: BrowserHostPagePlacementRegistry + requireState(state: BrowserHostLeaseState): void + removeRecord(record: InitializationRecord): void + cleanup( + record: InitializationRecord, + state: BrowserHostLeaseState, + releaseGrant: () => void, + isCurrent: () => boolean + ): void + }, + state: BrowserHostLeaseState, + input: BrowserHostLegacyPageRecoveryInput +): BrowserHostLegacyPageRecoveryClaim | undefined { + const record = owner.records.get(input.browserPageId) + if (!record) { + return undefined + } + const connection = state.connectionToken + const ledger = state.commandLedger + let attempt: InitializationRecord['legacyAttempt'] + const validate = ( + current: BrowserHostLeaseState, + placement?: RuntimeBrowserClientPlacement + ): void => { + owner.requireState(state) + const live = owner.placements.getPlacement(input.browserPageId) + if ( + current !== state || + state.connectionToken !== connection || + state.commandLedger !== ledger || + owner.records.get(input.browserPageId) !== record || + record.closed || + record.stopped || + !record.authorityRetired || + record.target || + record.operation || + record.successor || + record.releaseCleanup || + record.legacyAttempt !== attempt || + record.browserProfileId !== input.browserProfileId || + record.workspaceId !== input.workspaceId || + record.pairedDeviceId !== state.lease.pairedDeviceId || + !sameInitializationPlacement(record.expected, input.expected) || + !input.isCurrent() || + (live !== undefined && (!placement || !sameInitializationPlacement(live, placement))) + ) { + throw new Error('browser_page_replacement_requires_retirement') + } + } + validate(state) + return { + admit(id, current, cancel) { + if (id !== input.browserPageId || attempt) { + throw new Error('browser_page_replacement_requires_retirement') + } + validate(current) + attempt = { state, cancel } + record.legacyAttempt = attempt + record.scope = state + }, + validate(current) { + validate(current) + }, + commit(current, placement, replace) { + validate(current, placement) + replace() + record.legacyAttempt = undefined + record.scope = undefined + owner.removeRecord(record) + }, + failed(placement, executionHostKey, releaseGrant) { + if ( + !attempt || + owner.records.get(input.browserPageId) !== record || + record.legacyAttempt !== attempt || + ledger?.isClosed() + ) { + return false + } + try { + owner.requireState(state) + } catch { + return false + } + record.legacyAttempt = undefined + record.browserHostClientId = state.lease.browserHostClientId + record.authorityRetired = false + record.closed ||= !input.isCurrent() + record.target = { + ...placement, + authorityRuntimeId: state.lease.authorityRuntimeId, + authorityEpoch: state.lease.authorityEpoch + } + record.targetExecutionHostKey = executionHostKey + record.releaseCleanup = owner.placements.claimClientPageCleanup(input.browserPageId) + owner.cleanup(record, state, releaseGrant, input.isCurrent) + return true + }, + finish() { + if (record.legacyAttempt === attempt && attempt) { + record.legacyAttempt = undefined + record.scope = undefined + if (record.closed && owner.records.get(input.browserPageId) === record) { + owner.removeRecord(record) + } + } + } + } +} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-page-partition-preparation.ts b/src/main/runtime/browser-host/client-pages/browser-host-page-partition-preparation.ts new file mode 100644 index 00000000000..19269303ebc --- /dev/null +++ b/src/main/runtime/browser-host/client-pages/browser-host-page-partition-preparation.ts @@ -0,0 +1,193 @@ +import { parseBrowserNetworkExecutionHostKey } from '../../../../shared/browser/client-hosting/browser-network-execution-host' +import { browserNetworkExecutionHostStorageIdentity } from '../../../browser/routing/identity/browser-execution-host-storage-identity' +import type { BrowserHostLeaseState } from '../leases/browser-host-lease-records' +import type { InitializationRecord } from './browser-host-page-initialization-records' + +type Waiter = { ready(): void; retired(): void } +type Partition = { predecessors: Set; waiting: Set } +type Profile = { partitions: Map; predecessors: number } +type Scope = { + connection: symbol + sealed: boolean + profiles: Map + members: Map +} + +// Dependency membership never certifies native destruction; only the exact page close can remove it. +export class BrowserHostPagePartitionPreparation { + private readonly scopes = new Map() + + observe(state: BrowserHostLeaseState): void { + const scope = this.scope(state) + scope.connection = state.connectionToken + scope.sealed = false + } + + seal(state: BrowserHostLeaseState): void { + const scope = this.scope(state) + if (scope.connection !== state.connectionToken) { + return + } + scope.sealed = true + for (const [id, profile] of scope.profiles) { + this.drainProfile(scope, id, profile) + } + } + + register(state: BrowserHostLeaseState, record: InitializationRecord): void { + if (!record.target) { + return + } + const scope = this.scope(state) + if (scope.members.has(record)) { + return + } + const key = partitionGroup(record.targetExecutionHostKey) + this.partition(scope, record.browserProfileId, key).predecessors.add(record) + scope.profiles.get(record.browserProfileId)!.predecessors += 1 + scope.members.set(record, key) + } + + joined(state: BrowserHostLeaseState, record: InitializationRecord): void { + const scope = this.scopes.get(state.token) + const profile = scope?.profiles.get(record.browserProfileId) + if (!scope || !profile || !scope.members.has(record)) { + return + } + const key = scope.members.get(record)! + scope.members.delete(record) + profile.partitions.get(key)?.predecessors.delete(record) + profile.predecessors -= 1 + if (key === null && !profile.partitions.get(null)?.predecessors.size) { + // The final unclassified predecessor releases every dependent group in this profile. + this.drainProfile(scope, record.browserProfileId, profile) + } else { + this.drain(scope, record.browserProfileId, profile, key) + this.drain(scope, record.browserProfileId, profile, null) + } + } + + isPrepared(state: BrowserHostLeaseState, profileId: string, key: string): boolean { + const scope = this.scopes.get(state.token) + if (!scope || !scope.sealed || scope.connection !== state.connectionToken) { + return false + } + const profile = scope.profiles.get(profileId) + if (!profile) { + return true + } + const group = partitionGroup(key) + return ( + !profile.partitions.get(null)?.predecessors.size && + !profile.partitions.get(group)?.predecessors.size && + (group !== null || profile.predecessors === 0) + ) + } + + wait( + state: BrowserHostLeaseState, + profileId: string, + key: string, + ready: () => void, + retired: () => void + ): () => void { + const scope = this.scope(state) + const group = partitionGroup(key) + const partition = this.partition(scope, profileId, group) + const profile = scope.profiles.get(profileId)! + const waiter = { ready, retired } + partition.waiting.add(waiter) + this.drain(scope, profileId, profile, group) + return () => { + partition.waiting.delete(waiter) + this.drain(scope, profileId, profile, group) + } + } + + retire(state: BrowserHostLeaseState): void { + const scope = this.scopes.get(state.token) + this.scopes.delete(state.token) + for (const profile of scope?.profiles.values() ?? []) { + for (const partition of profile.partitions.values()) { + for (const waiter of partition.waiting) { + waiter.retired() + } + } + } + } + + private scope(state: BrowserHostLeaseState): Scope { + let scope = this.scopes.get(state.token) + if (!scope) { + scope = { + connection: state.connectionToken, + sealed: false, + profiles: new Map(), + members: new Map() + } + this.scopes.set(state.token, scope) + } + return scope + } + + private partition(scope: Scope, id: string, key: string | null): Partition { + let profile = scope.profiles.get(id) + if (!profile) { + profile = { partitions: new Map(), predecessors: 0 } + scope.profiles.set(id, profile) + } + let partition = profile.partitions.get(key) + if (!partition) { + partition = { predecessors: new Set(), waiting: new Set() } + profile.partitions.set(key, partition) + } + return partition + } + + private drainProfile(scope: Scope, id: string, profile: Profile): void { + const keys = [...profile.partitions.keys()] + for (const key of keys) { + this.drain(scope, id, profile, key) + } + } + + private drain(scope: Scope, id: string, profile: Profile, key: string | null): void { + const partition = profile.partitions.get(key) + if (!partition || scope.profiles.get(id) !== profile) { + return + } + const ready = + scope.sealed && + !profile.partitions.get(null)?.predecessors.size && + !partition.predecessors.size && + (key !== null || profile.predecessors === 0) + const waiting = ready ? [...partition.waiting] : [] + if (ready) { + partition.waiting.clear() + } + if (!partition.waiting.size && !partition.predecessors.size) { + profile.partitions.delete(key) + } + if (!profile.partitions.size) { + scope.profiles.delete(id) + } + for (const waiter of waiting) { + waiter.ready() + } + } +} + +function partitionGroup(key: string | undefined): string | null { + if (key === undefined) { + return null + } + try { + // The lease already fixes authority storage scope and client profile; reuse native host equality. + return browserNetworkExecutionHostStorageIdentity( + parseBrowserNetworkExecutionHostKey(key), + 'lease' + ) + } catch { + return null + } +} diff --git a/src/main/runtime/browser-host/client-pages/browser-host-uncommitted-client-page.ts b/src/main/runtime/browser-host/client-pages/browser-host-uncommitted-client-page.ts new file mode 100644 index 00000000000..b9d09a966bb --- /dev/null +++ b/src/main/runtime/browser-host/client-pages/browser-host-uncommitted-client-page.ts @@ -0,0 +1,48 @@ +import type { BrowserHostLeaseState } from '../leases/browser-host-lease-records' +import type { RuntimeBrowserClientPlacement } from '../../../../shared/runtime-browser-placement' + +export function closeUncommittedClientPage( + state: BrowserHostLeaseState, + browserPageId: string, + placement: RuntimeBrowserClientPlacement +): void { + const ledger = state.commandLedger + if (!ledger) { + return + } + try { + const close = ledger.issue({ + browserPageId, + pageHostGeneration: placement.pageHostGeneration, + command: { + type: 'closePage', + targetAuthority: { + authorityRuntimeId: state.lease.authorityRuntimeId, + authorityEpoch: state.lease.authorityEpoch, + browserHostClientId: placement.browserHostClientId, + browserHostGeneration: placement.browserHostGeneration, + pageHostGeneration: placement.pageHostGeneration + } + }, + resultAdmission: 'reserved-page' + }) + void close.result.then( + () => retireUncommittedClientPage(ledger, browserPageId, placement.pageHostGeneration), + () => retireUncommittedClientPage(ledger, browserPageId, placement.pageHostGeneration) + ) + } catch { + retireUncommittedClientPage(ledger, browserPageId, placement.pageHostGeneration) + } +} + +export function retireUncommittedClientPage( + ledger: NonNullable, + browserPageId: string, + pageHostGeneration: number +): void { + try { + ledger.retirePage(browserPageId, pageHostGeneration) + } catch { + // Best-effort close converges through authenticated inventory reconciliation. + } +} diff --git a/src/main/runtime/browser-host/commands/browser-host-client-page-command-issue.ts b/src/main/runtime/browser-host/commands/browser-host-client-page-command-issue.ts index 7420419d2c4..70e7931ac2c 100644 --- a/src/main/runtime/browser-host/commands/browser-host-client-page-command-issue.ts +++ b/src/main/runtime/browser-host/commands/browser-host-client-page-command-issue.ts @@ -1,6 +1,11 @@ +import { + requireBrowserHostCommandResultLedger, + type BrowserHostCommandResultIdentity, + type BrowserHostLeaseState +} from '../leases/browser-host-lease-records' +import type { BrowserHostCommandResultParams } from './browser-host-command-state' import type { BrowserClientHostCommandEvent } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' import type { BrowserClientAutomationResult as BrowserClientHostCommandResult } from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' -import type { BrowserHostLeaseState } from '../leases/browser-host-lease-records' import { assertBrowserHostPageCommandAdmission } from './browser-host-page-command-admission' import type { BrowserClientPageAuthority } from '../reconciliation/browser-host-page-placement' @@ -27,3 +32,27 @@ export function issueBrowserHostClientPageCommand( command }) } + +export function attachBrowserHostClientCommandDelivery( + state: BrowserHostLeaseState, + delivery: (event: BrowserClientHostCommandEvent) => void +): () => void { + const ledger = state.commandLedger + if (!ledger) { + throw new Error('browser_host_command_protocol_required') + } + return ledger.attach(delivery) +} + +export function settleBrowserHostClientPageCommand( + state: BrowserHostLeaseState, + identity: BrowserHostCommandResultIdentity, + params: BrowserHostCommandResultParams, + requirePage: (authority: BrowserClientPageAuthority) => unknown +): boolean { + const ledger = requireBrowserHostCommandResultLedger(state, identity) + if (!ledger.isUnplacedPageResult(params)) { + requirePage(params) + } + return ledger.settle(params) +} diff --git a/src/main/runtime/browser-host/commands/browser-host-command-ledger.ts b/src/main/runtime/browser-host/commands/browser-host-command-ledger.ts index 1c24827be15..b1487de4417 100644 --- a/src/main/runtime/browser-host/commands/browser-host-command-ledger.ts +++ b/src/main/runtime/browser-host/commands/browser-host-command-ledger.ts @@ -9,8 +9,7 @@ import { assertBrowserHostCommandResultAuthority, assertBrowserHostCommandOrder, createBrowserHostCommandRecord, - DEFAULT_MAX_OUTSTANDING_COMMANDS, - DEFAULT_MAX_OUTSTANDING_COMMANDS_PER_PAGE, + createBrowserHostCommandPageState, DEFAULT_MAX_PAGES, type BrowserHostCommandInput, type BrowserHostCommandLedgerOptions, @@ -20,11 +19,13 @@ import { positiveBrowserHostCommandLimit, recordBrowserHostCommandOrder, resolveBrowserHostCommandCacheLimits, + resolveBrowserHostOutstandingCommandLimits, snapshotBrowserHostPageCommand } from './browser-host-command-state' import { replayOutstandingBrowserHostCommands } from './browser-host-command-replay' import { hasOutstandingBrowserHostReconciliation, + hasOutstandingBrowserHostPageCreation, isBrowserHostReconciliationResult, isBrowserHostUnplacedPageResult, replaySettledBrowserHostCommand @@ -52,14 +53,9 @@ export class BrowserHostCommandLedger { } this.authority = Object.freeze({ ...options.authority }) this.createCommandId = options.createCommandId ?? (() => randomUUID()) - this.maxOutstandingCommands = positiveBrowserHostCommandLimit( - options.maxOutstandingCommands, - DEFAULT_MAX_OUTSTANDING_COMMANDS - ) - this.maxOutstandingCommandsPerPage = positiveBrowserHostCommandLimit( - options.maxOutstandingCommandsPerPage, - DEFAULT_MAX_OUTSTANDING_COMMANDS_PER_PAGE - ) + const limits = resolveBrowserHostOutstandingCommandLimits(options) + this.maxOutstandingCommands = limits.maxOutstandingCommands + this.maxOutstandingCommandsPerPage = limits.maxOutstandingCommandsPerPage this.resultCache = new BrowserHostCommandResultCache( resolveBrowserHostCommandCacheLimits(options), (browserPageId) => this.pages.delete(browserPageId) @@ -92,7 +88,10 @@ export class BrowserHostCommandLedger { this.delivery = undefined } - issue(input: BrowserHostCommandInput): { + issue( + input: BrowserHostCommandInput, + onAdmitted?: () => void + ): { event: BrowserClientHostCommandEvent result: Promise } { @@ -139,6 +138,7 @@ export class BrowserHostCommandLedger { page.nextIssueSequence += 1 page.outstanding += 1 this.outstandingCommands += 1 + onAdmitted?.() try { this.delivery(event) } catch { @@ -160,6 +160,14 @@ export class BrowserHostCommandLedger { return hasOutstandingBrowserHostReconciliation(this.pages) } + isClosed(): boolean { + return this.closed + } + + hasOutstandingPageCreation(browserPageId: string, generation: number): boolean { + return hasOutstandingBrowserHostPageCreation(this.pages.get(browserPageId), generation) + } + stageResultChunk(params: BrowserHostCommandResultChunkParams): void { if (this.closed) { throw new Error('browser_host_command_ledger_closed') @@ -287,16 +295,7 @@ export class BrowserHostCommandLedger { if (claimsActivePage && this.activePages >= this.maxPages) { throw new Error('browser_host_command_page_capacity') } - const page: BrowserHostCommandPageState = { - generation: input.pageHostGeneration, - nextIssueSequence: 1, - nextSettlementSequence: 1, - records: new Map(), - outstanding: 0, - settledSequences: [], - terminalCommandIssued: false, - activeCapacityReleased: false - } + const page = createBrowserHostCommandPageState(input.pageHostGeneration) return { page, commit: () => { diff --git a/src/main/runtime/browser-host/commands/browser-host-command-result-replay.ts b/src/main/runtime/browser-host/commands/browser-host-command-result-replay.ts index ac9f5a93082..8d975185a72 100644 --- a/src/main/runtime/browser-host/commands/browser-host-command-result-replay.ts +++ b/src/main/runtime/browser-host/commands/browser-host-command-result-replay.ts @@ -68,3 +68,15 @@ export function replaySettledBrowserHostCommand( } return false } + +export function hasOutstandingBrowserHostPageCreation( + page: BrowserHostCommandPageState | undefined, + generation: number +): boolean { + return Boolean( + page?.generation === generation && + [...page.records.values()].some( + (record) => !record.settled && record.event.command.type === 'createPage' + ) + ) +} diff --git a/src/main/runtime/browser-host/commands/browser-host-command-state.ts b/src/main/runtime/browser-host/commands/browser-host-command-state.ts index d190f05e3f2..9ea8dde1b7e 100644 --- a/src/main/runtime/browser-host/commands/browser-host-command-state.ts +++ b/src/main/runtime/browser-host/commands/browser-host-command-state.ts @@ -59,6 +59,19 @@ export type BrowserHostCommandPageState = { activeCapacityReleased: boolean } +export function createBrowserHostCommandPageState(generation: number): BrowserHostCommandPageState { + return { + generation, + nextIssueSequence: 1, + nextSettlementSequence: 1, + records: new Map(), + outstanding: 0, + settledSequences: [], + terminalCommandIssued: false, + activeCapacityReleased: false + } +} + export function assertBrowserHostCommandOrder( page: BrowserHostCommandPageState, command: BrowserClientHostCommandEvent['command'], @@ -216,3 +229,18 @@ export function resolveBrowserHostCommandCacheLimits(options: BrowserHostCommand ) } } + +export function resolveBrowserHostOutstandingCommandLimits( + options: BrowserHostCommandLedgerOptions +) { + return { + maxOutstandingCommands: positiveBrowserHostCommandLimit( + options.maxOutstandingCommands, + DEFAULT_MAX_OUTSTANDING_COMMANDS + ), + maxOutstandingCommandsPerPage: positiveBrowserHostCommandLimit( + options.maxOutstandingCommandsPerPage, + DEFAULT_MAX_OUTSTANDING_COMMANDS_PER_PAGE + ) + } +} diff --git a/src/main/runtime/browser-host/commands/browser-host-page-command-admission.ts b/src/main/runtime/browser-host/commands/browser-host-page-command-admission.ts index 38ac89127b1..286c3ddb7d6 100644 --- a/src/main/runtime/browser-host/commands/browser-host-page-command-admission.ts +++ b/src/main/runtime/browser-host/commands/browser-host-page-command-admission.ts @@ -1,4 +1,5 @@ import { BROWSER_CLIENT_PROFILE_SWITCH_CAPABILITY } from '../../../../shared/browser/client-hosting/browser-client-profile-switch-protocol' +import { BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' import type { BrowserClientHostCommandEvent } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY, @@ -18,6 +19,14 @@ export function assertBrowserHostPageCommandAdmission( command: BrowserClientHostCommandEvent['command'], requireExecutionHost: (executionHostKey: string) => void ): void { + if (!lease.hostCapabilities.includes(BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY)) { + if (command.type === 'navigate' && command.url.length > 8192) { + throw new Error('browser_host_navigation_url_too_long') + } + if (command.type === 'createPage' && command.initialUrl !== undefined) { + throw new Error('browser_host_page_initialization_unsupported') + } + } if ( command.type === 'switchProfile' && !lease.hostCapabilities.includes(BROWSER_CLIENT_PROFILE_SWITCH_CAPABILITY) diff --git a/src/main/runtime/browser-host/leases/browser-host-capability-selection.ts b/src/main/runtime/browser-host/leases/browser-host-capability-selection.ts index a7c2034cc40..7e6218821b1 100644 --- a/src/main/runtime/browser-host/leases/browser-host-capability-selection.ts +++ b/src/main/runtime/browser-host/leases/browser-host-capability-selection.ts @@ -1,3 +1,5 @@ +import type { BrowserHostLease, BrowserHostLeaseState } from './browser-host-lease-records' + export const BROWSER_HOST_WEBVIEW_CAPABILITY = 'webview' type BrowserHostCapabilityLease = Readonly<{ @@ -47,3 +49,15 @@ function hasCapabilities( ): boolean { return requiredCapabilities.every((capability) => lease.hostCapabilities.includes(capability)) } + +export function selectActiveBrowserHostLease( + leasesByClientId: ReadonlyMap, + browserHostClientId?: string, + requiredCapabilities: readonly string[] = [] +): BrowserHostLease { + return selectBrowserHostLease( + new Map([...leasesByClientId].filter(([, state]) => state.status === 'active')), + browserHostClientId, + requiredCapabilities + ) +} diff --git a/src/main/runtime/browser-host/leases/browser-host-lease-attachment.ts b/src/main/runtime/browser-host/leases/browser-host-lease-attachment.ts index 538d0303c2f..91737096e40 100644 --- a/src/main/runtime/browser-host/leases/browser-host-lease-attachment.ts +++ b/src/main/runtime/browser-host/leases/browser-host-lease-attachment.ts @@ -1,8 +1,16 @@ +import type { BrowserHostGenerationCounter } from './browser-host-generation-counter' +import type { BrowserHostLeaseReconnectController } from './browser-host-lease-reconnect' +import { + assertBrowserHostLeaseAdmission, + type BrowserHostLeaseHandle, + type BrowserHostLeaseState +} from './browser-host-lease-records' +import { snapshotBrowserHostPageInventory } from '../reconciliation/browser-host-page-inventory-snapshot' + import type { BrowserClientHostedPageInventory } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' import { BrowserHostCommandLedger } from '../commands/browser-host-command-ledger' import { BrowserExecutionHostGrantRegistry } from './browser-execution-host-grant-registry' import { createBrowserHostFence } from './browser-host-lease-fence' -import type { BrowserHostLeaseState } from './browser-host-lease-records' export type BrowserHostLeaseAttachInput = { browserHostClientId: string @@ -101,3 +109,46 @@ export function createBrowserHostLeaseState(options: { } return state } + +export function attachBrowserHostLease( + input: BrowserHostLeaseAttachInput, + context: { + authorityRuntimeId: string + authorityEpoch: string + leasesByClientId: Map + reconnects: BrowserHostLeaseReconnectController + generations: BrowserHostGenerationCounter + observeInventory( + state: BrowserHostLeaseState, + inventory: readonly BrowserClientHostedPageInventory[] | undefined + ): void + fenceLease(state: BrowserHostLeaseState): void + } +): BrowserHostLeaseHandle { + const pageInventory = snapshotBrowserHostPageInventory(input) + assertBrowserHostReconnectNegotiation(input) + const existing = context.leasesByClientId.get(input.browserHostClientId) + if (existing && existing.lease.pairedDeviceId !== input.pairedDeviceId) { + throw new Error('browser_host_identity_conflict') + } + assertBrowserHostLeaseAdmission(context.leasesByClientId.values(), input, existing) + const restored = existing ? context.reconnects.restore(existing, input, pageInventory) : undefined + if (restored && existing) { + context.observeInventory(existing, pageInventory) + return restored + } + const generation = context.generations.take('host') + if (existing) { + context.fenceLease(existing) + } + const state = createBrowserHostLeaseState({ + authorityRuntimeId: context.authorityRuntimeId, + authorityEpoch: context.authorityEpoch, + generation, + input, + pageInventory + }) + context.leasesByClientId.set(input.browserHostClientId, state) + context.observeInventory(state, pageInventory) + return context.reconnects.createHandle(state) +} diff --git a/src/main/runtime/browser-host/leases/browser-host-lease-controllers.ts b/src/main/runtime/browser-host/leases/browser-host-lease-controllers.ts new file mode 100644 index 00000000000..681e76ff3a9 --- /dev/null +++ b/src/main/runtime/browser-host/leases/browser-host-lease-controllers.ts @@ -0,0 +1,86 @@ +import type { BrowserClientHostedPageInventory } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import { BrowserHostPageInitializations } from '../client-pages/browser-host-page-initialization' +import { BrowserHostPageReconciliationOrchestrator } from '../reconciliation/browser-host-page-reconciliation-orchestration' +import { BrowserHostLeaseReconnectController } from './browser-host-lease-reconnect' +import type { BrowserHostLeaseRegistry } from './browser-host-lease-registry' +import type { BrowserHostLeaseIdentity, BrowserHostLeaseState } from './browser-host-lease-records' +import type { BrowserHostPagePlacementRegistry } from '../reconciliation/browser-host-page-placement' +import type { BrowserClientPageExecutionHostGrant } from '../client-pages/browser-host-client-page-creation' +import type { BrowserHostFenceReason } from './browser-host-lease-fence' +import type { BrowserHostTunnelRegistry } from './browser-host-tunnel-registry' + +export function createBrowserHostLeaseControllers( + registry: Pick< + BrowserHostLeaseRegistry, + 'authorityRuntimeId' | 'authorityEpoch' | 'completePageRetirement' | 'requireLease' + >, + placements: BrowserHostPagePlacementRegistry, + grants: Map, + dependencies: { + leases: Map + tunnels: BrowserHostTunnelRegistry + initializationChanged?: (workspaceId: string) => void + reconnectGraceMs?: number + fence(state: BrowserHostLeaseState, reason: BrowserHostFenceReason): void + } +) { + const initializations = new BrowserHostPageInitializations( + { + placements, + grants, + retirePlacement: (id, placement) => { + registry.completePageRetirement(placements.beginPageRetirement(id, placement)) + } + }, + (state) => { + if (registry.requireLease(state.lease) !== state.lease) { + throw new Error('browser_host_lease_stale') + } + }, + (state) => dependencies.fence(state, 'released'), + dependencies.initializationChanged + ) + const reconciliations = new BrowserHostPageReconciliationOrchestrator(registry, placements) + const fenceReconciliation = (state: BrowserHostLeaseState): void => { + initializations.fence(state) + reconciliations.fence(state) + } + const reconnects = new BrowserHostLeaseReconnectController({ + graceMs: dependencies.reconnectGraceMs ?? 15_000, + leasesByClientId: dependencies.leases, + fenceReconciliation, + fenceLease: dependencies.fence, + fenceRoute: (state, reason) => dependencies.tunnels.fence(state, reason) + }) + return { + initializations, + reconciliations, + reconnects, + fenceReconciliation, + sealInitializations: ( + identity: BrowserHostLeaseIdentity & { connectionId?: string }, + blocked: boolean + ) => { + const state = dependencies.leases.get(identity.browserHostClientId) + if ( + !state || + state.status !== 'active' || + state.lease.authorityEpoch !== identity.authorityEpoch || + state.lease.browserHostGeneration !== identity.browserHostGeneration || + state.lease.pairedDeviceId !== identity.pairedDeviceId + ) { + return + } + initializations.sealAttach(state, blocked, identity.connectionId) + }, + observeInventory: + (complete: () => void) => + ( + state: BrowserHostLeaseState, + inventory: readonly BrowserClientHostedPageInventory[] | undefined + ) => { + initializations.observeAttach(state, inventory, complete) + reconciliations.observeInventory(state) + } + } +} diff --git a/src/main/runtime/browser-host/leases/browser-host-lease-fence-dispatch.ts b/src/main/runtime/browser-host/leases/browser-host-lease-fence-dispatch.ts index ec51d1ad9ad..4cb46b33628 100644 --- a/src/main/runtime/browser-host/leases/browser-host-lease-fence-dispatch.ts +++ b/src/main/runtime/browser-host/leases/browser-host-lease-fence-dispatch.ts @@ -9,6 +9,7 @@ import type { type BrowserHostLeaseFenceDependencies = { leasesByClientId: Map pagePlacements: Pick + retireInitializations?(state: BrowserHostLeaseState): void clearReconnect(state: BrowserHostLeaseState): void fenceReconciliation(state: BrowserHostLeaseState): void fenceRoute(route: BrowserHostRouteState, reason: BrowserHostFenceReason): void @@ -24,6 +25,7 @@ export function dispatchBrowserHostLeaseFence( if (dependencies.leasesByClientId.get(state.lease.browserHostClientId)?.token !== state.token) { return } + dependencies.retireInitializations?.(state) dependencies.fenceReconciliation(state) const fencedPages = dependencies.pagePlacements.fenceClientHostPlacements({ browserHostClientId: state.lease.browserHostClientId, diff --git a/src/main/runtime/browser-host/leases/browser-host-lease-registry-instance.ts b/src/main/runtime/browser-host/leases/browser-host-lease-registry-instance.ts index 681bb60a235..6df3c0494ee 100644 --- a/src/main/runtime/browser-host/leases/browser-host-lease-registry-instance.ts +++ b/src/main/runtime/browser-host/leases/browser-host-lease-registry-instance.ts @@ -1,3 +1,4 @@ +import { getRuntimeBrowserPageRegistry } from '../../browser/client-pages/runtime-browser-page-registry' import { releaseBrowserClientDownloadTransfersForPage } from '../../browser/downloads/browser-client-download-transfer-store' import { BrowserHostLeaseRegistry } from './browser-host-lease-registry' import { @@ -14,6 +15,8 @@ export function getBrowserHostLeaseRegistry( if (!registry) { registry = new BrowserHostLeaseRegistry({ authorityRuntimeId: runtime.getRuntimeId(), + onClientPageInitializationChanged: (workspaceId) => + runtime.notifyMobileSessionTabsChanged?.(workspaceId), onClientPageReleased: (browserPageId, pageHostGeneration) => { void releaseBrowserClientDownloadTransfersForPage( runtime, @@ -28,6 +31,10 @@ export function getBrowserHostLeaseRegistry( } }) registries.set(runtime, registry) + const owner = registry + getRuntimeBrowserPageRegistry(runtime).setRetirementListener((page) => { + owner.cancelClientPageInitialization(page.browserPageId, page.placement) + }) } return registry } diff --git a/src/main/runtime/browser-host/leases/browser-host-lease-registry-options.ts b/src/main/runtime/browser-host/leases/browser-host-lease-registry-options.ts new file mode 100644 index 00000000000..fc3d879cec5 --- /dev/null +++ b/src/main/runtime/browser-host/leases/browser-host-lease-registry-options.ts @@ -0,0 +1,12 @@ +import type { RuntimeBrowserClientPlacement } from '../../../../shared/runtime-browser-placement' + +export type BrowserHostLeaseRegistryOptions = { + authorityRuntimeId: string + authorityEpoch?: string + onClientPageInitializationChanged?: (workspaceId: string) => void + reconnectGraceMs?: number + /** Release staged per-page state even after transport loss; an optional generation scopes cleanup. */ + onClientPageReleased?: (browserPageId: string, pageHostGeneration?: number) => void + /** Retain fenced page records for recovery when the same host identity returns. */ + onClientPageFenced?: (browserPageId: string, placement: RuntimeBrowserClientPlacement) => void +} diff --git a/src/main/runtime/browser-host/leases/browser-host-lease-registry.test.ts b/src/main/runtime/browser-host/leases/browser-host-lease-registry.test.ts index 822de15a613..c1abfd084f2 100644 --- a/src/main/runtime/browser-host/leases/browser-host-lease-registry.test.ts +++ b/src/main/runtime/browser-host/leases/browser-host-lease-registry.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { BrowserHostLeaseRegistry } from './browser-host-lease-registry' +import type { BrowserClientHostCommandEvent } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' const registry = (reconnectGraceMs?: number): BrowserHostLeaseRegistry => new BrowserHostLeaseRegistry({ @@ -42,3 +43,57 @@ describe('BrowserHostLeaseRegistry', () => { ).toThrow('browser_host_identity_conflict') }) }) + +it('refuses a long navigation for an older desktop while keeping its short commands usable', async () => { + const leases = registry() + const host = leases.attach({ + browserHostClientId: 'host-a', + connectionId: 'connection-a', + pairedDeviceId: 'device-a', + hostCapabilities: ['webview'], + pageCommandProtocolVersion: 1, + pageInventoryProtocolVersion: 1, + pageInventory: [], + pageReconciliationProtocolVersion: 1 + }) + const delivered: BrowserClientHostCommandEvent[] = [] + leases.attachCommandDelivery(host.lease, (event) => { + delivered.push(event) + leases.settleClientPageCommand(host.lease, { + ...event, + result: { + status: 'completed', + ...(event.command.type === 'navigate' ? { value: event.command.url } : {}) + } + }) + }) + try { + const placement = await leases.createClientPage({ + browserPageId: 'page-a', + browserHostClientId: 'host-a', + pairedDeviceId: 'device-a', + browserProfileId: 'default', + executionHostKey: 'host-key' + }) + const proof = { ...host.lease, ...placement, browserPageId: 'page-a' } + const before = delivered.length + expect(() => + leases.issueClientPageCommand(proof, { + type: 'navigate', + url: `https://example.internal/${'x'.repeat(8300)}` + }) + ).toThrow() + expect(delivered).toHaveLength(before) + const next = leases.issueClientPageCommand(proof, { + type: 'navigate', + url: 'https://example.internal/short' + }) + await expect(next.result).resolves.toEqual({ + status: 'completed', + value: 'https://example.internal/short' + }) + expect(leases.select('host-a')).toEqual(host.lease) + } finally { + host.release() + } +}) diff --git a/src/main/runtime/browser-host/leases/browser-host-lease-registry.ts b/src/main/runtime/browser-host/leases/browser-host-lease-registry.ts index e1373a6ce3f..cf51b3d2b31 100644 --- a/src/main/runtime/browser-host/leases/browser-host-lease-registry.ts +++ b/src/main/runtime/browser-host/leases/browser-host-lease-registry.ts @@ -1,20 +1,26 @@ +import type { BrowserHostLegacyPageRecoveryInput } from '../client-pages/browser-host-page-legacy-recovery' +import { createBrowserHostLeaseControllers } from './browser-host-lease-controllers' +import type { BrowserHostLeaseRegistryOptions } from './browser-host-lease-registry-options' +import type { BrowserHostPageInitializationInput } from '../client-pages/browser-host-page-initialization' import { switchBrowserHostClientProfile } from '../profiles/browser-host-client-profile-switch' import { randomUUID } from 'node:crypto' import type { BrowserClientHostCommandEvent } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' -import type { BrowserClientAutomationResult as BrowserClientHostCommandResult } from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' import type { BrowserHostCommandResultParams } from '../commands/browser-host-command-state' -import { - assertBrowserHostLeaseAdmission, - requireBrowserHostCommandResultLedger, - type BrowserHostCommandResultIdentity, - type BrowserHostLease, - type BrowserHostLeaseHandle, - type BrowserHostLeaseIdentity, - type BrowserHostLeaseState, - type BrowserTunnelLeaseHandle +import { requireBrowserHostCommandResultLedger } from './browser-host-lease-records' +import type { + BrowserHostCommandResultIdentity, + BrowserHostLease, + BrowserHostLeaseHandle, + BrowserHostLeaseIdentity, + BrowserHostLeaseState, + BrowserTunnelLeaseHandle } from './browser-host-lease-records' import { BrowserHostGenerationCounter } from './browser-host-generation-counter' -import { issueBrowserHostClientPageCommand } from '../commands/browser-host-client-page-command-issue' +import { + issueBrowserHostClientPageCommand, + attachBrowserHostClientCommandDelivery, + settleBrowserHostClientPageCommand +} from '../commands/browser-host-client-page-command-issue' import { BrowserHostPagePlacementRegistry, type BrowserClientPageAuthority, @@ -27,27 +33,32 @@ import type { import { requireLiveBrowserClientPage } from '../reconciliation/browser-host-page-authority' import type { BrowserHostFenceReason } from './browser-host-lease-fence' import { dispatchBrowserHostLeaseFence } from './browser-host-lease-fence-dispatch' -import { selectBrowserHostLease } from './browser-host-capability-selection' +import { selectActiveBrowserHostLease } from './browser-host-capability-selection' import { createBrowserHostClientPage, type BrowserClientPageExecutionHostGrant, type BrowserHostClientPageCreateOptions } from '../client-pages/browser-host-client-page-creation' -import { adoptBrowserHostClientPages } from '../client-pages/browser-host-client-page-adoption' -import { snapshotBrowserHostPageInventory } from '../reconciliation/browser-host-page-inventory-snapshot' -import { BrowserHostPageReconciliationOrchestrator } from '../reconciliation/browser-host-page-reconciliation-orchestration' -import type { BrowserHostRuntimePageIntent } from '../reconciliation/browser-host-page-reconciliation-plan' -import { BrowserHostLeaseReconnectController } from './browser-host-lease-reconnect' import { - assertBrowserHostReconnectNegotiation, - createBrowserHostLeaseState, + adoptBrowserHostClientPages, + type BrowserHostClientPageAdoptionOptions +} from '../client-pages/browser-host-client-page-adoption' +import type { BrowserHostRuntimePageIntent } from '../reconciliation/browser-host-page-reconciliation-plan' +import { + attachBrowserHostLease, type BrowserHostLeaseAttachInput } from './browser-host-lease-attachment' import { BrowserHostTunnelRegistry } from './browser-host-tunnel-registry' import { requireBrowserHostLeaseState } from './browser-host-lease-state-resolution' -import { requireBrowserClientPageConnection } from '../client-pages/browser-host-client-page-connection' +import { + requireBrowserClientPageConnection, + type BrowserClientPageConnectionInput +} from '../client-pages/browser-host-client-page-connection' import { completeBrowserHostPageRetirement } from '../reconciliation/browser-host-page-retirement-completion' -import { placeBrowserHostClientPage } from '../client-pages/browser-host-client-page-placement' +import { + placeBrowserHostClientPage, + selectPageExecutionHostKey +} from '../client-pages/browser-host-client-page-placement' export class BrowserHostLeaseRegistry { readonly authorityRuntimeId: string @@ -55,95 +66,62 @@ export class BrowserHostLeaseRegistry { private readonly generations = new BrowserHostGenerationCounter() private readonly leasesByClientId = new Map() private readonly tunnels = new BrowserHostTunnelRegistry() - private readonly clientPageExecutionHostGrants = new Map< - string, - BrowserClientPageExecutionHostGrant - >() + private readonly pageGrants = new Map() private readonly pagePlacements: BrowserHostPagePlacementRegistry - private readonly pageReconciliations: BrowserHostPageReconciliationOrchestrator - private readonly reconnects: BrowserHostLeaseReconnectController + private readonly controllers: ReturnType - constructor( - private readonly options: { - authorityRuntimeId: string - authorityEpoch?: string - reconnectGraceMs?: number - /** Release staged per-page state even after transport loss; an optional generation scopes cleanup. */ - onClientPageReleased?: (browserPageId: string, pageHostGeneration?: number) => void - /** Retain fenced page records for recovery when the same host identity returns. */ - onClientPageFenced?: (browserPageId: string, placement: RuntimeBrowserClientPlacement) => void - } - ) { + constructor(private readonly options: BrowserHostLeaseRegistryOptions) { this.authorityRuntimeId = options.authorityRuntimeId this.authorityEpoch = options.authorityEpoch ?? randomUUID() this.pagePlacements = new BrowserHostPagePlacementRegistry({ authorityRuntimeId: this.authorityRuntimeId, authorityEpoch: this.authorityEpoch }) - this.pageReconciliations = new BrowserHostPageReconciliationOrchestrator( + this.controllers = createBrowserHostLeaseControllers( this, - this.pagePlacements + this.pagePlacements, + this.pageGrants, + { + leases: this.leasesByClientId, + tunnels: this.tunnels, + reconnectGraceMs: options.reconnectGraceMs, + initializationChanged: options.onClientPageInitializationChanged, + fence: (state, reason) => this.fenceLease(state, reason) + } ) - this.reconnects = new BrowserHostLeaseReconnectController({ - graceMs: options.reconnectGraceMs ?? 15_000, - leasesByClientId: this.leasesByClientId, - fenceReconciliation: (state) => this.pageReconciliations.fence(state), - fenceLease: (state, reason) => this.fenceLease(state, reason), - fenceRoute: (state, reason) => this.tunnels.fence(state, reason) - }) } - attach(input: BrowserHostLeaseAttachInput): BrowserHostLeaseHandle { - const pageInventory = snapshotBrowserHostPageInventory(input) - assertBrowserHostReconnectNegotiation(input) - const existing = this.leasesByClientId.get(input.browserHostClientId) - if (existing && existing.lease.pairedDeviceId !== input.pairedDeviceId) { - throw new Error('browser_host_identity_conflict') - } - assertBrowserHostLeaseAdmission(this.leasesByClientId.values(), input, existing) - const restored = existing ? this.reconnects.restore(existing, input, pageInventory) : undefined - if (restored && existing) { - this.pageReconciliations.observeInventory(existing) - return restored - } - const generation = this.generations.take('host') - if (existing) { - this.fenceLease(existing, 'replaced') - } - const state = createBrowserHostLeaseState({ + attach( + input: BrowserHostLeaseAttachInput, + onReconciled: () => void = () => {} + ): BrowserHostLeaseHandle { + return attachBrowserHostLease(input, { authorityRuntimeId: this.authorityRuntimeId, authorityEpoch: this.authorityEpoch, - generation, - input, - pageInventory + leasesByClientId: this.leasesByClientId, + reconnects: this.controllers.reconnects, + generations: this.generations, + observeInventory: this.controllers.observeInventory(onReconciled), + fenceLease: (state) => this.fenceLease(state, 'replaced') }) - this.leasesByClientId.set(input.browserHostClientId, state) - return this.reconnects.createHandle(state) } - select( - browserHostClientId?: string, - requiredCapabilities: readonly string[] = [] - ): BrowserHostLease { - return selectBrowserHostLease( - new Map([...this.leasesByClientId].filter(([, state]) => state.status === 'active')), - browserHostClientId, - requiredCapabilities - ) + sealClientPageInitializations( + identity: BrowserHostLeaseIdentity & { connectionId?: string }, + blocked: boolean + ): void { + this.controllers.sealInitializations(identity, blocked) + } + + select(browserHostClientId?: string, capabilities: readonly string[] = []): BrowserHostLease { + return selectActiveBrowserHostLease(this.leasesByClientId, browserHostClientId, capabilities) } requireLease(identity: BrowserHostLeaseIdentity): BrowserHostLease { return this.requireLeaseState(identity).lease } - requireClientPageConnection(input: { - browserPageId: string - placement: RuntimeBrowserClientPlacement - pairedDeviceId: string - connectionId: string - /** File transfers only: a staged profile-switch guest downloads before its generation commits. */ - admitPendingProfileSwitch?: boolean - }): RuntimeBrowserClientPlacement { + requireClientPageConnection(input: BrowserClientPageConnectionInput) { return requireBrowserClientPageConnection(input, { authorityRuntimeId: this.authorityRuntimeId, authorityEpoch: this.authorityEpoch, @@ -194,28 +172,53 @@ export class BrowserHostLeaseRegistry { }) } - async createClientPage( - options: BrowserHostClientPageCreateOptions - ): Promise { + createClientPage(options: BrowserHostClientPageCreateOptions) { return createBrowserHostClientPage(options, { selectLease: (clientId, capabilities) => this.select(clientId, capabilities), requireLeaseState: (lease) => this.requireLeaseState(lease), pagePlacements: this.pagePlacements, - executionHostGrants: this.clientPageExecutionHostGrants + executionHostGrants: this.pageGrants, + initializations: this.controllers.initializations }) } + admitClientPageInitialization( + identity: BrowserHostLeaseIdentity, + input: BrowserHostPageInitializationInput + ) { + return this.controllers.initializations.admit(this.requireLeaseState(identity), input) + } + + get clientPageInitializations() { + return this.controllers.initializations + } + + claimLegacyClientPageRecovery( + identity: BrowserHostLeaseIdentity, + input: BrowserHostLegacyPageRecoveryInput + ) { + const state = this.requireLeaseState(identity) + return this.controllers.initializations.claimLegacyRecovery(state, input) + } + + cancelClientPageInitialization( + browserPageId: string, + expected: RuntimeBrowserClientPlacement + ): boolean { + return this.controllers.initializations.cancel(browserPageId, expected) + } + switchClientPageProfile( authority: BrowserClientPageAuthority, profile: Parameters[1], onLateCommit?: (placement: RuntimeBrowserClientPlacement) => void - ): Promise { + ) { return switchBrowserHostClientProfile(authority, profile, { onLateCommit, registry: this, state: (clientId) => this.requireLeaseState(this.select(clientId, [])), placements: this.pagePlacements, - grants: this.clientPageExecutionHostGrants, + grants: this.pageGrants, onCommitted: this.options.onClientPageReleased }) } @@ -227,34 +230,37 @@ export class BrowserHostLeaseRegistry { adoptClientPages( identity: BrowserHostLeaseIdentity, intents: readonly BrowserHostRuntimePageIntent[], - options: { maxConcurrency?: number; actionTimeoutMs?: number; signal?: AbortSignal } = {} + options: BrowserHostClientPageAdoptionOptions = {} ): Promise { return adoptBrowserHostClientPages(intents, options, { state: this.requireLeaseState(identity), - reconciliations: this.pageReconciliations, + reconciliations: this.controllers.reconciliations, placements: this.pagePlacements, - executionHostGrants: this.clientPageExecutionHostGrants + executionHostGrants: this.pageGrants }) } + retireUnpublishedClientPage( + identity: BrowserHostLeaseIdentity, + input: BrowserHostPageInitializationInput + ): void { + this.controllers.initializations.retireUnpublished(this.requireLeaseState(identity), input) + } + attachCommandDelivery( identity: BrowserHostLeaseIdentity, delivery: (event: BrowserClientHostCommandEvent) => void ): () => void { - const ledger = this.requireLeaseState(identity).commandLedger - if (!ledger) { - throw new Error('browser_host_command_protocol_required') - } - return ledger.attach(delivery) + const state = this.requireLeaseState(identity) + const release = attachBrowserHostClientCommandDelivery(state, delivery) + this.controllers.initializations.resume(state) + return release } issueClientPageCommand( authority: BrowserClientPageAuthority, command: BrowserClientHostCommandEvent['command'] - ): { - event: BrowserClientHostCommandEvent - result: Promise - } { + ): ReturnType { this.requireClientPage(authority) return issueBrowserHostClientPageCommand(authority, command, this.leasesByClientId) } @@ -263,11 +269,12 @@ export class BrowserHostLeaseRegistry { identity: BrowserHostCommandResultIdentity, params: BrowserHostCommandResultParams ): boolean { - const ledger = this.requireCommandResultLedger(identity) - if (!ledger.isUnplacedPageResult(params)) { - this.requireClientPage(params) - } - return ledger.settle(params) + return settleBrowserHostClientPageCommand( + this.requireLeaseState(identity), + identity, + params, + (authority) => this.requireClientPage(authority) + ) } requireCommandResultLedger(identity: BrowserHostCommandResultIdentity) { @@ -278,17 +285,11 @@ export class BrowserHostLeaseRegistry { return this.pagePlacements.getPlacement(browserPageId) } - getClientPageExecutionHostKey(browserPageId: string): string | undefined { - const grant = this.clientPageExecutionHostGrants.get(browserPageId) - return grant && this.pagePlacements.getPlacement(browserPageId) === grant.placement - ? grant.executionHostKey - : undefined + getClientPageExecutionHostKey(id: string): string | undefined { + return selectPageExecutionHostKey(id, this.pageGrants, this.pagePlacements) } - beginPageRetirement( - browserPageId: string, - expected: RuntimeBrowserPlacement - ): BrowserPageRetirement { + beginPageRetirement(browserPageId: string, expected: RuntimeBrowserPlacement) { return this.pagePlacements.beginPageRetirement(browserPageId, expected) } @@ -300,7 +301,7 @@ export class BrowserHostLeaseRegistry { return completeBrowserHostPageRetirement(retirement, { pagePlacements: this.pagePlacements, leasesByClientId: this.leasesByClientId, - executionHostGrants: this.clientPageExecutionHostGrants, + executionHostGrants: this.pageGrants, onClientPageReleased: this.options.onClientPageReleased }) } @@ -316,8 +317,9 @@ export class BrowserHostLeaseRegistry { dispatchBrowserHostLeaseFence(state, reason, { leasesByClientId: this.leasesByClientId, pagePlacements: this.pagePlacements, - clearReconnect: (fenced) => this.reconnects.clear(fenced), - fenceReconciliation: (fenced) => this.pageReconciliations.fence(fenced), + retireInitializations: (fenced) => this.controllers.initializations.retireAuthority(fenced), + clearReconnect: (fenced) => this.controllers.reconnects.clear(fenced), + fenceReconciliation: this.controllers.fenceReconciliation, fenceRoute: (route, routeReason) => this.tunnels.fence(route, routeReason), releaseFencedPage: (retirement) => this.releaseFencedClientPage(retirement) }) diff --git a/src/main/runtime/browser-host/profiles/browser-host-client-profile-switch.ts b/src/main/runtime/browser-host/profiles/browser-host-client-profile-switch.ts index bacba6dca23..f1a1c7b1545 100644 --- a/src/main/runtime/browser-host/profiles/browser-host-client-profile-switch.ts +++ b/src/main/runtime/browser-host/profiles/browser-host-client-profile-switch.ts @@ -13,9 +13,9 @@ import type { import { clientPageCreationTimeout, waitForClientPageCreationResult, - closeUncommittedClientPage, type BrowserClientPageExecutionHostGrant } from '../client-pages/browser-host-client-page-creation' +import { closeUncommittedClientPage } from '../client-pages/browser-host-uncommitted-client-page' const pendingByPlacements = new WeakMap< BrowserHostPagePlacementRegistry, diff --git a/src/main/runtime/browser-host/reconciliation/browser-host-page-placement.test.ts b/src/main/runtime/browser-host/reconciliation/browser-host-page-placement.test.ts index 72c85c23941..e289232139b 100644 --- a/src/main/runtime/browser-host/reconciliation/browser-host-page-placement.test.ts +++ b/src/main/runtime/browser-host/reconciliation/browser-host-page-placement.test.ts @@ -21,3 +21,19 @@ describe('browser host page placement authority', () => { expect(pages.commitClientPageReservation(replacement)).toBe(replacement.placement) }) }) + +it('charges one page through reservation cancellation and exact current cleanup', () => { + const pages = placements(1) + const host = { browserHostClientId: 'host-a', browserHostGeneration: 1 } + const reservation = pages.reserveNewClientPage('page-a', host) + const releaseCleanup = pages.claimClientPageCleanup('page-a') + pages.cancelClientPageReservation(reservation) + expect(() => pages.placeServerPage('page-b')).toThrow('browser_page_placement_capacity') + const replacement = pages.reserveNewClientPage('page-a', host) + pages.commitClientPageReservation(replacement) + releaseCleanup() + releaseCleanup() + expect(() => pages.placeServerPage('page-b')).toThrow('browser_page_placement_capacity') + pages.completePageRetirement(pages.beginPageRetirement('page-a', replacement.placement)) + expect(pages.placeServerPage('page-b')).toEqual({ kind: 'server' }) +}) diff --git a/src/main/runtime/browser-host/reconciliation/browser-host-page-placement.ts b/src/main/runtime/browser-host/reconciliation/browser-host-page-placement.ts index 7b3893388e6..2f033ddf6fa 100644 --- a/src/main/runtime/browser-host/reconciliation/browser-host-page-placement.ts +++ b/src/main/runtime/browser-host/reconciliation/browser-host-page-placement.ts @@ -42,8 +42,7 @@ export class BrowserHostPagePlacementRegistry { private nextPageGeneration = 1 private readonly placementsByPageId = new Map() private readonly reservationsByPageId = new Map() - private readonly reservationSlotClaims = new Set() - private reservedPlacementSlots = 0 + private readonly slotOwners = new Map() private readonly maxPagePlacements: number constructor( @@ -56,6 +55,7 @@ export class BrowserHostPagePlacementRegistry { placeServerPage(browserPageId: string): RuntimeBrowserServerPlacement { this.assertPlacementAdmission(browserPageId) const placement = Object.freeze({ kind: 'server' as const }) + this.claimSlot(browserPageId) this.placementsByPageId.set(browserPageId, { placement }) return placement } @@ -72,6 +72,7 @@ export class BrowserHostPagePlacementRegistry { browserHostGeneration: host.browserHostGeneration, pageHostGeneration: this.takePageGeneration() }) + this.claimSlot(browserPageId) this.placementsByPageId.set(browserPageId, { placement }) return placement } @@ -87,13 +88,7 @@ export class BrowserHostPagePlacementRegistry { if (this.reservationsByPageId.has(browserPageId)) { throw new Error('browser_page_reconciliation_reservation_pending') } - const claimsPlacementSlot = !this.placementsByPageId.has(browserPageId) - if ( - claimsPlacementSlot && - this.placementsByPageId.size + this.reservedPlacementSlots >= this.maxPagePlacements - ) { - throw new Error('browser_page_placement_capacity') - } + this.assertSlotAvailable(browserPageId) if (pageHostGeneration < this.nextPageGeneration) { throw new Error('browser_page_generation_stale') } @@ -108,17 +103,20 @@ export class BrowserHostPagePlacementRegistry { }) }) this.reservationsByPageId.set(browserPageId, reservation) - if (claimsPlacementSlot) { - this.claimReservationSlot(reservation) - } + this.claimSlot(browserPageId) return reservation } reserveNewClientPage( browserPageId: string, - host: BrowserHostPlacementIdentity + host: BrowserHostPlacementIdentity, + minimumGeneration = 1 ): BrowserClientPagePlacementReservation { - return this.reserveClientPage(browserPageId, host, this.nextPageGeneration) + return this.reserveClientPage( + browserPageId, + host, + Math.max(this.nextPageGeneration, minimumGeneration) + ) } commitClientPageReservation( @@ -131,7 +129,6 @@ export class BrowserHostPagePlacementRegistry { throw new Error('browser_page_replacement_requires_retirement') } this.reservationsByPageId.delete(reservation.browserPageId) - this.releaseReservationSlot(reservation) this.placementsByPageId.set(reservation.browserPageId, { placement: reservation.placement }) @@ -151,7 +148,7 @@ export class BrowserHostPagePlacementRegistry { throw new Error('browser_page_placement_stale') } this.reservationsByPageId.delete(reservation.browserPageId) - this.releaseReservationSlot(reservation) + this.releaseSlot(reservation.browserPageId) this.placementsByPageId.set(reservation.browserPageId, { placement: reservation.placement }) return reservation.placement } @@ -161,7 +158,7 @@ export class BrowserHostPagePlacementRegistry { return false } this.reservationsByPageId.delete(reservation.browserPageId) - this.releaseReservationSlot(reservation) + this.releaseSlot(reservation.browserPageId) return true } @@ -254,11 +251,9 @@ export class BrowserHostPagePlacementRegistry { state.retirementCompletionInProgress = true try { beforeComplete?.() - const reservation = this.reservationsByPageId.get(retirement.browserPageId) - if (reservation) { - this.claimReservationSlot(reservation) - } - return this.placementsByPageId.delete(retirement.browserPageId) + this.placementsByPageId.delete(retirement.browserPageId) + this.releaseSlot(retirement.browserPageId) + return true } finally { state.retirementCompletionInProgress = false } @@ -272,9 +267,7 @@ export class BrowserHostPagePlacementRegistry { ) { throw new Error('browser_page_replacement_requires_retirement') } - if (this.placementsByPageId.size + this.reservedPlacementSlots >= this.maxPagePlacements) { - throw new Error('browser_page_placement_capacity') - } + this.assertSlotAvailable(browserPageId) } private takePageGeneration(): number { @@ -286,16 +279,35 @@ export class BrowserHostPagePlacementRegistry { return value } - private claimReservationSlot(reservation: BrowserClientPagePlacementReservation): void { - if (!this.reservationSlotClaims.has(reservation)) { - this.reservationSlotClaims.add(reservation) - this.reservedPlacementSlots += 1 + claimClientPageCleanup(browserPageId: string): () => void { + assertBrowserPageIdentity(browserPageId) + this.assertSlotAvailable(browserPageId) + this.claimSlot(browserPageId) + let owned = true + return () => { + if (owned) { + owned = false + this.releaseSlot(browserPageId) + } } } - private releaseReservationSlot(reservation: BrowserClientPagePlacementReservation): void { - if (this.reservationSlotClaims.delete(reservation)) { - this.reservedPlacementSlots -= 1 + private assertSlotAvailable(browserPageId: string): void { + if (!this.slotOwners.has(browserPageId) && this.slotOwners.size >= this.maxPagePlacements) { + throw new Error('browser_page_placement_capacity') + } + } + + private claimSlot(browserPageId: string): void { + this.slotOwners.set(browserPageId, (this.slotOwners.get(browserPageId) ?? 0) + 1) + } + + private releaseSlot(browserPageId: string): void { + const owners = this.slotOwners.get(browserPageId) + if (owners === 1) { + this.slotOwners.delete(browserPageId) + } else if (owners) { + this.slotOwners.set(browserPageId, owners - 1) } } } diff --git a/src/main/runtime/browser-host/reconciliation/browser-host-page-reconciliation-actions.ts b/src/main/runtime/browser-host/reconciliation/browser-host-page-reconciliation-actions.ts index 3ab1aa49bd0..e5ae913be80 100644 --- a/src/main/runtime/browser-host/reconciliation/browser-host-page-reconciliation-actions.ts +++ b/src/main/runtime/browser-host/reconciliation/browser-host-page-reconciliation-actions.ts @@ -116,9 +116,10 @@ export class BrowserHostPageReconciliationActions { intent: BrowserHostRuntimePageIntent, signal: AbortSignal ): Promise { - const url = this.plan.closeThenRestore.find( - (pair) => pair.intent.browserPageId === intent.browserPageId - )?.page.currentUrl + const url = + intent.url ?? + this.plan.closeThenRestore.find((pair) => pair.intent.browserPageId === intent.browserPageId) + ?.page.currentUrl await this.issueCommand( intent.browserPageId, intent.pageHostGeneration, diff --git a/src/main/runtime/browser-host/reconciliation/browser-host-page-reconciliation-plan.ts b/src/main/runtime/browser-host/reconciliation/browser-host-page-reconciliation-plan.ts index 673527e2080..634f2a7623e 100644 --- a/src/main/runtime/browser-host/reconciliation/browser-host-page-reconciliation-plan.ts +++ b/src/main/runtime/browser-host/reconciliation/browser-host-page-reconciliation-plan.ts @@ -20,6 +20,7 @@ export type BrowserHostRuntimePageIntent = BrowserHostPageAuthority & executionHostKey: string /** Round-tripped to the client so the page's inventory keeps naming its workspace. */ workspaceId?: string + url?: string reclaimFrom?: BrowserHostPageAuthority & Readonly<{ pairedDeviceId: string }> }> @@ -61,11 +62,15 @@ export function planBrowserHostPageReconciliation( continue } consumedPageIds.add(page.browserPageId) - if (page.state === 'active' && sameCurrentPage(intent, page)) { + if (page.state === 'active' && page.currentUrl !== undefined && sameCurrentPage(intent, page)) { retain.push(Object.freeze({ intent, page })) continue } - if (page.state === 'active' && canReclaimPage(intent, page, options.inventoryPairedDeviceId)) { + if ( + page.state === 'active' && + page.currentUrl !== undefined && + canReclaimPage(intent, page, options.inventoryPairedDeviceId) + ) { reclaim.push(Object.freeze({ intent, page })) continue } diff --git a/src/main/runtime/browser/client-pages/browser-client-page-close.ts b/src/main/runtime/browser/client-pages/browser-client-page-close.ts index f9ee5478766..11dd1d9c55f 100644 --- a/src/main/runtime/browser/client-pages/browser-client-page-close.ts +++ b/src/main/runtime/browser/client-pages/browser-client-page-close.ts @@ -20,8 +20,12 @@ export async function retireRuntimeBrowserClientPage( clientPage: RuntimeBrowserClientPage ): Promise<{ closed: true }> { const authority = host.getBrowserHostLeaseRegistry() + const initializing = authority.cancelClientPageInitialization( + clientPage.browserPageId, + clientPage.placement + ) // Retained tabs outlive placements; requiring an absent host would make them impossible to dismiss. - if (authority.getPlacement(clientPage.browserPageId)) { + if (!initializing && authority.getPlacement(clientPage.browserPageId)) { await closeRuntimeBrowserClientPage(authority, { browserPageId: clientPage.browserPageId, placement: clientPage.placement diff --git a/src/main/runtime/browser/client-pages/browser-client-page-opening.ts b/src/main/runtime/browser/client-pages/browser-client-page-opening.ts index ae4a8ee155c..2c0a78b4a27 100644 --- a/src/main/runtime/browser/client-pages/browser-client-page-opening.ts +++ b/src/main/runtime/browser/client-pages/browser-client-page-opening.ts @@ -33,6 +33,7 @@ export type BrowserClientPageOpeningHost = Pick< | 'getRuntimeBrowserPageRegistry' | 'markHeadlessBrowserSessionTabActive' | 'notifyHeadlessBrowserSessionTabsChanged' + | 'retireRuntimeOwnedBrowserSessionTab' > type ResolvedWorkspace = Awaited> @@ -46,7 +47,11 @@ export type BrowserClientPageCreateParams = { placement: { kind: 'client'; browserHostClientId: string } } -export type BrowserClientPageCaller = { pairedDeviceId?: string; clientKind?: 'mobile' | 'runtime' } +export type BrowserClientPageCaller = { + pairedDeviceId?: string + clientKind?: 'mobile' | 'runtime' + signal?: AbortSignal +} export const DEFAULT_BROWSER_PROFILE_ID = 'default' @@ -84,33 +89,46 @@ export async function createBrowserClientPage( const browserPageId = params.page ?? randomUUID() const executionHost = await host.resolveBrowserNetworkExecutionHost(worktree) const authority = host.getBrowserHostLeaseRegistry() + const pages = host.getRuntimeBrowserPageRegistry() + if (pages.getPage(browserPageId)) { + throw new Error('browser_page_replacement_requires_retirement') + } const created = await createRuntimeBrowserClientPage(authority, { browserPageId, browserHostClientId: params.placement.browserHostClientId, pairedDeviceId: caller.pairedDeviceId, browserProfileId, executionHost, - workspaceId: worktree.id - }) - const pages = host.getRuntimeBrowserPageRegistry() - pages.publishClientPage({ - browserPageId, workspaceId: worktree.id, - browserProfileId, - executionHostKey: browserNetworkExecutionHostKey(executionHost), - placement: created.placement, - pairedDeviceId: caller.pairedDeviceId, - url: 'about:blank', - loading: url !== 'about:blank', - active: focus.startsActive - }) - publishCreatedBrowserSessionTab(host, { - placementKind: 'client', - browserPageId, - worktreeId: worktree.id, - focus, - clientNavigationId: caller.pairedDeviceId, - targetGroupId: params.targetGroupId + signal: caller.signal, + committed: (placement) => { + try { + pages.publishClientPage({ + browserPageId, + workspaceId: worktree.id, + browserProfileId, + executionHostKey: browserNetworkExecutionHostKey(executionHost), + placement: placement, + pairedDeviceId: caller.pairedDeviceId, + url: 'about:blank', + loading: url !== 'about:blank', + active: focus.startsActive + }) + publishCreatedBrowserSessionTab(host, { + placementKind: 'client', + browserPageId, + worktreeId: worktree.id, + focus, + clientNavigationId: caller.pairedDeviceId, + targetGroupId: params.targetGroupId + }) + } catch (error) { + if (pages.retirePage(browserPageId, placement)) { + host.retireRuntimeOwnedBrowserSessionTab?.(worktree.id, browserPageId) + } + throw error + } + } }) if (url !== 'about:blank') { try { @@ -130,13 +148,16 @@ export async function createBrowserClientPage( export function openBrowserUrlOnClient( host: BrowserClientPageOpeningHost, - params: { url: string; worktree: string } + params: { url: string; worktree: string }, + signal?: AbortSignal ): Promise<{ browserPageId: string }> { assertClientOpenableUrl(params.url) return createBrowserPageOnPairedHost( host, { url: params.url, worktree: params.worktree, activate: true, navigation: 'caller' }, - DEFAULT_BROWSER_PROFILE_ID + DEFAULT_BROWSER_PROFILE_ID, + undefined, + signal ) } @@ -152,7 +173,8 @@ export async function createBrowserPageOnPairedHost( params: Omit & { worktree?: string }, browserProfileId: string, /** The server's own browser, used when no paired desktop takes the page. */ - fallback?: () => Promise<{ browserPageId: string }> + fallback?: () => Promise<{ browserPageId: string }>, + signal?: AbortSignal ): Promise<{ browserPageId: string }> { const { worktree: selector, ...rest } = params const worktree = selector ? await host.resolveBrowserWorkspace(selector) : undefined @@ -172,7 +194,7 @@ export async function createBrowserPageOnPairedHost( { worktree, browserProfileId, - caller: { pairedDeviceId: lease.pairedDeviceId, clientKind: 'runtime' } + caller: { pairedDeviceId: lease.pairedDeviceId, clientKind: 'runtime', signal } } ) } diff --git a/src/main/runtime/browser/client-pages/browser-page-initialization-notifications.ts b/src/main/runtime/browser/client-pages/browser-page-initialization-notifications.ts new file mode 100644 index 00000000000..7f93fdcb977 --- /dev/null +++ b/src/main/runtime/browser/client-pages/browser-page-initialization-notifications.ts @@ -0,0 +1,25 @@ +/** Collect synchronous admission output; later real transitions publish immediately. */ +export function batchBrowserPageInitializationNotifications( + notify: (workspaceId: string) => void, + admit: (notify: (workspaceId: string) => void) => T +): T { + let collecting = true + const workspaces = new Set() + const changed = (workspace: string): void => { + if (collecting) { + workspaces.add(workspace) + } else { + notify(workspace) + } + } + try { + return admit(changed) + } finally { + collecting = false + const pending = [...workspaces] + workspaces.clear() + for (const workspace of pending) { + notify(workspace) + } + } +} diff --git a/src/main/runtime/browser/client-pages/client-hosted-browser-page-persistence.integration.test.ts b/src/main/runtime/browser/client-pages/client-hosted-browser-page-persistence.integration.test.ts index 5888046b96a..0f59ce0b451 100644 --- a/src/main/runtime/browser/client-pages/client-hosted-browser-page-persistence.integration.test.ts +++ b/src/main/runtime/browser/client-pages/client-hosted-browser-page-persistence.integration.test.ts @@ -2,7 +2,7 @@ * Real Store writes are covered by store-runtime-authored-session-writes.test.ts. */ import { describe, expect, it } from 'vitest' import type { RuntimeBrowserClientPlacement } from '../../../../shared/runtime-browser-placement' -import type { WorkspaceSessionState } from '../../../../shared/workspace-session/workspace-session-state-types' +import { createProfile } from './client-hosted-browser-persistence-test-harness' import { getBrowserHostLeaseRegistry } from '../../browser-host/leases/browser-host-lease-registry-instance' import { isRestoredClientHostedBrowserPlacement } from './client-hosted-browser-page-persistence' import { OrcaRuntimeService } from '../../orca-runtime' @@ -10,58 +10,6 @@ import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' const WT = 'repo-1::/tmp/worktree-a' -const storeBase = { - getRepo: () => ({ - id: 'repo-1', - path: '/tmp/repo', - displayName: 'repo', - badgeColor: 'blue', - addedAt: 1 - }), - getRepos: () => [storeBase.getRepo()], - addRepo: () => {}, - updateRepo: () => undefined as never, - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined, - getGitHubCache: () => ({ pr: {}, issue: {} }), - setWorktreeMeta: () => undefined as never, - removeWorktreeMeta: () => {}, - getRetiredWorktreeNameRegistry: () => ({ exhaustedTiers: 0, names: [] }), - addRetiredWorktreeName: () => {}, - mergeRetiredWorktreeNames: () => false, - getSettings: () => ({ - workspaceDir: '/tmp/workspaces', - nestWorkspaces: false, - refreshLocalBaseRefOnWorktreeCreate: false, - branchPrefix: 'none', - branchPrefixCustom: '' - }) -} - -function createProfile() { - const state = { - session: { - activeRepoId: 'repo-1', - activeWorktreeId: WT, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {} - } as WorkspaceSessionState - } - return { - get session() { - return state.session - }, - store: { - ...storeBase, - getWorkspaceSession: () => state.session, - setWorkspaceSession: (next: WorkspaceSessionState) => { - state.session = next - } - } - } -} - function startRuntime(profile: ReturnType): OrcaRuntimeService { const runtime = new OrcaRuntimeService(profile.store, undefined, {}) runtime.rehydrateClientHostedBrowserPages() diff --git a/src/main/runtime/browser/client-pages/client-page-ordinary-request-test-harness.ts b/src/main/runtime/browser/client-pages/client-page-ordinary-request-test-harness.ts new file mode 100644 index 00000000000..64f029e12cb --- /dev/null +++ b/src/main/runtime/browser/client-pages/client-page-ordinary-request-test-harness.ts @@ -0,0 +1,102 @@ +import { RpcDispatcher } from '../../rpc/dispatcher' +import { BROWSER_CORE_METHODS } from '../../rpc/methods/browser/browser-core' +import { RuntimeBrowserTabCreation } from '../tabs/browser-tab-creation' +import type { createRecoveryLoop } from './client-page-recovery-loop-test-harness' +import { vi } from 'vitest' +import { orcadClientPageCommand } from '../../../orcad/browser-providers/orcad-client-page-commands' +import type { RuntimeBrowserCommandHost } from '../commands/browser-command-contracts' + +export function ordinaryPageRpc(h: ReturnType, remote = false) { + h.native.dependencies.retainNetworkRoute.mockImplementation(async (key) => ({ + ...h.native.route, + key + })) + const host: RuntimeBrowserCommandHost = { + getRuntimeId: () => 'runtime-a', + resolveWorkspaceSelector: async () => ({ id: 'repo-1::/tmp/worktree-a' }), + getAgentBrowserBridge: () => null, + resolveBrowserWorkspace: async () => ({ id: 'repo-1::/tmp/worktree-a' }), + resolveBrowserNetworkExecutionHost: async () => ({ + kind: 'native', + runtimeId: 'runtime-a', + revision: 1 + }), + getBrowserHostLeaseRegistry: () => h.authority, + getBrowserHostRequestRegistry: () => { + throw new Error('unexpected_host_volunteer') + }, + getRuntimeBrowserPageRegistry: () => h.pages, + getAvailableAuthoritativeWindow: () => null, + getOffscreenBrowserBackend: () => null, + getAuthoritativeWindow: () => { + throw new Error('unexpected_host_window') + }, + notifyHeadlessBrowserSessionTabsChanged: (id) => + h.hostRuntime.notifyMobileSessionTabsChanged(id) + } + const creation = new RuntimeBrowserTabCreation(host, { + requireAgentBrowserBridge: () => { + throw new Error('unexpected_bridge') + }, + notifyRendererNavigation: () => {} + }) + vi.spyOn(h.hostRuntime, 'browserTabCreate').mockImplementation( + remote + ? async (...args) => + orcadClientPageCommand( + host, + 'browserTabCreate', + () => { + throw new Error('unexpected_engine') + }, + () => false + )!(...args) + : creation.browserTabCreate.bind(creation) + ) + vi.spyOn(h.hostRuntime, 'browserOpenUrlOnClient').mockImplementation( + remote + ? async (...args) => + orcadClientPageCommand( + host, + 'browserOpenUrlOnClient', + () => { + throw new Error('unexpected_engine') + }, + () => false + )!(...args) + : creation.browserOpenUrlOnClient.bind(creation) + ) + const rpc = new RpcDispatcher({ runtime: h.hostRuntime, methods: BROWSER_CORE_METHODS }) + return async ( + method: 'browser.tabCreate' | 'browser.openUrl', + page: string, + signal: AbortSignal, + url = 'https://example.internal/new' + ) => { + const replies: { ok: boolean; error?: { message: string } }[] = [] + await rpc.dispatchStreaming( + { + id: page, + authToken: 'test', + method, + params: + method === 'browser.tabCreate' + ? { + worktree: 'id:repo-1::/tmp/worktree-a', + page, + ...(remote + ? {} + : { placement: { kind: 'client', browserHostClientId: 'client-a' } }) + } + : { worktree: 'id:repo-1::/tmp/worktree-a', url } + }, + (text) => replies.push(JSON.parse(text)), + { + ...h.caller(), + ...(remote ? { pairedDeviceId: undefined, clientKind: undefined } : {}), + signal + } + ) + return replies + } +} diff --git a/src/main/runtime/browser/client-pages/client-page-ordinary-request.test.ts b/src/main/runtime/browser/client-pages/client-page-ordinary-request.test.ts new file mode 100644 index 00000000000..7323f418058 --- /dev/null +++ b/src/main/runtime/browser/client-pages/client-page-ordinary-request.test.ts @@ -0,0 +1,325 @@ +import { expect, it, vi } from 'vitest' +import { createRecoveryLoop } from './client-page-recovery-loop-test-harness' +import { ordinaryPageRpc } from './client-page-ordinary-request-test-harness' +import { RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT } from './client-hosted-browser-page-persistence' +import { browserNetworkExecutionHostKey } from '../../../../shared/browser/client-hosting/browser-network-execution-host' + +const WORKSPACE = 'repo-1::/tmp/worktree-a' + +it.each([ + ['browser.tabCreate', false], + ['browser.openUrl', false], + ['browser.tabCreate', true], + ['browser.openUrl', true] +] as const)( + 'cancels a real %s request (remote=%s) before preparation without late allocation', + async (method, remote) => { + const h = createRecoveryLoop([]) + try { + h.pages.publishClientPage({ + browserPageId: 'slow', + workspaceId: WORKSPACE, + browserProfileId: 'default', + pairedDeviceId: 'device-a', + placement: RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT, + executionHostKey: 'old-key', + url: 'https://example.internal/slow', + loading: false, + active: false + }) + await h.seedNative(['slow'], 'default') + const cleanup = h.holdRetirement('slow') + h.attach() + await cleanup.started + const request = ordinaryPageRpc(h, remote) + const entered = Promise.withResolvers() + const original = h.authority.createClientPage.bind(h.authority) + vi.spyOn(h.authority, 'createClientPage').mockImplementation((options) => { + const result = original(options) + entered.resolve() + return result + }) + const aborted = new AbortController() + const reply = request(method, 'abandoned', aborted.signal) + await entered.promise + aborted.abort() + expect(await reply).toEqual([expect.objectContaining({ ok: false })]) + expect(h.commands.some((c) => c.command.type === 'createPage')).toBe(false) + cleanup.release() + await h.published('slow') + expect( + h.commands.filter((c) => c.command.type === 'createPage').map((c) => c.browserPageId) + ).toEqual(['slow']) + expect(h.pages.listPages().map((p) => p.browserPageId)).toEqual(['slow']) + } finally { + await h.close() + } + } +) + +it('keeps exact ordinary allocation cleanup charged after cancellation and leaves a committed handoff alive on later abort', async () => { + const h = createRecoveryLoop([]) + try { + h.attach() + const mount = h.holdMount('ordinary') + const cleanup = h.holdRetirement('ordinary') + const signal = new AbortController() + const creating = h.authority.createClientPage({ + browserPageId: 'ordinary', + browserHostClientId: 'client-a', + pairedDeviceId: 'device-a', + browserProfileId: 'profile-a', + executionHostKey: 'execution-host-a', + workspaceId: WORKSPACE, + signal: signal.signal + }) + const rejected = expect(creating).rejects.toThrow('browser_host_page_creation_cancelled') + await mount.started + signal.abort() + await rejected + expect(h.authority.getPlacement('ordinary')).toBeUndefined() + // Reusing the still-native id cannot bypass the existing exact cleanup owner. + await expect( + h.authority.createClientPage({ + browserPageId: 'ordinary', + browserHostClientId: 'client-a', + pairedDeviceId: 'device-a', + browserProfileId: 'profile-a', + executionHostKey: 'execution-host-a', + timeoutMs: 5 + }) + ).rejects.toThrow('browser_host_page_creation_timeout') + // A different ready profile cannot reuse an id still owned by exact cleanup. + await expect( + h.authority.createClientPage({ + browserPageId: 'ordinary', + browserHostClientId: 'client-a', + pairedDeviceId: 'device-a', + browserProfileId: 'profile-b', + executionHostKey: 'rejected-key' + }) + ).rejects.toThrow('browser_page_replacement_requires_retirement') + expect(() => + h.authority.requireExecutionHost(h.authority.select('client-a'), 'rejected-key') + ).toThrow() + mount.release() + await cleanup.started + expect(h.executor.snapshotPageInventory().some((p) => p.browserPageId === 'ordinary')).toBe( + true + ) + cleanup.release() + await vi.waitFor(() => expect(h.executor.snapshotPageInventory()).toEqual([])) + const later = new AbortController() + const placement = await h.authority.createClientPage({ + browserPageId: 'handed-off', + browserHostClientId: 'client-a', + pairedDeviceId: 'device-a', + browserProfileId: 'profile-b', + executionHostKey: 'execution-host-a', + signal: later.signal + }) + later.abort() + expect(h.authority.getPlacement('handed-off')).toEqual(placement) + expect(h.executor.snapshotPageInventory().map((p) => p.browserPageId)).toEqual(['handed-off']) + } finally { + await h.close() + } +}) + +it('rejects a real duplicate tabCreate without changing the clean stopped saved row', async () => { + const h = createRecoveryLoop(['slow']) + try { + const started = h.hold('slow') + h.attach() + await started + const old = h.pages.getPage('slow')! + expect(h.authority.clientPageInitializations.stop('slow', old.placement)).toBe(true) + await vi.waitFor(() => expect(h.executor.snapshotPageInventory()).toEqual([])) + const row = h.pages.getPage('slow') + const creates = h.commands.filter((c) => c.command.type === 'createPage').length + expect( + await ordinaryPageRpc(h)('browser.tabCreate', 'slow', new AbortController().signal) + ).toEqual([expect.objectContaining({ ok: false })]) + expect(h.pages.getPage('slow')).toEqual(row) + expect(h.authority.clientPageInitializations.status('slow', old.placement)).toBe('stopped') + expect(h.authority.getPlacement('slow')).toBeUndefined() + expect(h.commands.filter((c) => c.command.type === 'createPage')).toHaveLength(creates) + expect(h.executor.snapshotPageInventory()).toEqual([]) + } finally { + await h.close() + } +}) + +it('rechecks the current attach between ready listeners, drops an aborted waiter and admits a live request once', async () => { + const h = createRecoveryLoop(['slow']) + try { + await h.seedNative(['slow']) + const cleanup = h.holdRetirement('slow') + const first = h.attach() + await cleanup.started + const cancelled = new AbortController() + let reattached = false + const create = (id: string, signal?: AbortSignal) => + h.authority.createClientPage({ + browserPageId: id, + browserHostClientId: 'client-a', + pairedDeviceId: 'device-a', + browserProfileId: 'profile-a', + executionHostKey: 'execution-host-a', + signal + }) + h.hooks.delivery = (event, dispatch) => { + if (!reattached && event.browserPageId === 'trigger' && event.command.type === 'createPage') { + reattached = true + h.attach() + first.abort() + cancelled.abort() + } + dispatch() + } + const trigger = create('trigger') + const dropped = expect(create('cancelled', cancelled.signal)).rejects.toThrow( + 'browser_host_page_creation_cancelled' + ) + const live = create('live') + cleanup.release() + await Promise.all([trigger, dropped, live]) + expect(h.authority.select('client-a').connectionId).toBe('connection-2') + expect( + h.commands.filter((c) => c.command.type === 'createPage' && c.browserPageId === 'live') + ).toHaveLength(1) + expect(h.commands.some((c) => c.browserPageId === 'cancelled')).toBe(false) + expect(h.authority.getPlacement('live')?.kind).toBe('client') + } finally { + await h.close() + } +}) + +it('retires a preparation waiter on full authority death without late creation', async () => { + const h = createRecoveryLoop(['slow']) + try { + await h.seedNative(['slow']) + const cleanup = h.holdRetirement('slow') + h.attach() + await cleanup.started + const creating = h.authority.createClientPage({ + browserPageId: 'abandoned', + browserHostClientId: 'client-a', + pairedDeviceId: 'device-a', + browserProfileId: 'profile-a', + executionHostKey: 'execution-host-a' + }) + const rejected = expect(creating).rejects.toThrow('browser_host_lease_stale') + h.fence() + await rejected + cleanup.release() + expect(h.commands.some((c) => c.browserPageId === 'abandoned')).toBe(false) + expect(h.authority.getPlacement('abandoned')).toBeUndefined() + } finally { + await h.close() + } +}) + +it('keeps a successful real RPC handoff alive after caller cancellation', async () => { + const h = createRecoveryLoop([]) + try { + h.attach() + const cancelled = new AbortController() + expect(await ordinaryPageRpc(h)('browser.tabCreate', 'handed-off', cancelled.signal)).toEqual([ + expect.objectContaining({ ok: true }) + ]) + cancelled.abort() + expect(h.pages.getPage('handed-off')?.placement).toEqual(h.authority.getPlacement('handed-off')) + expect(h.executor.snapshotPageInventory().map((p) => p.browserPageId)).toEqual(['handed-off']) + } finally { + await h.close() + } +}) + +it('transfers a real logical-capacity publication failure to exact cleanup until its join', async () => { + const h = createRecoveryLoop([]) + try { + h.attach() + // Seal the empty attach through an ordinary successfully published caller first. + const request = ordinaryPageRpc(h) + expect(await request('browser.tabCreate', 'first', new AbortController().signal)).toEqual([ + expect.objectContaining({ ok: true }) + ]) + for (let i = 0; i < 255; i += 1) { + h.pages.publishClientPage({ + browserPageId: `saved-${i}`, + workspaceId: WORKSPACE, + browserProfileId: 'default', + pairedDeviceId: 'device-a', + placement: RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT, + executionHostKey: 'saved-key', + url: 'https://example.internal/saved', + loading: false, + active: false + }) + } + const cleanup = h.holdRetirement('over-capacity') + expect( + await request('browser.tabCreate', 'over-capacity', new AbortController().signal) + ).toEqual([expect.objectContaining({ ok: false })]) + await cleanup.started + expect(h.pages.getPage('over-capacity')).toBeUndefined() + expect(h.authority.getPlacement('over-capacity')).toBeUndefined() + expect( + h.executor.snapshotPageInventory().some((p) => p.browserPageId === 'over-capacity') + ).toBe(true) + // One successful page and the failed native allocation each still own a placement slot. + for (let i = 0; i < 254; i += 1) { + h.authority.placeServerPage(`slot-${i}`) + } + expect(() => h.authority.placeServerPage('blocked')).toThrow() + cleanup.release() + await vi.waitFor(() => + expect(h.executor.snapshotPageInventory().map((p) => p.browserPageId)).toEqual(['first']) + ) + expect(h.authority.placeServerPage('released')).toEqual({ kind: 'server' }) + const key = browserNetworkExecutionHostKey({ + kind: 'native', + runtimeId: 'runtime-a', + revision: 1 + }) + // The successful first page still owns this route grant after failed-page cleanup. + expect(() => + h.authority.requireExecutionHost(h.authority.select('client-a'), key) + ).not.toThrow() + expect(h.pages.getPage('first')).toBeDefined() + } finally { + await h.close() + } +}) + +it('keeps the created tab usable when an older desktop cannot receive its long initial URL', async () => { + const h = createRecoveryLoop([]) + try { + h.attach({ modern: false }) + const request = ordinaryPageRpc(h) + const long = `https://example.internal/${'x'.repeat(8300)}` + const before = h.authority.select('client-a') + expect(await request('browser.openUrl', 'long', new AbortController().signal, long)).toEqual([ + expect.objectContaining({ ok: true }) + ]) + expect(h.commands.some((event) => event.command.type === 'createPage')).toBe(true) + expect( + h.commands.some((event) => event.command.type === 'navigate' && event.command.url === long) + ).toBe(false) + expect(h.authority.select('client-a')).toEqual(before) + expect(await request('browser.openUrl', 'short', new AbortController().signal)).toEqual([ + expect.objectContaining({ ok: true }) + ]) + expect( + h.commands.some( + (event) => + event.command.type === 'navigate' && event.command.url === 'https://example.internal/new' + ) + ).toBe(true) + expect(h.authority.select('client-a')).toEqual(before) + expect(h.errors).toEqual([]) + } finally { + await h.close() + } +}) diff --git a/src/main/runtime/browser/client-pages/client-page-recovery-actions.test.ts b/src/main/runtime/browser/client-pages/client-page-recovery-actions.test.ts new file mode 100644 index 00000000000..d7b567548c3 --- /dev/null +++ b/src/main/runtime/browser/client-pages/client-page-recovery-actions.test.ts @@ -0,0 +1,524 @@ +import * as initializationSettlement from '../../browser-host/client-pages/browser-host-page-initialization-settlement' +import * as initializationCommands from '../../browser-host/client-pages/browser-host-page-initialization-commands' +import * as pageAdoption from './runtime-browser-client-page-adoption' +import { workspaceSessionStateSchema } from '../../../../shared/workspace-session/workspace-session-schema' +import { prepareBrowserClientPageInventoryForAttach } from '../../../browser/client-pages/lifecycle/browser-client-page-inventory' +import { BrowserClientPageMetadataTransport } from '../../../browser/client-pages/renderer/browser-client-page-metadata-transport' +import { + buildPersistedClientHostedBrowserPages, + rehydrateClientHostedBrowserPages +} from './client-hosted-browser-page-persistence' +import { BrowserClientHostCommandEvent } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import { expect, it, vi } from 'vitest' +import { createRecoveryLoop } from './client-page-recovery-loop-test-harness' +import { retireRuntimeBrowserClientPage } from './browser-client-page-close' + +const WORKSPACE = 'repo-1::/tmp/worktree-a' +type Harness = ReturnType +async function action(h: Harness, type: 'stop' | 'navigate', url?: string) { + const page = h.pages.getPage('slow')! + const responses: { ok: boolean; result?: unknown; error?: unknown }[] = [] + await h.rpc.dispatchStreaming( + { + id: 'action', + authToken: 'test', + method: 'browser.clientPage.initializationAction', + params: { + worktree: `id:${WORKSPACE}`, + page: 'slow', + expectedPlacement: page.placement, + action: type === 'stop' ? { type } : { type, url } + } + }, + (text) => responses.push(JSON.parse(text)), + h.caller() + ) + expect(responses).toHaveLength(1) + return responses[0] +} + +it('accepts missing-guest Stop and Retry, supersedes a pending agent goto, and honors intentional blank', async () => { + const h = createRecoveryLoop(['slow', 'fast']) + const navigating = h.hold('slow') + try { + h.attach() + await navigating + const next = h.hold('next') + const goto = h.hostRuntime.routeClientHostedBrowserRpc( + 'browser.goto', + { + page: 'slow', + url: 'https://example.internal/next', + expectUrl: 'https://example.internal/slow' + }, + { kind: 'local-cli' } + ) + const cancelled = expect(goto).rejects.toThrow('initialization_cancelled') + await next + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + const stopped = await action(h, 'stop') + expect(stopped).toMatchObject({ ok: true }) + await cancelled + await vi.waitFor(() => + expect(h.executor.snapshotPageInventory().map((p) => p.browserPageId)).toEqual(['fast']) + ) + expect( + h.hostRuntime + .snapshotForClient(WORKSPACE, 'device-a') + .tabs.find((t) => t.type === 'browser' && t.browserPageId === 'slow') + ).toMatchObject({ + initializationState: 'stopped', + loading: false, + url: 'https://example.internal/slow' + }) + const retried = h.hold('retry') + expect((await action(h, 'navigate', 'https://example.internal/retry')).ok).toBe(true) + await retried + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/retry') + expect( + h.profile.session.clientHostedBrowserPagesByWorktree?.[WORKSPACE]?.find( + (p) => p.browserPageId === 'slow' + ) + ).not.toHaveProperty('initializationState') + const blank = await action(h, 'navigate', 'about:blank') + expect(blank.ok).toBe(true) + await h.published('slow') + expect(h.pages.getPage('slow')?.url).toBe('data:text/html,') + expect( + h.authority.clientPageInitializations.status('slow', h.pages.getPage('slow')!.placement) + ).toBeUndefined() + expect(h.errors).toEqual([]) + } finally { + await h.close() + } +}) + +it('follows the same row through commit while the workspace resolver is awaited and rejects a closed owner', async () => { + const h = createRecoveryLoop(['slow']) + const navigating = h.hold('slow') + try { + h.attach() + await navigating + const resolving = Promise.withResolvers() + const resolved = Promise.withResolvers() + h.hooks.beforeWorkspace = async () => { + resolving.resolve() + await resolved.promise + } + const stop = action(h, 'stop') + await resolving.promise + h.release('slow') + await h.published('slow') + resolved.resolve() + expect(await stop).toMatchObject({ ok: true }) + expect(h.commands.findLast((c) => c.command.type === 'automation')?.command).toMatchObject({ + method: 'browser.eval', + params: { expression: 'window.stop()' }, + agent: false + }) + h.hooks.beforeWorkspace = undefined + // A second held resolver cannot cross logical close and act on the removed page. + const waiting = Promise.withResolvers() + const done = Promise.withResolvers() + h.hooks.beforeWorkspace = async () => { + waiting.resolve() + await done.promise + } + const stale = action(h, 'navigate', 'https://example.internal/stale') + await waiting.promise + await retireRuntimeBrowserClientPage( + { + getBrowserHostLeaseRegistry: () => h.authority, + getRuntimeBrowserPageRegistry: () => h.pages, + notifyHeadlessBrowserSessionTabsChanged: (id) => + h.hostRuntime.notifyMobileSessionTabsChanged(id), + resolveBrowserWorkspace: async () => ({ id: WORKSPACE }) + }, + h.pages.getPage('slow')! + ) + const commands = h.commands.length + done.resolve() + expect((await stale).ok).toBe(false) + expect(h.commands).toHaveLength(commands) + expect(h.pages.getPage('slow')).toBeUndefined() + } finally { + await h.close() + } +}) + +it('acknowledges committed Navigate without waiting for its full load', async () => { + const h = createRecoveryLoop(['slow']) + const started = h.hold('slow') + const resolving = Promise.withResolvers() + const resolved = Promise.withResolvers() + try { + h.attach() + await started + h.hooks.beforeWorkspace = async () => { + resolving.resolve() + await resolved.promise + } + const accepted = action(h, 'navigate', 'https://example.internal/held-full-load') + await resolving.promise + h.release('slow') + await h.published('slow') + const completedBefore = h.results.filter((r) => r.type === 'navigate').length + const loading = h.hold('held-full-load') + resolved.resolve() + expect(await accepted).toMatchObject({ ok: true }) + await loading + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/held-full-load') + expect(h.results.filter((r) => r.type === 'navigate')).toHaveLength(completedBefore) + h.release('held-full-load') + } finally { + resolved.resolve() + await h.close() + } +}) + +it('rejects superseded goto promptly while its native cleanup is held, and preserves clean Stop through same-lease reconnect', async () => { + const h = createRecoveryLoop(['slow', 'unfinished']) + const initial = h.hold('slow') + const unfinished = h.hold('unfinished') + try { + const first = h.attach() + await Promise.all([initial, unfinished]) + expect( + h.hostRuntime.snapshotForClient(WORKSPACE, 'device-a').clientHostedPagesUnreconciled + ).toBe(true) + const following = h.hold('next') + const goto = h.hostRuntime.routeClientHostedBrowserRpc( + 'browser.goto', + { + page: 'slow', + url: 'https://example.internal/next' + }, + { kind: 'local-cli' } + ) + const rejected = expect(goto).rejects.toThrow('initialization_cancelled') + await following + const cleanup = h.holdRetirement('slow') + expect(await action(h, 'stop')).toMatchObject({ ok: true }) + await rejected + await cleanup.started + expect(h.executor.snapshotPageInventory().some((p) => p.browserPageId === 'slow')).toBe(true) + const commandCount = h.commands.filter( + (c) => c.browserPageId === 'slow' && c.command.type === 'createPage' + ).length + cleanup.release() + await vi.waitFor(() => + expect(h.executor.snapshotPageInventory().some((p) => p.browserPageId === 'slow')).toBe(false) + ) + const reattached = h.attach() + first.abort() + await first.completion + await vi.waitFor(() => expect(h.authority.select('client-a').connectionId).toBe('connection-2')) + expect( + h.authority.clientPageInitializations.status('slow', h.pages.getPage('slow')!.placement) + ).toBe('stopped') + expect( + h.commands.filter((c) => c.browserPageId === 'slow' && c.command.type === 'createPage') + ).toHaveLength(commandCount) + h.release('unfinished') + await h.published('unfinished') + await vi.waitFor(() => expect(h.snapshots.some((s) => s.reconciled)).toBe(true)) + expect( + h.hostRuntime.snapshotForClient(WORKSPACE, 'device-a').clientHostedPagesUnreconciled + ).toBeUndefined() + expect(await action(h, 'navigate', 'about:blank')).toMatchObject({ ok: true }) + await h.published('slow') + expect(h.pages.getPage('slow')?.url).toBe('data:text/html,') + reattached.abort() + await reattached.completion + } finally { + await h.close() + } +}) + +it.each(['successor', 'operation'] as const)( + 'keeps the current UI destination across reconnect while its %s is pending', + async (phase) => { + const h = createRecoveryLoop(['slow']) + const started = h.hold('slow') + try { + const first = h.attach() + await started + const cleanup = h.holdRetirement('slow') + if (phase === 'operation') { + expect(await action(h, 'stop')).toMatchObject({ ok: true }) + await cleanup.started + cleanup.release() + await vi.waitFor(() => expect(h.executor.snapshotPageInventory()).toEqual([])) + } + const requested = h.hold('B') + expect(await action(h, 'navigate', 'https://example.internal/B')).toMatchObject({ ok: true }) + await (phase === 'successor' ? cleanup.started : requested) + const before = h.nativeLoop.initializedUrls.length + const replacement = phase === 'operation' ? h.hold('B') : requested + const second = h.attach() + first.abort() + await first.completion + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/B') + cleanup.release() + await replacement + h.release('B', 'https://example.internal/B-redirect') + await vi.waitFor(() => + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/B-redirect') + ) + expect(h.nativeLoop.initializedUrls.slice(before)).toEqual(['https://example.internal/B']) + expect(buildPersistedClientHostedBrowserPages(h.pages.listPages(), 100)[0].url).toBe( + 'https://example.internal/B-redirect' + ) + expect(h.errors).toEqual([]) + second.abort() + await second.completion + } finally { + await h.close() + } + } +) + +it.each(['reconnect', 'rehydrate'] as const)( + 'keeps an offline native commit after exact guest loss and %s', + async (mode) => { + const h = createRecoveryLoop(['slow']) + let resumed: Harness | undefined + try { + const first = h.attach() + await h.published('slow') + first.disconnect() + first.abort() + await first.completion + const inventory = h.executor.snapshotPageInventory()[0] + const metadata = new BrowserClientPageMetadataTransport(h.executor.recordPublishedPageUrl) + await expect( + metadata.publish({ + ...inventory, + revision: 1, + url: 'https://example.internal/C', + title: 'Committed offline', + loading: false, + canGoBack: true, + canGoForward: false + }) + ).rejects.toThrow('unavailable') + h.nativeLoop.lose('slow') + expect(h.executor.snapshotPageInventory()[0]).toMatchObject({ + state: 'outcomeUnknown', + currentUrl: 'https://example.internal/C' + }) + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + const target = + mode === 'rehydrate' + ? (resumed = createRecoveryLoop([], { nativeLoop: h.nativeLoop, executor: h.executor })) + : h + if (mode === 'rehydrate') { + rehydrateClientHostedBrowserPages(target.pages, { + listWorkspaceSessions: () => [h.profile.session], + isKnownWorktree: () => true + }) + expect(target.pages.getPage('slow')?.placement.pageHostGeneration).toBe(0) + } + const next = target.attach() + await vi.waitFor(() => + expect(target.pages.getPage('slow')?.url).toBe('https://example.internal/C') + ) + expect( + target.commands.findLast((event) => event.command.type === 'navigate')?.command + ).toEqual({ type: 'navigate', url: 'https://example.internal/C' }) + next.abort() + await next.completion + expect(target.errors).toEqual([]) + } finally { + await resumed?.close() + await h.close() + } + } +) + +it.each(['pending', 'stopped'] as const)( + 'passes an ordinary valid long address through the %s action and actual native commit', + async (phase) => { + const h = createRecoveryLoop(['slow']) + const started = h.hold('slow') + const url = `https://example.internal/${'x'.repeat(8300)}` + const native = h.hold('x'.repeat(8300)) + try { + h.attach() + await started + if (phase === 'stopped') { + expect(await action(h, 'stop')).toMatchObject({ ok: true }) + } + expect(await action(h, 'navigate', url)).toMatchObject({ ok: true }) + await native + const inventory = h.executor.snapshotPageInventory() + expect(inventory[0]).not.toHaveProperty('currentUrl') + expect(prepareBrowserClientPageInventoryForAttach(inventory)).toHaveLength(1) + const creation = h.commands.findLast((event) => event.command.type === 'createPage')! + const navigation = h.commands.findLast((event) => event.command.type === 'navigate')! + expect(BrowserClientHostCommandEvent.parse(creation).command).toMatchObject({ + initialUrl: url + }) + expect(BrowserClientHostCommandEvent.parse(navigation).command).toEqual({ + type: 'navigate', + url + }) + h.release('x'.repeat(8300), url) + await vi.waitFor(() => + expect( + h.authority.clientPageInitializations.status('slow', h.pages.getPage('slow')!.placement) + ).toBeUndefined() + ) + expect(h.pages.getPage('slow')?.url).toBe(url) + expect(buildPersistedClientHostedBrowserPages(h.pages.listPages(), 100)[0].url).toBe(url) + const loaded = workspaceSessionStateSchema.parse( + JSON.parse(JSON.stringify(h.profile.session)) + ) + const restored = createRecoveryLoop([]) + try { + rehydrateClientHostedBrowserPages(restored.pages, { + listWorkspaceSessions: () => [loaded], + isKnownWorktree: () => true + }) + expect(restored.pages.getPage('slow')?.url).toBe(url) + expect(restored.executor.snapshotPageInventory()).toEqual([]) + restored.attach() + await restored.published('slow') + await vi.waitFor(() => + expect( + restored.authority.clientPageInitializations.status( + 'slow', + restored.pages.getPage('slow')!.placement + ) + ).toBeUndefined() + ) + expect(restored.pages.getPage('slow')?.url).toBe(url) + expect(restored.nativeLoop.initializedUrls).toEqual([url]) + } finally { + await restored.close() + } + } finally { + await h.close() + } + } +) + +it('keeps the UI successor through a truthful empty reconnect while the prior native close result is in transit', async () => { + const h = createRecoveryLoop(['slow']) + const initial = h.hold('slow') + const closeReply = Promise.withResolvers() + const physicallyClosed = Promise.withResolvers() + const trace: string[] = [] + const initialization = h.authority.clientPageInitializations + const settle = initializationSettlement.settleInitialization + vi.spyOn(initializationSettlement, 'settleInitialization').mockImplementation( + (operation, outcome, owner) => { + const requested = operation.admission.input.url + trace.push(`terminal-enter:${requested}:${outcome}`) + const result = settle(operation, outcome, owner) + trace.push(`terminal-return:${requested}:${outcome}`) + return result + } + ) + const forget = initializationCommands.forgetAbsentInitializationTarget + vi.spyOn(initializationCommands, 'forgetAbsentInitializationTarget').mockImplementation( + (...args) => { + trace.push('authoritative-absence-join') + return forget(...args) + } + ) + const observe = initialization.observeAttach.bind(initialization) + vi.spyOn(initialization, 'observeAttach').mockImplementation((state, inventory, complete) => { + trace.push(`observe:${state.lease.connectionId}:${inventory?.length}`) + if (state.lease.connectionId === 'connection-2') { + trace.push('release-close-result-inside-observe') + closeReply.resolve() + } + return observe(state, inventory, complete) + }) + const admit = initialization.admit.bind(initialization) + vi.spyOn(initialization, 'admit').mockImplementation((state, input) => { + const connectionId = state.lease.connectionId + trace.push(`admit:${connectionId}:${input.url}`) + const disposition = admit(state, { + ...input, + settled: (outcome) => { + trace.push(`settle:${connectionId}:${outcome}`) + input.settled?.(outcome) + } + }) + trace.push(`admit-return:${connectionId}:${disposition}`) + return disposition + }) + const delivery = h.authority.attachCommandDelivery.bind(h.authority) + vi.spyOn(h.authority, 'attachCommandDelivery').mockImplementation((...args) => { + trace.push('delivery-attached') + return delivery(...args) + }) + const adopt = pageAdoption.adoptRuntimeBrowserClientPagesFromInventory + vi.spyOn(pageAdoption, 'adoptRuntimeBrowserClientPagesFromInventory').mockImplementation( + (options) => { + const result = adopt(options) + trace.push(`adoption-return:${options.lease.connectionId}`) + return result + } + ) + const seal = initialization.sealAttach.bind(initialization) + vi.spyOn(initialization, 'sealAttach').mockImplementation((...args) => { + trace.push(`seal:${args[0].lease.connectionId}`) + return seal(...args) + }) + const acceptResult = h.authority.settleClientPageCommand.bind(h.authority) + vi.spyOn(h.authority, 'settleClientPageCommand').mockImplementation((...args) => { + const accepted = acceptResult(...args) + const oldClose = h.commands.find((event) => event.command.type === 'closePage') + trace.push( + `ledger-result:${args[1].commandId === oldClose?.commandId ? 'old-close' : 'other'}:${accepted}` + ) + return accepted + }) + try { + const first = h.attach() + await initial + const cleanup = h.holdRetirement('slow') + h.hooks.beforeResult = async (event) => { + if (event.command.type === 'closePage') { + trace.push('native-closed-result-held') + physicallyClosed.resolve() + await closeReply.promise + trace.push('close-result-sent') + } + } + h.hooks.onResult = (event) => trace.push(`result:${event.command.type}`) + const requested = h.hold('B') + expect(await action(h, 'navigate', 'https://example.internal/B')).toMatchObject({ ok: true }) + await cleanup.started + cleanup.release() + await physicallyClosed.promise + const inventory = h.executor.snapshotPageInventory() + expect(inventory).toEqual([]) + trace.push('truthful-empty-attach') + const second = h.attach({ inventory }) + await requested + trace.push('B-native-started') + h.release('B', 'https://example.internal/B-redirect') + await vi.waitFor(() => + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/B-redirect') + ) + expect(h.nativeLoop.initializedUrls).toEqual([ + 'https://example.internal/slow', + 'https://example.internal/B' + ]) + expect(buildPersistedClientHostedBrowserPages(h.pages.listPages(), 100)[0].url).toBe( + 'https://example.internal/B-redirect' + ) + expect(h.errors).toEqual([]) + console.info('R18-R1 native/result transport trace', trace) + first.abort() + second.abort() + await Promise.all([first.completion, second.completion]) + } finally { + closeReply.resolve() + await h.close() + } +}) diff --git a/src/main/runtime/browser/client-pages/client-page-recovery-admission-scaling.test.ts b/src/main/runtime/browser/client-pages/client-page-recovery-admission-scaling.test.ts new file mode 100644 index 00000000000..4a0cbf36f18 --- /dev/null +++ b/src/main/runtime/browser/client-pages/client-page-recovery-admission-scaling.test.ts @@ -0,0 +1,331 @@ +import { expect, it, vi } from 'vitest' +import { createRecoveryLoop } from './client-page-recovery-loop-test-harness' +import * as persistence from './client-hosted-browser-page-persistence' +const WORKSPACE = 'repo-1::/tmp/worktree-a' + +it.each([ + [25, 1, 750], + [250, 3, 7500] +])( + 'publishes %i recovery admissions once per affected workspace (%i across %i saved worktrees)', + async (count, groups, worktrees) => { + const h = createRecoveryLoop([], { worktreeCount: worktrees }) + const original = persistence.persistClientHostedBrowserPages + const work = { passes: 0, rows: 0 } + const measured = vi + .spyOn(persistence, 'persistClientHostedBrowserPages') + .mockImplementation((host, registry, id) => + original( + host, + { + listPages: (workspace) => { + const rows = registry.listPages(workspace) + work.passes += 1 + work.rows += rows.length + return rows + } + }, + id + ) + ) + const ids = Array.from({ length: count }, (_, i) => `page-${i}`) + const workspaces = Array.from({ length: groups }, (_, i) => + i === 0 ? WORKSPACE : `repo-1::/tmp/worktree-${i}` + ) + try { + for (const [index, id] of ids.entries()) { + h.pages.publishClientPage({ + browserPageId: id, + workspaceId: workspaces[index % groups], + browserProfileId: 'profile-a', + executionHostKey: 'old-key', + placement: persistence.RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT, + pairedDeviceId: 'device-a', + url: `https://example.internal/${id}`, + loading: false, + active: false + }) + } + const started = ids.map((id) => h.hold(id)) + h.attach({ inventory: [] }) + await started[0] + // A synchronous recovery cohort has one required initial publication per workspace, regardless of row count. + expect(work).toEqual({ passes: groups, rows: count }) + expect(h.profile.work.writes).toBe(groups) + for (const workspace of workspaces) { + const rows = h.hostRuntime + .snapshotForClient(workspace, 'device-a') + .tabs.filter((t) => t.type === 'browser') + expect(rows.length).toBeGreaterThan(0) + expect(rows.every((t) => t.initializationState === 'pending')).toBe(true) + } + } finally { + measured.mockRestore() + await h.close() + } + } +) + +it.each([ + [10, false], + [100, false], + [10, true], + [100, true] +] as const)( + 'expires %i ordinary requests before reservation while exact predecessor cleanup is unknown=%s', + async (count, unknown) => { + const h = createRecoveryLoop(['slow']) + const replaced = vi.spyOn(h.pages, 'replaceClientPagePlacement') + let release = () => {} + const calls: Promise[] = [] + try { + await h.seedNative(['slow'], 'profile-a', unknown) + const held = h.holdRetirement('slow') + release = held.release + if (unknown) { + h.native.renderer.retirePage.mockRejectedValueOnce(new Error('renderer-retirement-failed')) + } + h.attach() + await held.started + if (unknown) { + release() + await vi.waitFor(() => + expect(h.results).toContainEqual({ type: 'closePage', id: 'slow', status: 'failed' }) + ) + } + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const outcomes: string[] = [] + for (let i = 0; i < count; i += 1) { + calls.push( + h.authority + .createClientPage({ + browserPageId: `abandoned-${i}`, + browserHostClientId: 'client-a', + pairedDeviceId: 'device-a', + browserProfileId: 'profile-a', + executionHostKey: 'waiting-key', + timeoutMs: 10, + workspaceId: WORKSPACE + }) + .then( + () => { + outcomes.push('unexpected-success') + }, + (error) => { + outcomes.push(error.message) + } + ) + ) + } + const lease = h.authority.select('client-a') + expect(() => h.authority.requireExecutionHost(lease, 'waiting-key')).toThrow() + // The predecessor owns one slot; waiting callers must leave all 255 others usable. + for (let i = 0; i < 254; i += 1) { + h.authority.placeServerPage(`capacity-${i}`) + } + expect(outcomes).toEqual([]) + expect(h.commands.some((c) => c.browserPageId.startsWith('abandoned-'))).toBe(false) + const independent = await h.authority.createClientPage({ + browserPageId: 'independent', + browserHostClientId: 'client-a', + pairedDeviceId: 'device-a', + browserProfileId: 'profile-b', + executionHostKey: 'execution-host-a', + workspaceId: WORKSPACE + }) + expect(independent.pageHostGeneration).toBeGreaterThan(0) + expect(() => h.authority.placeServerPage('overflow')).toThrow() + await vi.advanceTimersByTimeAsync(11) + expect(outcomes).toEqual(Array(count).fill('browser_host_page_creation_timeout')) + expect(() => h.authority.requireExecutionHost(lease, 'waiting-key')).toThrow() + vi.useRealTimers() + release() + if (unknown) { + h.attach() + await vi.waitFor(() => { + expect(h.errors).toEqual([]) + expect(h.results).toContainEqual({ type: 'closePage', id: 'slow', status: 'completed' }) + }) + } + await h.published('slow') + expect( + h.commands.filter((c) => c.browserPageId === 'slow' && c.command.type === 'createPage') + ).toHaveLength(1) + expect(replaced.mock.calls.filter(([id]) => id === 'slow')).toHaveLength(1) + await Promise.all(calls) + expect(h.commands.some((c) => c.browserPageId.startsWith('abandoned-'))).toBe(false) + expect( + h.executor + .snapshotPageInventory() + .map((p) => p.browserPageId) + .sort() + ).toEqual(['independent', 'slow']) + } finally { + release() + vi.useRealTimers() + await h.close() + await Promise.all(calls) + } + } +) + +it.each([25, 250])( + 'batches saved-row and new-seed adoption output together at %i rows, then publishes an actual commit immediately', + async (count) => { + const ids = Array.from({ length: count }, (_, i) => `adopt-${i}`) + const h = createRecoveryLoop(ids.slice(0, Math.floor(count / 2))) + try { + await h.seedNative(ids) + const cleanup = h.holdRetirement(ids[0]) + const started = ids.map((id) => h.hold(id)) + h.attach() + await cleanup.started + expect(h.profile.work.writes).toBe(1) + const tabs = h.hostRuntime + .snapshotForClient(WORKSPACE, 'device-a') + .tabs.filter((t) => t.type === 'browser') + expect(tabs).toHaveLength(count) + expect(tabs.every((t) => t.initializationState === 'pending')).toBe(true) + cleanup.release() + await started[0] + const before = h.profile.work.writes + h.release(ids[0], 'https://example.internal/redirected') + await h.published(ids[0]) + expect(h.profile.work.writes).toBe(before + 1) + expect(h.pages.getPage(ids[0])?.url).toBe('https://example.internal/redirected') + } finally { + await h.close() + } + } +) + +it('flushes an earlier valid pending admission when a later page hits real shared capacity', async () => { + const h = createRecoveryLoop(['first', 'second']) + const observed: string[] = [] + try { + for (let i = 0; i < 255; i += 1) { + h.authority.placeServerPage(`existing-${i}`) + } + h.hooks.onNotify = () => { + const status = h.authority.clientPageInitializations.status( + 'first', + h.pages.getPage('first')!.placement + ) + if (status) { + observed.push(status) + } + } + const connection = h.attach({ inventory: [] }) + await connection.completion + expect(observed).toContain('pending') + expect( + h.profile.session.clientHostedBrowserPagesByWorktree?.[WORKSPACE]?.map((p) => p.browserPageId) + ).toEqual(['first', 'second']) + } finally { + await h.close() + } +}) + +it('lets an ordinary page progress beside four genuinely held recovery documents', async () => { + const ids = ['a', 'b', 'c', 'd'] + const h = createRecoveryLoop(ids) + try { + const started = ids.map((id) => h.hold(id)) + h.attach() + await Promise.all(started) + const placement = await h.authority.createClientPage({ + browserPageId: 'ordinary', + browserHostClientId: 'client-a', + pairedDeviceId: 'device-a', + browserProfileId: 'profile-a', + executionHostKey: 'execution-host-a' + }) + expect(placement.pageHostGeneration).toBeGreaterThan(0) + expect( + h.executor + .snapshotPageInventory() + .map((p) => p.browserPageId) + .sort() + ).toEqual(['a', 'b', 'c', 'd', 'ordinary']) + expect(h.pages.listPages().every((p) => p.placement.pageHostGeneration === 0)).toBe(true) + } finally { + await h.close() + } +}) + +it('keeps a failed exact retirement charged and parks recovery until another legitimate attach', async () => { + const h = createRecoveryLoop(['slow']) + try { + await h.seedNative(['slow'], 'profile-a', true) + h.native.renderer.retirePage.mockRejectedValue(new Error('renderer-retirement-unavailable')) + h.attach() + await vi.waitFor(() => + expect(h.results.filter((r) => r.id === 'slow' && r.type === 'closePage')).toEqual([ + { id: 'slow', type: 'closePage', status: 'failed' } + ]) + ) + expect(h.executor.snapshotPageInventory()).toMatchObject([ + { browserPageId: 'slow', state: 'outcomeUnknown' } + ]) + for (let i = 0; i < 254; i += 1) { + h.authority.placeServerPage(`occupied-${i}`) + } + const independent = await h.authority.createClientPage({ + browserPageId: 'independent', + browserHostClientId: 'client-a', + pairedDeviceId: 'device-a', + browserProfileId: 'profile-b', + executionHostKey: 'execution-host-a', + workspaceId: WORKSPACE + }) + expect(independent.pageHostGeneration).toBeGreaterThan(0) + expect(() => h.authority.placeServerPage('overflow')).toThrow() + await expect( + h.authority.createClientPage({ + browserPageId: 'waiting', + browserHostClientId: 'client-a', + pairedDeviceId: 'device-a', + browserProfileId: 'profile-a', + executionHostKey: 'waiting-key', + workspaceId: WORKSPACE, + timeoutMs: 10 + }) + ).rejects.toThrow('browser_host_page_creation_timeout') + expect(h.results.filter((r) => r.id === 'slow' && r.type === 'closePage')).toHaveLength(1) + const sealed = vi.spyOn(h.authority, 'sealClientPageInitializations') + h.attach() + await vi.waitFor(() => { + expect(sealed).toHaveBeenCalled() + expect( + h.results.filter((r) => r.id === 'slow' && r.type === 'closePage' && r.status === 'failed') + ).toHaveLength(2) + }) + expect(h.native.renderer.retirePage).toHaveBeenCalledTimes(2) + expect( + h.results + .filter((r) => r.id === 'slow' && r.type === 'closePage') + .every((r) => r.status === 'failed') + ).toBe(true) + expect( + new Set( + h.commands + .filter((c) => c.browserPageId === 'slow' && c.command.type === 'closePage') + .map((c) => c.commandId) + ).size + ).toBe(2) + expect( + h.commands.filter((c) => c.browserPageId === 'slow' && c.command.type === 'createPage') + ).toEqual([]) + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + expect(h.executor.snapshotPageInventory()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ browserPageId: 'slow', state: 'outcomeUnknown' }), + expect.objectContaining({ browserPageId: 'independent', state: 'active' }) + ]) + ) + expect(() => h.authority.placeServerPage('still-overflow')).toThrow() + } finally { + h.native.renderer.retirePage.mockResolvedValue(undefined) + await h.close() + } +}) diff --git a/src/main/runtime/browser/client-pages/client-page-recovery-cancellation.test.ts b/src/main/runtime/browser/client-pages/client-page-recovery-cancellation.test.ts new file mode 100644 index 00000000000..27b7e5c6a17 --- /dev/null +++ b/src/main/runtime/browser/client-pages/client-page-recovery-cancellation.test.ts @@ -0,0 +1,161 @@ +import { BrowserHostCommandLedger } from '../../browser-host/commands/browser-host-command-ledger' +import { expect, it, vi } from 'vitest' +import { createRecoveryLoop } from './client-page-recovery-loop-test-harness' +import { retireRuntimeBrowserClientPage } from './browser-client-page-close' + +it.each(['create', 'gap', 'navigate', 'publication'])( + 'logical close during %s retires the exact initialization without a late saved row', + async (phase) => { + const h = createRecoveryLoop(['slow', 'fast']) + const closed = Promise.withResolvers() + const close = () => { + const page = h.pages.getPage('slow') + if (!page) { + return + } + void retireRuntimeBrowserClientPage( + { + getBrowserHostLeaseRegistry: () => h.authority, + notifyHeadlessBrowserSessionTabsChanged: (id) => + h.hostRuntime.notifyMobileSessionTabsChanged(id), + getRuntimeBrowserPageRegistry: () => h.pages, + resolveBrowserWorkspace: async () => ({ id: 'workspace' }) + }, + page + ).then(() => closed.resolve(), closed.reject) + } + if (phase === 'gap') { + h.hooks.onResult = (event) => { + if (event.browserPageId === 'slow' && event.command.type === 'createPage') { + close() + } + } + } + if (phase === 'publication') { + h.hooks.onNotify = () => { + if (h.pages.getPage('slow')?.placement.pageHostGeneration) { + close() + } + } + } + try { + const mount = phase === 'create' ? h.holdMount('slow') : undefined + const navigating = phase === 'navigate' ? h.hold('slow') : undefined + const attached = h.attach() + if (mount) { + await mount.started + close() + mount.release() + } + if (navigating) { + await navigating + close() + } + await closed.promise + await h.published('fast') + await vi.waitFor(() => + expect(h.executor.snapshotPageInventory().map((page) => page.browserPageId)).toEqual([ + 'fast' + ]) + ) + expect(h.pages.getPage('slow')).toBeUndefined() + expect(h.authority.getPlacement('slow')).toBeUndefined() + expect(h.authority.getClientPageExecutionHostKey('slow')).toBeUndefined() + expect(h.authority.getClientPageExecutionHostKey('fast')).toBe('execution-host-a') + expect( + h.profile.session.clientHostedBrowserPagesByWorktree?.['repo-1::/tmp/worktree-a']?.map( + (page) => page.browserPageId + ) + ).toEqual(['fast']) + expect(h.results.findLast((result) => result.id === 'slow')).toMatchObject({ + type: 'closePage', + status: 'completed' + }) + attached.abort() + await attached.completion + } finally { + await h.close() + } + } +) + +it.each([false, true])( + 'a transport exception after delivery=%s retains exact uncertainty until fresh inventory', + async (received) => { + const h = createRecoveryLoop(['slow']) + const attempted = Promise.withResolvers() + h.hooks.delivery = (event, dispatch) => { + if (event.command.type === 'createPage') { + if (received) { + dispatch() + } + attempted.resolve() + throw new Error('transport-lost') + } + dispatch() + } + try { + const first = h.attach() + await attempted.promise + await first.completion + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + expect(h.authority.getClientPageExecutionHostKey('slow')).toBeUndefined() + h.hooks.delivery = undefined + const next = h.attach() + await h.published('slow') + expect(h.executor.snapshotPageInventory()).toHaveLength(1) + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + expect(h.authority.getClientPageExecutionHostKey('slow')).toBe('execution-host-a') + next.abort() + await next.completion + } finally { + await h.close() + } + } +) + +it('a real ledger rejection before admission creates no native cleanup obligation', async () => { + const h = createRecoveryLoop(['slow']) + const rejected = Promise.withResolvers() + const issue = BrowserHostCommandLedger.prototype.issue + let admissions = 0 + let rejectFirst = true + const observed = vi + .spyOn(BrowserHostCommandLedger.prototype, 'issue') + .mockImplementation(function (this: BrowserHostCommandLedger, input, onAdmitted) { + if (rejectFirst && input.command.type === 'createPage') { + rejectFirst = false + this.detachDelivery() + try { + return issue.call(this, input, () => { + admissions += 1 + onAdmitted?.() + }) + } catch (error) { + rejected.resolve() + throw error + } + } + return issue.call(this, input, onAdmitted) + }) + try { + const first = h.attach() + await rejected.promise + expect(admissions).toBe(0) + expect(h.commands).toEqual([]) + expect(h.executor.snapshotPageInventory()).toEqual([]) + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + first.abort() + await first.completion + const next = h.attach() + await h.published('slow') + expect(h.commands.filter((event) => event.command.type === 'closePage')).toEqual([]) + expect(h.authority.getClientPageExecutionHostKey('slow')).toBe('execution-host-a') + expect(h.executor.snapshotPageInventory()).toHaveLength(1) + next.abort() + await next.completion + } finally { + observed.mockRestore() + await h.close() + } +}) diff --git a/src/main/runtime/browser/client-pages/client-page-recovery-cleanup.test.ts b/src/main/runtime/browser/client-pages/client-page-recovery-cleanup.test.ts new file mode 100644 index 00000000000..404a38538cb --- /dev/null +++ b/src/main/runtime/browser/client-pages/client-page-recovery-cleanup.test.ts @@ -0,0 +1,239 @@ +import { + openRecoveryTabsStream, + publishRecoveryTabsGraph +} from './client-page-recovery-stream-test-harness' +import { expect, it, vi } from 'vitest' +import { createRecoveryLoop } from './client-page-recovery-loop-test-harness' +import { retireRuntimeBrowserClientPage } from './browser-client-page-close' + +it('a close deadline leaves native capacity charged until the real retirement joins', async () => { + const h = createRecoveryLoop(['fast', 'a', 'b', 'c', 'd', 'queued']) + try { + const started = ['a', 'b', 'c', 'd'].map((id) => h.hold(id)) + const attached = h.attach() + await Promise.all(started) + await h.published('fast') + const fast = h.pages.getPage('fast')! + const action = h.authority.issueClientPageCommand( + { + authorityRuntimeId: h.authority.authorityRuntimeId, + authorityEpoch: h.authority.authorityEpoch, + browserPageId: 'fast', + ...fast.placement + }, + { type: 'navigate', url: 'https://example.internal/user-action' } + ) + expect((await action.result).status).toBe('completed') + const retirement = h.holdRetirement('a') + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + await retireRuntimeBrowserClientPage( + { + getBrowserHostLeaseRegistry: () => h.authority, + notifyHeadlessBrowserSessionTabsChanged: (id) => + h.hostRuntime.notifyMobileSessionTabsChanged(id), + getRuntimeBrowserPageRegistry: () => h.pages, + resolveBrowserWorkspace: async () => ({ id: 'workspace' }) + }, + h.pages.getPage('a')! + ) + await retirement.started + await vi.advanceTimersByTimeAsync(45_001) + expect(h.pages.getPage('a')).toBeUndefined() + expect(h.commands.some((event) => event.browserPageId === 'queued')).toBe(false) + expect( + h.hostRuntime + .snapshotForClient('repo-1::/tmp/worktree-a', 'device-a') + .tabs.find((tab) => tab.id === 'queued') + ).toMatchObject({ type: 'browser', url: 'https://example.internal/queued' }) + expect(h.executor.snapshotPageInventory().some((page) => page.browserPageId === 'a')).toBe(true) + retirement.release() + await h.published('queued') + expect(h.executor.snapshotPageInventory().some((page) => page.browserPageId === 'a')).toBe( + false + ) + expect(h.authority.getClientPageExecutionHostKey('a')).toBeUndefined() + expect(h.authority.getClientPageExecutionHostKey('queued')).toBe('execution-host-a') + for (const id of ['b', 'c', 'd']) { + h.release(id) + } + attached.abort() + await attached.completion + } finally { + vi.useRealTimers() + await h.close() + } +}) + +it.each([false, true])( + 'same-ledger inventory keeps an outstanding create with fast sibling=%s', + async (withFast) => { + const h = createRecoveryLoop(['slow']) + publishRecoveryTabsGraph(h.hostRuntime) + const firstDelivery = Promise.withResolvers() + const stream = openRecoveryTabsStream(h.hostRuntime, `outstanding-${withFast}`) + let held = false + h.hooks.delivery = (event, dispatch) => { + if (event.command.type === 'createPage' && !held) { + held = true + firstDelivery.resolve() + return + } + dispatch() + } + try { + await stream.initial + expect( + stream.frames.findLast((frame) => frame.worktree === 'repo-1::/tmp/worktree-a') + ?.clientHostedPagesUnreconciled + ).toBe(true) + await h.seedNative(['slow']) + const navigation = h.hold('slow') + const mount = h.holdMount('slow') + const first = h.attach() + await firstDelivery.promise + expect(h.executor.snapshotPageInventory()).toEqual([]) + const retirement = h.holdRetirement('slow') + if (withFast) { + await h.seedNative(['fast']) + } + const next = h.attach({ inventory: h.executor.snapshotPageInventory() }) + await mount.started + if (withFast) { + await h.published('fast') + } + expect( + h.hostRuntime.snapshotForClient('repo-1::/tmp/worktree-a', 'device-a') + .clientHostedPagesUnreconciled + ).toBe(true) + first.abort() + await first.completion + mount.release() + await retirement.started + expect( + new Set( + h.commands + .filter( + (event) => event.browserPageId === 'slow' && event.command.type === 'createPage' + ) + .map((event) => event.commandId) + ).size + ).toBe(1) + expect(h.pages.getPage('slow')?.placement.pageHostGeneration).toBe(0) + retirement.release() + await navigation + h.release('slow') + await h.published('slow') + await vi.waitFor(() => + expect( + h.hostRuntime.snapshotForClient('repo-1::/tmp/worktree-a', 'device-a') + .clientHostedPagesUnreconciled + ).toBeUndefined() + ) + await vi.waitFor(() => + expect( + stream.frames.findLast((frame) => frame.worktree === 'repo-1::/tmp/worktree-a') + ?.clientHostedPagesUnreconciled + ).toBeUndefined() + ) + expect(stream.errors).toEqual([]) + stream.stop() + await stream.completion + expect(h.executor.snapshotPageInventory()).toHaveLength(withFast ? 2 : 1) + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + expect( + h.results + .filter((result) => result.id === 'slow') + .map((result) => [result.type, result.status]) + ).toContainEqual(['closePage', 'completed']) + expect(h.errors).toEqual([]) + next.abort() + await next.completion + } finally { + stream.stop() + await h.close() + } + } +) + +it('an omitted inventory cannot certify a retired native target absent', async () => { + const h = createRecoveryLoop(['slow']) + try { + const started = h.hold('slow') + const first = h.attach() + await started + h.fence() + first.abort() + await first.completion + await h.restartClient() + h.commands.length = 0 + const unavailable = h.attach({ omitInventory: true }) + await Promise.resolve() + expect(h.commands).toEqual([]) + expect(h.pages.getPage('slow')?.placement.pageHostGeneration).toBe(0) + const next = h.attach({ inventory: [] }) + h.release('slow') + await h.published('slow') + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + unavailable.abort() + next.abort() + await Promise.all([unavailable.completion, next.completion]) + } finally { + await h.close() + } +}) + +it.each(['native-retirement', 'initialization-failure'] as const)( + 'complete absence after %s retires settled-create nonallocating work before replay', + async (cause) => { + const h = createRecoveryLoop(['slow']) + const pending = Promise.withResolvers() + const resultDelivery = Promise.withResolvers() + let heldCommand: string | undefined + if (cause === 'initialization-failure') { + h.hooks.failCommit = true + h.hooks.beforeResult = async (event) => { + if (event.command.type === 'navigate' && !heldCommand) { + heldCommand = event.commandId + pending.resolve() + await resultDelivery.promise + } + } + } else { + h.hooks.delivery = (event, dispatch) => { + if (event.command.type === 'navigate' && !heldCommand) { + heldCommand = event.commandId + pending.resolve() + return + } + dispatch() + } + } + try { + const first = h.attach() + await pending.promise + expect(h.results).toContainEqual({ id: 'slow', type: 'createPage', status: 'completed' }) + const inventory = h.executor.snapshotPageInventory() + if (cause === 'native-retirement') { + expect(inventory).toHaveLength(1) + await h.executor.retirePage('slow', inventory[0]!.pageHostGeneration) + } else { + expect(inventory).toEqual([]) + h.hooks.failCommit = false + } + const next = h.attach({ inventory: [] }) + await h.published('slow') + expect(h.commands.filter((event) => event.commandId === heldCommand)).toHaveLength(1) + expect(h.commands.filter((event) => event.command.type === 'createPage')).toHaveLength(2) + expect(h.executor.snapshotPageInventory()).toHaveLength(1) + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + expect(h.errors).toEqual([]) + resultDelivery.resolve() + first.abort() + next.abort() + await Promise.all([first.completion, next.completion]) + } finally { + resultDelivery.resolve() + await h.close() + } + } +) diff --git a/src/main/runtime/browser/client-pages/client-page-recovery-completion.test.ts b/src/main/runtime/browser/client-pages/client-page-recovery-completion.test.ts new file mode 100644 index 00000000000..07edbdac48a --- /dev/null +++ b/src/main/runtime/browser/client-pages/client-page-recovery-completion.test.ts @@ -0,0 +1,182 @@ +import { RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT } from './client-hosted-browser-page-persistence' +import { expect, it, vi } from 'vitest' +import { createRecoveryLoop } from './client-page-recovery-loop-test-harness' +import { RpcDispatcher } from '../../rpc/dispatcher' +import { + openRecoveryTabsStream, + publishRecoveryTabsGraph +} from './client-page-recovery-stream-test-harness' +import { createRecoveryPeer } from './client-page-recovery-peer-test-harness' +import { SESSION_TAB_METHODS } from '../../rpc/methods/session-tabs/session-tabs' + +const WORKSPACE = 'repo-1::/tmp/worktree-a' + +it.each([false, true])( + 'clears legacy held snapshots before its pending navigation, with modern peer=%s', + async (withModern) => { + const h = createRecoveryLoop(withModern ? [] : ['slow']) + publishRecoveryTabsGraph(h.hostRuntime) + const stream = openRecoveryTabsStream(h.hostRuntime, 'legacy-consumer') + const peer = createRecoveryPeer(h.hostRuntime, 'modern-peer') + try { + await stream.initial + expect( + stream.frames.findLast((frame) => frame.worktree === WORKSPACE) + ?.clientHostedPagesUnreconciled + ).toBe(true) + if (withModern) { + h.pages.publishClientPage({ + browserPageId: 'peer', + workspaceId: WORKSPACE, + browserProfileId: 'profile-a', + executionHostKey: 'old-key', + placement: { + kind: 'client', + browserHostClientId: 'modern-peer', + browserHostGeneration: 1, + pageHostGeneration: 1 + }, + pairedDeviceId: 'device-a', + url: 'https://example.internal/peer', + loading: false, + active: false + }) + const started = peer.hold('peer') + peer.attach() + await started + h.pages.publishClientPage({ + browserPageId: 'slow', + workspaceId: WORKSPACE, + browserProfileId: 'profile-a', + executionHostKey: 'old-key', + placement: RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT, + pairedDeviceId: 'device-a', + url: 'https://example.internal/slow', + loading: false, + active: false + }) + } + expect( + h.hostRuntime.snapshotForClient(WORKSPACE, 'device-a').clientHostedPagesUnreconciled + ).toBe(true) + const started = h.hold('slow') + const attached = h.attach({ modern: false }) + await started + if (withModern) { + expect( + stream.frames.findLast((frame) => frame.worktree === WORKSPACE) + ?.clientHostedPagesUnreconciled + ).toBe(true) + peer.release('peer') + await vi.waitFor(() => expect(h.authority.getPlacement('peer')?.kind).toBe('client')) + } + await vi.waitFor(() => + expect( + h.hostRuntime.snapshotForClient(WORKSPACE, 'device-a').clientHostedPagesUnreconciled + ).toBeUndefined() + ) + await vi.waitFor(() => + expect( + stream.frames.findLast((frame) => frame.worktree === WORKSPACE) + ?.clientHostedPagesUnreconciled + ).toBeUndefined() + ) + expect( + h.commands.some( + (event) => event.browserPageId === 'slow' && event.command.type === 'navigate' + ) + ).toBe(true) + h.release('slow') + await vi.waitFor(() => + expect(h.results).toContainEqual({ id: 'slow', type: 'navigate', status: 'completed' }) + ) + attached.abort() + await attached.completion + } finally { + stream.stop() + peer.release('peer') + await h.close() + await peer.close() + } + } +) + +it.each(['session.tabs.subscribe', 'session.tabs.subscribeAll'])( + 'forgets %s initial hold after the same stream receives an unheld frame', + async (method) => { + vi.useFakeTimers({ toFake: ['Date'] }) + const h = createRecoveryLoop([]) + const runtime = h.hostRuntime + const stopped = new AbortController() + const replies: string[] = [] + try { + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: WORKSPACE, + publicationEpoch: 'renderer', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + const rpc = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const stream = rpc.dispatchStreaming( + { + id: 'held-stream', + authToken: 'test', + method, + params: method === 'session.tabs.subscribe' ? { worktree: `id:${WORKSPACE}` } : {} + }, + (reply) => replies.push(reply), + { + connectionId: 'held-connection', + pairedDeviceId: 'device-a', + clientKind: 'mobile', + signal: stopped.signal + } + ) + await vi.waitFor(() => + expect( + replies.some((reply) => + ['snapshots', 'snapshot'].includes(JSON.parse(reply).result?.type) + ) + ).toBe(true) + ) + expect( + replies.some((reply) => + (JSON.parse(reply).result?.snapshots ?? [JSON.parse(reply).result]).some( + (frame: { worktree: string; clientHostedPagesUnreconciled?: true }) => + frame.worktree === WORKSPACE && frame.clientHostedPagesUnreconciled + ) + ) + ).toBe(true) + vi.setSystemTime(Date.now() + 46_000) + runtime.notifyMobileSessionTabsChanged(WORKSPACE) + expect( + replies.some( + (reply) => + JSON.parse(reply).result?.type === 'updated' && + JSON.parse(reply).result?.clientHostedPagesUnreconciled === undefined + ) + ).toBe(true) + runtime.projectedFrames = 0 + const delivered = replies.length + runtime.markClientHostedPagesReconciled('device-a') + expect(runtime.projectedFrames).toBe(0) + expect(replies).toHaveLength(delivered) + stopped.abort() + await stream + } finally { + stopped.abort() + await h.close() + vi.useRealTimers() + } + } +) diff --git a/src/main/runtime/browser/client-pages/client-page-recovery-contracts.test.ts b/src/main/runtime/browser/client-pages/client-page-recovery-contracts.test.ts new file mode 100644 index 00000000000..34c38bd7807 --- /dev/null +++ b/src/main/runtime/browser/client-pages/client-page-recovery-contracts.test.ts @@ -0,0 +1,302 @@ +import { expect, it, vi } from 'vitest' +import { createRecoveryLoop } from './client-page-recovery-loop-test-harness' +import { RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT } from './client-hosted-browser-page-persistence' +import { BROWSER_CLIENT_HOST_PAGE_INVENTORY_URL_MAX_LENGTH } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-session-contracts' + +const WORKSPACE = 'repo-1::/tmp/worktree-a' + +it.each([false, true])( + 'publishes cleared current holds to stale workspaces after cleanup=%s', + async (failCommit) => { + const h = createRecoveryLoop([]) + const frames: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = h.hostRuntime.onMobileSessionTabsChanged( + (frame) => frames.push(frame), + 'device-a', + 'mobile' + ) + const staleWorkspace = 'repo-1::/tmp/stale' + try { + const stale = h.pages.publishClientPage({ + browserPageId: 'stale', + workspaceId: staleWorkspace, + browserProfileId: 'profile-a', + pairedDeviceId: 'device-a', + executionHostKey: 'old-key', + placement: RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT, + url: 'https://example.internal/stale', + loading: false, + active: false + }) + h.hostRuntime.notifyMobileSessionTabsChanged(staleWorkspace) + h.pages.retirePage('stale', stale.placement) + await h.seedNative(['slow']) + const started = h.hold('slow') + h.hooks.failCommit = failCommit + const first = h.attach() + if (!failCommit) { + await started + expect( + frames.findLast((frame) => frame.worktree === WORKSPACE)?.clientHostedPagesUnreconciled + ).toBe(true) + const retirement = h.holdRetirement('slow') + const next = h.attach() + await retirement.started + first.abort() + await first.completion + expect( + frames.findLast((frame) => frame.worktree === WORKSPACE)?.clientHostedPagesUnreconciled + ).toBe(true) + retirement.release() + h.release('slow') + await h.published('slow') + await vi.waitFor(() => + expect( + h.snapshots.some((snapshot) => snapshot.reconciled), + JSON.stringify({ snapshots: h.snapshots, errors: h.errors, results: h.results }) + ).toBe(true) + ) + await vi.waitFor(() => + expect( + frames.findLast((frame) => frame.worktree === staleWorkspace) + ?.clientHostedPagesUnreconciled + ).toBeUndefined() + ) + next.abort() + await next.completion + } else { + await vi.waitFor(() => + expect(h.snapshots.some((snapshot) => snapshot.reconciled)).toBe(true) + ) + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + expect(h.pages.getPage('slow')?.placement.pageHostGeneration).toBe(0) + expect( + frames.findLast((frame) => frame.worktree === WORKSPACE)?.clientHostedPagesUnreconciled + ).toBeUndefined() + expect( + frames.findLast((frame) => frame.worktree === staleWorkspace) + ?.clientHostedPagesUnreconciled + ).toBeUndefined() + first.abort() + await first.completion + } + } finally { + unsubscribe() + await h.close() + } + } +) + +it('keeps a modern active exact guest when actual inventory trimming omits its address', async () => { + const h = createRecoveryLoop(['page']) + try { + const started = h.hold('page') + const first = h.attach() + await started + h.release( + 'page', + `https://example.internal/${'x'.repeat(BROWSER_CLIENT_HOST_PAGE_INVENTORY_URL_MAX_LENGTH)}` + ) + await h.published('page') + const page = h.pages.getPage('page')! + expect(h.executor.snapshotPageInventory()[0]).toMatchObject({ state: 'active' }) + expect(h.executor.snapshotPageInventory()[0]?.currentUrl).toBeUndefined() + h.commands.length = 0 + const next = h.attach() + await first.completion + expect(h.commands).toEqual([]) + expect(h.executor.hasPage('page', page.placement.pageHostGeneration)).toBe(true) + next.abort() + await next.completion + } finally { + await h.close() + } +}) + +it('retires a late legacy replacement after its captured logical row closes', async () => { + const h = createRecoveryLoop(['slow']) + try { + await h.seedNative(['slow']) + const started = h.hold('slow') + const attached = h.attach({ modern: false }) + await started + const page = h.pages.getPage('slow')! + h.pages.retirePage('slow', page.placement) + h.release('slow') + await vi.waitFor(() => expect(h.executor.snapshotPageInventory()).toEqual([])) + expect(h.authority.getPlacement('slow')).toBeUndefined() + expect(h.authority.getClientPageExecutionHostKey('slow')).toBeUndefined() + expect(h.commands.map((event) => event.command.type)).toContain('restorePage') + expect( + h.commands.some( + (event) => event.command.type === 'createPage' && event.command.initialUrl !== undefined + ) + ).toBe(false) + attached.abort() + await attached.completion + } finally { + await h.close() + } +}) + +it.each([false, true])( + 'clears a known saved predecessor hold after cleanup=%s', + async (failCommit) => { + const h = createRecoveryLoop(['slow']) + const frames: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = h.hostRuntime.onMobileSessionTabsChanged( + (frame) => frames.push(frame), + 'device-a', + 'mobile' + ) + try { + await h.seedNative(['slow']) + expect( + h.hostRuntime.snapshotForClient(WORKSPACE, 'device-a').clientHostedPagesUnreconciled + ).toBe(true) + const started = h.hold('slow') + h.hooks.failCommit = failCommit + const attached = h.attach() + if (!failCommit) { + await started + h.release('slow') + await h.published('slow') + } + await vi.waitFor(() => + expect( + frames.some( + (frame) => + frame.worktree === WORKSPACE && frame.clientHostedPagesUnreconciled === undefined + ) + ).toBe(true) + ) + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + if (failCommit) { + expect(h.pages.getPage('slow')?.placement.pageHostGeneration).toBe(0) + expect(h.executor.snapshotPageInventory()).toEqual([]) + } + attached.abort() + await attached.completion + } finally { + unsubscribe() + await h.close() + } + } +) + +it('legacy late cleanup cannot retire a newer native and logical page with the same id', async () => { + const h = createRecoveryLoop(['slow']) + const ready = Promise.withResolvers() + const deliver = Promise.withResolvers() + h.hooks.beforeResult = async (event) => { + if (event.command.type === 'restorePage') { + ready.resolve() + await deliver.promise + } + } + try { + await h.seedNative(['slow']) + const attached = h.attach({ modern: false }) + await ready.promise + const old = h.executor.snapshotPageInventory()[0]! + expect(await h.executor.retirePage('slow', old.pageHostGeneration)).toBe(true) + const replacement = { + type: 'command' as const, + authorityRuntimeId: 'runtime-newer', + authorityEpoch: 'epoch-newer', + browserHostClientId: 'newer-client', + browserHostGeneration: 9, + browserPageId: 'slow', + pageHostGeneration: 999, + pageCommandProtocolVersion: 1 as const, + commandId: 'newer-create', + commandSequence: 1, + command: { + type: 'createPage' as const, + browserProfileId: 'profile-a', + executionHostKey: 'execution-host-a', + workspaceId: WORKSPACE + } + } + expect(await h.executor.handle(replacement, new AbortController().signal)).toEqual({ + status: 'completed' + }) + const prior = h.pages.getPage('slow')! + h.pages.retirePage('slow', prior.placement) + const newer = h.pages.publishClientPage({ + ...prior, + placement: { + kind: 'client', + browserHostClientId: 'newer-client', + browserHostGeneration: 9, + pageHostGeneration: 999 + }, + url: 'https://example.internal/newer' + }) + deliver.resolve() + await vi.waitFor(() => + expect( + h.results.some((result) => result.type === 'closePage' && result.status === 'failed') + ).toBe(true) + ) + expect(h.pages.getPage('slow')).toBe(newer) + expect(h.executor.snapshotPageInventory()).toMatchObject([ + { authorityRuntimeId: 'runtime-newer', pageHostGeneration: 999 } + ]) + attached.abort() + await attached.completion + } finally { + deliver.resolve() + await h.close() + } +}) + +it.each(['cleanup-failure', 'full-fence'] as const)( + 'legacy late completion preserves the newer saved row after %s', + async (phase) => { + const h = createRecoveryLoop(['slow']) + try { + await h.seedNative(['slow']) + const started = h.hold('slow') + const attached = h.attach({ modern: false }) + await started + h.results.length = 0 + const prior = h.pages.getPage('slow')! + h.pages.retirePage('slow', prior.placement) + const newer = h.pages.publishClientPage({ + ...prior, + placement: { + kind: 'client', + browserHostClientId: 'newer-client', + browserHostGeneration: 9, + pageHostGeneration: 999 + }, + url: 'https://example.internal/newer' + }) + if (phase === 'cleanup-failure') { + h.native.renderer.retirePage.mockRejectedValueOnce(new Error('retirement-failed')) + } else { + h.fence() + } + h.release('slow') + await vi.waitFor(() => + expect( + h.results.some( + (result) => result.type === (phase === 'cleanup-failure' ? 'closePage' : 'restorePage') + ) + ).toBe(true) + ) + expect(h.pages.getPage('slow')).toBe(newer) + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/newer') + if (phase === 'cleanup-failure') { + expect(h.results.findLast((result) => result.type === 'closePage')?.status).toBe('failed') + expect(h.executor.snapshotPageInventory()).toHaveLength(1) + } + attached.abort() + await attached.completion + } finally { + await h.close() + } + } +) diff --git a/src/main/runtime/browser/client-pages/client-page-recovery-loop-test-harness.ts b/src/main/runtime/browser/client-pages/client-page-recovery-loop-test-harness.ts new file mode 100644 index 00000000000..8727471448b --- /dev/null +++ b/src/main/runtime/browser/client-pages/client-page-recovery-loop-test-harness.ts @@ -0,0 +1,301 @@ +import { + RecoveryWorkspaceRuntime, + type RecoveryRuntimeObservation +} from './client-page-recovery-workspace-test-harness' +import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY } from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-session-contracts' +import { createProfile } from './client-hosted-browser-persistence-test-harness' +import { RpcDispatcher } from '../../rpc/dispatcher' +import { BROWSER_CLIENT_HOST_METHODS } from '../../rpc/methods/browser/browser-client-host' +import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY } from '../../../../shared/browser/runtime/browser-runtime-capabilities' +import { + BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY, + type BrowserClientHostCommandEvent, + type BrowserClientHostedPageInventory +} from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import { BrowserClientHostCommandDispatcher } from '../../../browser/client-hosts/commands/browser-client-host-command-dispatcher' +import type { BrowserClientPageCommandExecutor } from '../../../browser/client-pages/execution/browser-client-page-command-executor' +import { + createRecoveryNativeLoop, + seedRecoveryNativePages +} from './client-page-recovery-native-test-harness' +import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' +import { getBrowserHostLeaseRegistry } from '../../browser-host/leases/browser-host-lease-registry-instance' +import { RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT } from './client-hosted-browser-page-persistence' + +export function createRecoveryLoop( + ids: readonly string[], + loopOptions: { + worktreeCount?: number + maxCachedCommandResults?: number + maxConcurrentHandlers?: number + nativeLoop?: ReturnType + executor?: BrowserClientPageCommandExecutor + } = {} +) { + const cleanups = new Map void>() + const publications = new Map>>() + const hooks: { + onResult?: (event: BrowserClientHostCommandEvent) => void + beforeResult?: (event: BrowserClientHostCommandEvent) => Promise + onNotify?: () => void + beforeWorkspace?: () => Promise + failCommit?: boolean + delivery?: (event: BrowserClientHostCommandEvent, dispatch: () => void) => void + } = {} + const nativeLoop = loopOptions.nativeLoop ?? createRecoveryNativeLoop(hooks) + const { native } = nativeLoop + const commands: BrowserClientHostCommandEvent[] = [] + const results: { type: string; id: string; status: string }[] = [] + const snapshots: { reconciled: boolean; ids: string[] }[] = [] + const errors: unknown[] = [] + const streams: Promise[] = [] + let reconciled = false + let connectionId = '' + let browserHostClientId = 'client-a' + let executor: BrowserClientPageCommandExecutor + let client: BrowserClientHostCommandDispatcher | undefined + let authorityGeneration = 0 + const profile = createProfile(loopOptions.worktreeCount) + class RecoveryRuntime extends RecoveryWorkspaceRuntime { + projectedFrames = 0 + sessionTabsSequence() { + return this.mobileSessionTabsChangeSequence + } + protected override projectMobileSessionTabsForClient( + result: RuntimeMobileSessionTabsResult, + clientId?: string + ) { + this.projectedFrames += 1 + return super.projectMobileSessionTabsForClient(result, clientId) + } + peekSnapshotForClient(worktree: string, clientId: string) { + return this.getMobileSessionTabsForWorktree(worktree, clientId, { + includeAgentSenders: false + }) + } + snapshotForClient(worktree: string, clientId: string) { + const result = this.getMobileSessionTabsForWorktree(worktree, clientId, { + includeAgentSenders: false + }) + this.observeClientHostedPageSnapshots([result], clientId) + return result + } + override markClientHostedPagesReconciled(id: string): void { + super.markClientHostedPagesReconciled(id) + reconciled = true + notify() + } + override notifyMobileSessionTabsChanged(id?: string): void { + super.notifyMobileSessionTabsChanged(id) + notify() + } + } + const hostRuntime: RecoveryRuntimeObservation = Object.assign( + new RecoveryRuntime(profile.store, undefined, {}), + { beforeRecoveryWorkspace: () => hooks.beforeWorkspace?.() } + ) + const registerSubscription = hostRuntime.registerSubscriptionCleanup.bind(hostRuntime) + hostRuntime.registerSubscriptionCleanup = (id, cleanup, connection) => { + registerSubscription(id, cleanup, connection) + cleanups.set(connection ?? '', () => hostRuntime.cleanupSubscription(id)) + } + const pages = getRuntimeBrowserPageRegistry(hostRuntime) + const authority = getBrowserHostLeaseRegistry(hostRuntime) + const rpc = new RpcDispatcher({ runtime: hostRuntime, methods: BROWSER_CLIENT_HOST_METHODS }) + function notify() { + hooks.onNotify?.() + snapshots.push({ reconciled, ids: pages.listPages().map((page) => page.browserPageId) }) + for (const [id, done] of publications) { + if (pages.getPage(id)?.placement.pageHostGeneration) { + done.resolve() + } + } + } + executor = loopOptions.executor ?? nativeLoop.createExecutor() + for (const id of ids) { + pages.publishClientPage({ + browserPageId: id, + workspaceId: 'repo-1::/tmp/worktree-a', + browserProfileId: 'profile-a', + executionHostKey: 'old-key', + placement: RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT, + pairedDeviceId: 'device-a', + url: `https://example.internal/${id}`, + loading: false, + active: false + }) + } + const caller = () => ({ + connectionId, + clientKind: 'runtime' as const, + pairedDeviceId: 'device-a', + clientCapabilities: [BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY] + }) + function attach( + options: { + inventory?: readonly BrowserClientHostedPageInventory[] + hostId?: string + modern?: boolean + omitInventory?: boolean + } = {} + ) { + connectionId = `connection-${++authorityGeneration}` + browserHostClientId = options.hostId ?? browserHostClientId + const attachedConnection = connectionId + const stopped = new AbortController() + const completion = rpc.dispatchStreaming( + { + id: attachedConnection, + authToken: 'test', + method: 'browser.clientHost.attach', + params: { + authorityRuntimeId: 'runtime-a', + browserHostClientId, + hostCapabilities: [ + 'webview', + BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY, + ...(options.modern === false ? [] : [BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY]) + ], + pageCommandProtocolVersion: 1, + ...(!options.omitInventory + ? { + pageReconciliationProtocolVersion: 1, + pageInventoryProtocolVersion: 1, + pageInventory: options.inventory ?? executor.snapshotPageInventory(), + leaseReconnectProtocolVersion: 1 + } + : {}) + } + }, + (reply) => { + const message = JSON.parse(reply) + if (message.ok === false) { + errors.push(message.error) + return + } + const event = message.result + if (event?.type === 'ready') { + if (!client || clientGeneration !== event.browserHostGeneration) { + clientGeneration = event.browserHostGeneration + client = new BrowserClientHostCommandDispatcher({ + maxCachedCommandResults: loopOptions.maxCachedCommandResults, + maxConcurrentHandlers: loopOptions.maxConcurrentHandlers, + authority: { ...event, authorityRuntimeId: 'runtime-a', browserHostClientId }, + handler: (command, signal, close) => executor.handle(command, signal, close), + acquireInitialization: (command) => executor.acquireInitialization(command) + }) + } + return + } + if (event?.type !== 'command') { + return + } + commands.push(event) + const dispatch = () => { + void client! + .dispatch(event) + .then(async (result) => { + if (hooks.beforeResult) { + await hooks.beforeResult(event) + } + hooks.onResult?.(event) + results.push({ + type: event.command.type, + id: event.browserPageId, + status: result.status + }) + await rpc.dispatchStreaming( + { + id: `result-${event.commandId}`, + authToken: 'test', + method: 'browser.clientHost.commandResult', + params: { ...event, result } + }, + () => {}, + caller() + ) + }) + .catch((error) => errors.push(error)) + } + if (hooks.delivery) { + hooks.delivery(event, dispatch) + } else { + dispatch() + } + }, + { ...caller(), signal: stopped.signal } + ) + streams.push(completion) + return { + completion, + abort: () => stopped.abort(), + disconnect: () => cleanups.get(attachedConnection)?.() + } + } + let clientGeneration = 0 + return { + pages, + hooks, + profile, + authority, + commands, + results, + snapshots, + errors, + native, + nativeLoop, + hostRuntime, + rpc, + caller, + attach, + hold: nativeLoop.hold, + release: nativeLoop.release, + holdMount: nativeLoop.holdMount, + holdRetirement: nativeLoop.holdRetirement, + seedNative: (ids: readonly string[], profileId?: string, controlled?: boolean) => + seedRecoveryNativePages(executor, ids, browserHostClientId, profileId, controlled), + get executor() { + return executor + }, + published(id: string) { + if (pages.getPage(id)?.placement.pageHostGeneration) { + return Promise.resolve() + } + const done = publications.get(id) ?? Promise.withResolvers() + publications.set(id, done) + return done.promise + }, + fence() { + const current = authority.select(browserHostClientId) + const replacement = authority.attach({ + browserHostClientId, + pairedDeviceId: 'device-a', + connectionId: 'full-fence', + hostCapabilities: [...current.hostCapabilities, 'fence-test'], + pageCommandProtocolVersion: 1, + pageInventoryProtocolVersion: 1, + pageInventory: [], + pageReconciliationProtocolVersion: 1, + leaseReconnectProtocolVersion: 1 + }) + replacement.release() + }, + closeClientDispatcher: () => client?.close(), + async restartClient() { + await client?.close() + await executor.close() + client = undefined + executor = nativeLoop.createExecutor() + }, + async close() { + for (const cleanup of cleanups.values()) { + cleanup() + } + nativeLoop.releaseAll() + await client?.close() + await executor.close() + await Promise.all(streams) + } + } +} diff --git a/src/main/runtime/browser/client-pages/client-page-recovery-native-test-harness.ts b/src/main/runtime/browser/client-pages/client-page-recovery-native-test-harness.ts new file mode 100644 index 00000000000..7e5eea8a2c3 --- /dev/null +++ b/src/main/runtime/browser/client-pages/client-page-recovery-native-test-harness.ts @@ -0,0 +1,187 @@ +import { BrowserRoutePageAvailability } from '../../../browser/routing/guest-authority/browser-route-page-availability' +import { BrowserClientPageCommandExecutor } from '../../../browser/client-pages/execution/browser-client-page-command-executor' +import { createHarness } from '../../../browser/client-pages/execution/browser-client-page-command-executor-test-harness' +import { BrowserClientHostCommandDispatcher } from '../../../browser/client-hosts/commands/browser-client-host-command-dispatcher' +import type { BrowserClientHostCommandEvent } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' + +export function createRecoveryNativeLoop(hooks: { failCommit?: boolean }) { + const native = createHarness() + const initializedUrls: string[] = [] + const availability = new BrowserRoutePageAvailability() + const commits = new Map>>() + const navigations = new Map>>() + const retirements = new Map>>() + const mounts = new Map>>() + const mounting = new Map>>() + const retiring = new Map>>() + function createExecutor() { + return new BrowserClientPageCommandExecutor({ + ...native.dependencies, + routeWebContents: { + ...native.dependencies.routeWebContents, + watchPageAvailability: availability.watch, + navigateGuest: async (_claim, url) => { + const id = new URL(url).pathname.slice(1) + navigations.get(id)?.resolve() + await commits.get(id)?.promise + return true + }, + initializeGuest: (_claim, url, signal) => { + initializedUrls.push(url) + if (hooks.failCommit) { + return Promise.reject(new Error('precommit-failure')) + } + const id = new URL(url).pathname.slice(1) + navigations.get(id)?.resolve() + const commit = commits.get(id) + if (!commit) { + return Promise.resolve(url) + } + return new Promise((resolve, reject) => { + const abort = () => reject(new Error('cancelled')) + signal.addEventListener('abort', abort, { once: true }) + void commit.promise.then( + (value) => { + signal.removeEventListener('abort', abort) + resolve(value) + }, + (error) => { + signal.removeEventListener('abort', abort) + reject(error) + } + ) + if (signal.aborted) { + abort() + } + }) + } + }, + selectRenderer: () => ({ + ...native.renderer, + mountPage: async (input) => { + mounting.get(input.browserPageId)?.resolve() + await mounts.get(input.browserPageId)?.promise + return native.renderer.mountPage() + }, + retirePage: async (input) => { + retiring.get(input.browserPageId)?.resolve() + await retirements.get(input.browserPageId)?.promise + await native.renderer.retirePage() + } + }) + }) + } + return { + native, + initializedUrls, + createExecutor, + lose(id: string) { + const registration = + native.dependencies.routeWebContents.claimGuestLifecycle.mock.calls.findLast( + ([registration]) => registration.browserPageId === id + )?.[0] + if (!registration) { + throw new Error('Missing native page') + } + availability.report(registration) + }, + hold(id: string) { + commits.set(id, Promise.withResolvers()) + const started = Promise.withResolvers() + navigations.set(id, started) + return started.promise + }, + release(id: string, url = `https://example.internal/${id}`) { + commits.get(id)?.resolve(url) + commits.delete(id) + }, + holdRetirement(id: string) { + const done = Promise.withResolvers() + retirements.set(id, done) + const started = Promise.withResolvers() + retiring.set(id, started) + return { started: started.promise, release: () => done.resolve() } + }, + holdMount(id: string) { + const done = Promise.withResolvers() + mounts.set(id, done) + const started = Promise.withResolvers() + mounting.set(id, started) + return { started: started.promise, release: () => done.resolve() } + }, + releaseAll() { + for (const done of mounts.values()) { + done.resolve() + } + for (const done of retirements.values()) { + done.resolve() + } + for (const done of commits.values()) { + done.resolve('about:blank') + } + } + } +} + +export async function seedRecoveryNativePages( + executor: BrowserClientPageCommandExecutor, + ids: readonly string[], + browserHostClientId: string, + browserProfileId = 'profile-a', + controlled = false +) { + const oldAuthority = { + authorityRuntimeId: 'runtime-old', + authorityEpoch: 'epoch-old', + browserHostClientId, + browserHostGeneration: 1, + pageCommandProtocolVersion: 1 as const, + pageReconciliationProtocolVersion: 1 as const + } + const predecessor = new BrowserClientHostCommandDispatcher({ + authority: oldAuthority, + handler: (event, signal) => executor.handle(event, signal) + }) + for (const [index, id] of ids.entries()) { + const event: BrowserClientHostCommandEvent = { + ...oldAuthority, + type: 'command', + browserPageId: id, + pageHostGeneration: index + 1, + commandId: `seed-${id}`, + commandSequence: 1, + command: { + type: 'createPage', + ...(controlled ? { initialUrl: `https://example.internal/${id}` } : {}), + browserProfileId, + executionHostKey: 'execution-host-a', + workspaceId: 'repo-1::/tmp/worktree-a' + } + } + const result = await predecessor.dispatch(event) + if (result.status !== 'completed') { + throw new Error('predecessor-create-failed') + } + if (controlled) { + const committed = await predecessor.dispatch({ + ...event, + commandId: `seed-commit-${id}`, + commandSequence: 2, + command: { type: 'navigate', url: `https://example.internal/${id}` } + }) + if (committed.status !== 'completed') { + throw new Error('predecessor-commit-failed') + } + } + executor.recordPublishedPageUrl({ + ...event, + revision: 1, + url: `https://example.internal/${id}`, + title: 'Saved', + loading: false, + canGoBack: false, + canGoForward: false + }) + } + await predecessor.close() +} diff --git a/src/main/runtime/browser/client-pages/client-page-recovery-partition-preparation.test.ts b/src/main/runtime/browser/client-pages/client-page-recovery-partition-preparation.test.ts new file mode 100644 index 00000000000..e48ea37bb7a --- /dev/null +++ b/src/main/runtime/browser/client-pages/client-page-recovery-partition-preparation.test.ts @@ -0,0 +1,170 @@ +import { expect, it, vi } from 'vitest' +import { retireRuntimeBrowserClientPage } from './browser-client-page-close' +import { createRecoveryLoop } from './client-page-recovery-loop-test-harness' +import { browserNetworkExecutionHostKey } from '../../../../shared/browser/client-hosting/browser-network-execution-host' + +const WORKSPACE = 'repo-1::/tmp/worktree-a' +const IDS = ['slow', 'fast', 'third', 'fourth', 'fifth', 'sixth'] + +it('joins all six exact predecessors before retargeting their partition, without holding fast pages behind slow navigation', async () => { + const h = createRecoveryLoop(IDS) + const key = browserNetworkExecutionHostKey({ + kind: 'native', + runtimeId: 'runtime-a', + revision: 1 + }) + h.native.dependencies.retainNetworkRoute.mockImplementation(async (requested) => ({ + ...h.native.route, + key: requested + })) + h.hostRuntime.resolveBrowserExecutionHostKeyForWorkspace = async () => ({ + status: 'resolved', + executionHostKey: key + }) + try { + await h.seedNative(IDS) + const predecessor = h.executor.snapshotPageInventory() + const cleanup = h.holdRetirement('sixth') + const started = h.hold('slow') + h.native.dependencies.routeSessions.preparePage.mockImplementation(async () => { + // A shared-session proxy may change only after every old page's native join. + expect( + h.executor.snapshotPageInventory().filter((p) => p.authorityRuntimeId === 'runtime-old') + ).toEqual([]) + return h.native.routeSession + }) + h.attach() + await cleanup.started + const lease = h.authority.select('client-a') + const ordinary = h.authority.createClientPage({ + browserPageId: 'ordinary', + browserHostClientId: 'client-a', + pairedDeviceId: 'device-a', + browserProfileId: 'profile-a', + executionHostKey: key, + workspaceId: WORKSPACE + }) + expect( + h.hostRuntime + .snapshotForClient(WORKSPACE, 'device-a') + .tabs.filter((t) => t.type === 'browser') + .map((t) => t.initializationState) + ).toEqual(['pending', 'pending', 'pending', 'pending', 'pending', 'pending']) + // No replacement may be visible while one native predecessor has not joined. + expect(h.commands.some((c) => c.command.type === 'createPage')).toBe(false) + cleanup.release() + await started + await Promise.all(IDS.slice(1).map((id) => h.published(id))) + await ordinary + expect(h.results.filter((r) => r.type === 'closePage' && IDS.includes(r.id))).toEqual( + expect.arrayContaining(IDS.map((id) => ({ type: 'closePage', id, status: 'completed' }))) + ) + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + expect(h.pages.getPage('slow')?.placement.pageHostGeneration).toBe(0) + expect( + h.authority.clientPageInitializations.status('slow', h.pages.getPage('slow')!.placement) + ).toBe('pending') + h.release('slow', 'https://example.internal/redirected') + await h.published('slow') + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/redirected') + expect( + h.authority.clientPageInitializations.status('slow', h.pages.getPage('slow')!.placement) + ).toBeUndefined() + expect( + h.executor + .snapshotPageInventory() + .filter((p) => IDS.includes(p.browserPageId)) + .every((p) => p.state === 'active') + ).toBe(true) + expect( + h.executor + .snapshotPageInventory() + .some((p) => predecessor.some((old) => old.authorityRuntimeId === p.authorityRuntimeId)) + ).toBe(false) + expect(h.errors).toEqual([]) + h.authority.requireLease(lease) + } finally { + await h.close() + } +}) + +it('lets another profile create while an exact predecessor join is held', async () => { + const h = createRecoveryLoop(['slow']) + try { + await h.seedNative(['slow']) + const cleanup = h.holdRetirement('slow') + h.attach() + await cleanup.started + const independent = await h.authority.createClientPage({ + browserPageId: 'independent', + browserHostClientId: 'client-a', + pairedDeviceId: 'device-a', + browserProfileId: 'profile-b', + executionHostKey: 'execution-host-a', + workspaceId: WORKSPACE + }) + expect(independent.pageHostGeneration).toBeGreaterThan(0) + expect( + h.executor + .snapshotPageInventory() + .map((p) => p.browserPageId) + .sort() + ).toEqual(['independent', 'slow']) + expect( + h.commands.some((c) => c.browserPageId === 'slow' && c.command.type === 'createPage') + ).toBe(false) + cleanup.release() + await h.published('slow') + expect(h.errors).toEqual([]) + } finally { + await h.close() + } +}) + +it('keeps a closed queued cohort member in exact cleanup and resumes the surviving cohort on successor attach', async () => { + const h = createRecoveryLoop(IDS) + try { + await h.seedNative(IDS) + const held = IDS.slice(0, 4).map((id) => h.holdRetirement(id)) + const first = h.attach() + await Promise.all(held.map((h) => h.started)) + const closed = h.pages.getPage('sixth')! + await retireRuntimeBrowserClientPage( + { + getBrowserHostLeaseRegistry: () => h.authority, + getRuntimeBrowserPageRegistry: () => h.pages, + resolveBrowserWorkspace: async () => ({ id: WORKSPACE }), + notifyHeadlessBrowserSessionTabsChanged: (id) => + h.hostRuntime.notifyMobileSessionTabsChanged(id) + }, + closed + ) + expect(h.pages.getPage('sixth')).toBeUndefined() + expect(h.executor.snapshotPageInventory().some((p) => p.browserPageId === 'sixth')).toBe(true) + const next = h.attach() + first.abort() + await first.completion + for (const cleanup of held) { + cleanup.release() + } + await Promise.all(IDS.slice(0, 5).map((id) => h.published(id))) + await vi.waitFor(() => + expect( + h.executor + .snapshotPageInventory() + .map((p) => p.browserPageId) + .sort() + ).toEqual(['fast', 'fifth', 'fourth', 'slow', 'third']) + ) + expect( + h.results.some((r) => r.id === 'sixth' && r.type === 'closePage' && r.status === 'completed') + ).toBe(true) + expect( + h.commands.some((c) => c.browserPageId === 'sixth' && c.command.type === 'createPage') + ).toBe(false) + next.abort() + await next.completion + } finally { + await h.close() + } +}) diff --git a/src/main/runtime/browser/client-pages/client-page-recovery-peer-test-harness.ts b/src/main/runtime/browser/client-pages/client-page-recovery-peer-test-harness.ts new file mode 100644 index 00000000000..14e927bf173 --- /dev/null +++ b/src/main/runtime/browser/client-pages/client-page-recovery-peer-test-harness.ts @@ -0,0 +1,102 @@ +import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY } from '../../../../shared/browser/runtime/browser-runtime-capabilities' +import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY } from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../../rpc/dispatcher' +import { BROWSER_CLIENT_HOST_METHODS } from '../../rpc/methods/browser/browser-client-host' +import { BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import { BrowserClientHostCommandDispatcher } from '../../../browser/client-hosts/commands/browser-client-host-command-dispatcher' +import { createRecoveryNativeLoop } from './client-page-recovery-native-test-harness' + +export function createRecoveryPeer( + runtime: OrcaRuntimeService, + hostId: string, + deviceId = 'device-a' +) { + const native = createRecoveryNativeLoop({}) + const executor = native.createExecutor() + const stopped = new AbortController() + const rpc = new RpcDispatcher({ runtime, methods: BROWSER_CLIENT_HOST_METHODS }) + const caller = { + connectionId: `connection-${hostId}`, + pairedDeviceId: deviceId, + clientKind: 'runtime' as const, + clientCapabilities: [BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY] + } + let dispatcher: BrowserClientHostCommandDispatcher | undefined + let completion: Promise | undefined + const errors: unknown[] = [] + const commands: string[] = [] + return { + ...native, + executor, + errors, + commands, + attach() { + completion = rpc.dispatchStreaming( + { + id: hostId, + authToken: 'test', + method: 'browser.clientHost.attach', + params: { + authorityRuntimeId: runtime.getRuntimeId(), + browserHostClientId: hostId, + hostCapabilities: [ + 'webview', + BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY, + BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY + ], + pageCommandProtocolVersion: 1, + pageInventoryProtocolVersion: 1, + pageReconciliationProtocolVersion: 1, + leaseReconnectProtocolVersion: 1, + pageInventory: executor.snapshotPageInventory() + } + }, + (reply) => { + const message = JSON.parse(reply) + if (message.ok === false) { + errors.push(message.error) + return + } + const event = message.result + if (event?.type === 'ready') { + dispatcher = new BrowserClientHostCommandDispatcher({ + authority: { + ...event, + authorityRuntimeId: runtime.getRuntimeId(), + browserHostClientId: hostId + }, + handler: (command, signal, close) => executor.handle(command, signal, close), + acquireInitialization: (command) => executor.acquireInitialization(command) + }) + } else if (event?.type === 'command') { + commands.push(event.browserPageId) + void dispatcher! + .dispatch(event) + .then((result) => + rpc.dispatchStreaming( + { + id: event.commandId, + authToken: 'test', + method: 'browser.clientHost.commandResult', + params: { ...event, result } + }, + () => {}, + caller + ) + ) + .catch((error) => errors.push(error)) + } + }, + { ...caller, signal: stopped.signal } + ) + }, + async close() { + stopped.abort() + native.releaseAll() + await dispatcher?.close() + await executor.close() + await completion + } + } +} diff --git a/src/main/runtime/browser/client-pages/client-page-recovery-publication.test.ts b/src/main/runtime/browser/client-pages/client-page-recovery-publication.test.ts new file mode 100644 index 00000000000..aa396787ffc --- /dev/null +++ b/src/main/runtime/browser/client-pages/client-page-recovery-publication.test.ts @@ -0,0 +1,126 @@ +import { adoptRuntimeBrowserClientPagesFromInventory } from './runtime-browser-client-page-adoption' +import { expect, it, vi } from 'vitest' +import { createRecoveryLoop } from './client-page-recovery-loop-test-harness' +import * as persistence from './client-hosted-browser-page-persistence' +import { retireRuntimeBrowserClientPage } from './browser-client-page-close' + +const WORKSPACE = 'repo-1::/tmp/worktree-a' + +it.each([ + [25, 750], + [250, 7500] +])( + 'seeds %i imports with one publication across a %i-worktree session', + async (count, worktrees) => { + const ids = Array.from({ length: count }, (_, index) => `page-${index}`) + const h = createRecoveryLoop([], { worktreeCount: worktrees }) + const originalPersist = persistence.persistClientHostedBrowserPages + const work = { passes: 0, rows: 0 } + const measure = vi + .spyOn(persistence, 'persistClientHostedBrowserPages') + .mockImplementation((host, registry, id) => + originalPersist( + host, + { + listPages: (workspace) => { + const rows = registry.listPages(workspace) + work.passes += 1 + work.rows += rows.length + return rows + } + }, + id + ) + ) + try { + await h.seedNative(ids) + for (const id of ids) { + h.hold(id) + } + const attached = h.attach() + await vi.waitFor(() => expect(h.pages.listPages()).toHaveLength(count)) + expect(work).toEqual({ passes: 1, rows: count }) + expect(h.profile.work).toEqual({ writes: 1, normalizedWorktrees: worktrees }) + expect(h.profile.session.clientHostedBrowserPagesByWorktree?.[WORKSPACE]).toHaveLength(count) + await adoptRuntimeBrowserClientPagesFromInventory({ + lease: h.authority.select('client-a'), + authority: h.authority, + pages: h.pages, + notifyWorkspace: (id) => h.hostRuntime.notifyMobileSessionTabsChanged(id), + resolveExecutionHostKey: async () => ({ + status: 'resolved', + executionHostKey: 'execution-host-a' + }) + }) + expect(h.profile.work.writes).toBe(1) + h.release(ids[0]!) + await h.published(ids[0]!) + expect(h.pages.getPage(ids[1]!)?.placement.pageHostGeneration).toBe(0) + expect( + h.profile.session.clientHostedBrowserPagesByWorktree?.[WORKSPACE]?.find( + (row) => row.browserPageId === ids[0] + )?.url + ).toBe('https://example.internal/page-0') + attached.abort() + await attached.completion + } finally { + measure.mockRestore() + await h.close() + } + } +) + +it('does not resurrect a closed queued import from the next authenticated inventory', async () => { + const h = createRecoveryLoop([]) + const ids = ['a', 'b', 'c', 'd', 'queued'] + try { + await h.seedNative(ids) + const started = ids.slice(0, 4).map((id) => h.hold(id)) + const first = h.attach() + await Promise.all(started) + const page = h.pages.getPage('queued')! + await retireRuntimeBrowserClientPage( + { + getBrowserHostLeaseRegistry: () => h.authority, + notifyHeadlessBrowserSessionTabsChanged: (id) => + h.hostRuntime.notifyMobileSessionTabsChanged(id), + getRuntimeBrowserPageRegistry: () => h.pages, + resolveBrowserWorkspace: async () => ({ id: WORKSPACE }) + }, + page + ) + expect( + h.commands.some( + (event) => event.browserPageId === 'queued' && event.command.type !== 'closePage' + ) + ).toBe(false) + const next = h.attach() + first.abort() + await first.completion + expect(h.pages.getPage('queued')).toBeUndefined() + expect( + h.profile.session.clientHostedBrowserPagesByWorktree?.[WORKSPACE]?.some( + (row) => row.browserPageId === 'queued' + ) + ).toBe(false) + h.release('a') + await h.published('a') + for (const id of ids.slice(0, 4)) { + h.release(id) + } + await vi.waitFor(() => + expect(h.executor.snapshotPageInventory().some((row) => row.browserPageId === 'queued')).toBe( + false + ) + ) + expect( + h.commands.filter( + (event) => event.browserPageId === 'queued' && event.command.type === 'createPage' + ) + ).toEqual([]) + next.abort() + await next.completion + } finally { + await h.close() + } +}) diff --git a/src/main/runtime/browser/client-pages/client-page-recovery-retirement.test.ts b/src/main/runtime/browser/client-pages/client-page-recovery-retirement.test.ts new file mode 100644 index 00000000000..fa7b4c84a06 --- /dev/null +++ b/src/main/runtime/browser/client-pages/client-page-recovery-retirement.test.ts @@ -0,0 +1,110 @@ +import { expect, it, vi } from 'vitest' +import { createRecoveryLoop } from './client-page-recovery-loop-test-harness' +import { retireRuntimeBrowserClientPage } from './browser-client-page-close' + +it.each(['no-close', 'close-first', 'retire-first'] as const)( + 'forgets fully retired cleanup authority with %s and preserves a freshly offered foreign tab', + async (order) => { + const h = createRecoveryLoop([]) + const attempted = Promise.withResolvers() + try { + await h.seedNative(['foreign']) + const inventory = h.executor.snapshotPageInventory() + h.hooks.delivery = (event, dispatch) => { + if (event.command.type === 'closePage') { + attempted.resolve() + } else { + dispatch() + } + } + const first = h.attach() + await attempted.promise + const page = h.pages.getPage('foreign')! + expect(page.placement.pageHostGeneration).toBe(0) + const close = () => + retireRuntimeBrowserClientPage( + { + getBrowserHostLeaseRegistry: () => h.authority, + notifyHeadlessBrowserSessionTabsChanged: (id) => + h.hostRuntime.notifyMobileSessionTabsChanged(id), + getRuntimeBrowserPageRegistry: () => h.pages, + resolveBrowserWorkspace: async () => ({ id: page.workspaceId }) + }, + page + ) + if (order === 'close-first') { + await close() + } + h.fence() + if (order === 'retire-first') { + await close() + } + expect(h.pages.getPage('foreign') === undefined).toBe(order !== 'no-close') + expect(h.executor.snapshotPageInventory()).toEqual(inventory) + first.abort() + await first.completion + h.hooks.delivery = undefined + const next = h.attach({ inventory }) + await h.published('foreign') + expect(h.pages.getPage('foreign')?.url).toBe('https://example.internal/foreign') + expect(h.authority.getClientPageExecutionHostKey('foreign')).toBe('execution-host-a') + expect(h.executor.snapshotPageInventory()).toHaveLength(1) + next.abort() + await next.completion + } finally { + await h.close() + } + } +) + +it('releases closed fresh-id scopes repeatedly before a fresh full-sized import', async () => { + const h = createRecoveryLoop([]) + try { + for (let cycle = 0; cycle < 3; cycle += 1) { + const id = `old-${cycle}` + const attempted = Promise.withResolvers() + await h.seedNative([id]) + h.hooks.delivery = (event, dispatch) => { + if (event.command.type === 'closePage') { + attempted.resolve() + } else { + dispatch() + } + } + const first = h.attach() + await attempted.promise + const page = h.pages.getPage(id)! + await retireRuntimeBrowserClientPage( + { + getBrowserHostLeaseRegistry: () => h.authority, + notifyHeadlessBrowserSessionTabsChanged: (workspace) => + h.hostRuntime.notifyMobileSessionTabsChanged(workspace), + getRuntimeBrowserPageRegistry: () => h.pages, + resolveBrowserWorkspace: async () => ({ id: page.workspaceId }) + }, + page + ) + h.fence() + first.abort() + await first.completion + await h.restartClient() + expect(h.pages.listPages()).toEqual([]) + expect(h.executor.snapshotPageInventory()).toEqual([]) + expect(h.authority.getPlacement(id)).toBeUndefined() + expect(h.authority.getClientPageExecutionHostKey(id)).toBeUndefined() + } + const ids = Array.from({ length: 256 }, (_, index) => `fresh-${index}`) + await h.seedNative(ids) + for (const id of ids) { + h.hold(id) + } + h.hooks.delivery = undefined + const next = h.attach() + await vi.waitFor(() => expect(h.pages.listPages()).toHaveLength(256)) + expect(h.pages.listPages().every((page) => page.browserPageId.startsWith('fresh-'))).toBe(true) + next.abort() + await next.completion + } finally { + await h.close() + } +}) diff --git a/src/main/runtime/browser/client-pages/client-page-recovery-stream-test-harness.ts b/src/main/runtime/browser/client-pages/client-page-recovery-stream-test-harness.ts index 1ad26127583..b6e214cdaaa 100644 --- a/src/main/runtime/browser/client-pages/client-page-recovery-stream-test-harness.ts +++ b/src/main/runtime/browser/client-pages/client-page-recovery-stream-test-harness.ts @@ -47,3 +47,22 @@ export function openRecoveryTabsStream( ) return { frames, errors, initial: initial.promise, completion, stop: () => stopped.abort() } } + +export function publishRecoveryTabsGraph(runtime: OrcaRuntimeService): void { + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: 'repo-1::/tmp/worktree-a', + publicationEpoch: 'renderer', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) +} diff --git a/src/main/runtime/browser/client-pages/client-page-recovery-terminal-cache.test.ts b/src/main/runtime/browser/client-pages/client-page-recovery-terminal-cache.test.ts new file mode 100644 index 00000000000..780d03bc1a8 --- /dev/null +++ b/src/main/runtime/browser/client-pages/client-page-recovery-terminal-cache.test.ts @@ -0,0 +1,131 @@ +import { expect, it, vi } from 'vitest' +import { createRecoveryLoop } from './client-page-recovery-loop-test-harness' +import { retireRuntimeBrowserClientPage } from './browser-client-page-close' + +it('keeps an admitted exact close across earlier creation-result eviction', async () => { + const h = createRecoveryLoop(['slow', 'fast', 'b', 'c', 'd', 'queued'], { + maxCachedCommandResults: 6 + }) + try { + const started = ['slow', 'b', 'c', 'd'].map((id) => h.hold(id)) + const attached = h.attach() + await Promise.all(started) + await h.published('fast') + expect(h.commands.some((event) => event.browserPageId === 'queued')).toBe(false) + await retireRuntimeBrowserClientPage( + { + getBrowserHostLeaseRegistry: () => h.authority, + notifyHeadlessBrowserSessionTabsChanged: (id) => + h.hostRuntime.notifyMobileSessionTabsChanged(id), + getRuntimeBrowserPageRegistry: () => h.pages, + resolveBrowserWorkspace: async () => ({ id: 'workspace' }) + }, + h.pages.getPage('slow')! + ) + await vi.waitFor(() => + expect(h.results.findLast((result) => result.id === 'slow')).toMatchObject({ + type: 'closePage', + status: 'completed' + }) + ) + expect( + h.results + .filter((result) => result.id === 'slow') + .map((result) => [result.type, result.status]) + ).toEqual([ + ['createPage', 'completed'], + ['navigate', 'failed'], + ['closePage', 'completed'] + ]) + await h.published('queued') + expect(h.authority.getClientPageExecutionHostKey('slow')).toBeUndefined() + expect(h.authority.getClientPageExecutionHostKey('queued')).toBe('execution-host-a') + expect(h.native.order.indexOf('retire-guest')).toBeLessThan( + h.native.order.indexOf('release-session') + ) + for (const id of ['b', 'c', 'd']) { + h.release(id) + } + attached.abort() + await attached.completion + } finally { + await h.close() + } +}) + +it('rejects a genuine missing native page after its bootstrap results were evicted', async () => { + const ids = ['missing', 'a', 'b', 'c', 'd', 'e', 'f'] + const h = createRecoveryLoop(ids, { maxCachedCommandResults: 6 }) + try { + const attached = h.attach() + await Promise.all(ids.map((id) => h.published(id))) + const page = h.pages.getPage('missing')! + expect(await h.executor.retirePage('missing', page.placement.pageHostGeneration)).toBe(true) + const identity = { + authorityRuntimeId: h.authority.authorityRuntimeId, + authorityEpoch: h.authority.authorityEpoch, + browserPageId: 'missing', + ...page.placement + } + const close = h.authority.issueClientPageCommand(identity, { + type: 'closePage', + targetAuthority: identity + }) + expect(await close.result).toMatchObject({ status: 'failed' }) + expect( + h.executor.snapshotPageInventory().some((page) => page.browserPageId === 'missing') + ).toBe(false) + attached.abort() + await attached.completion + } finally { + await h.close() + } +}) + +it('cancels an admitted queued close on dispatcher teardown without claiming native cleanup', async () => { + const h = createRecoveryLoop(['slow', 'fast'], { + maxCachedCommandResults: 6, + maxConcurrentHandlers: 1 + }) + try { + const navigating = h.hold('slow') + const attached = h.attach() + await navigating + const retirement = h.holdRetirement('slow') + await retireRuntimeBrowserClientPage( + { + getBrowserHostLeaseRegistry: () => h.authority, + notifyHeadlessBrowserSessionTabsChanged: (id) => + h.hostRuntime.notifyMobileSessionTabsChanged(id), + getRuntimeBrowserPageRegistry: () => h.pages, + resolveBrowserWorkspace: async () => ({ id: 'workspace' }) + }, + h.pages.getPage('slow')! + ) + await retirement.started + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const closing = h.closeClientDispatcher() + await vi.advanceTimersByTimeAsync(5001) + await closing + expect( + h.results.findLast((result) => result.id === 'slow' && result.type === 'closePage') + ).toMatchObject({ status: 'failed' }) + expect(h.executor.snapshotPageInventory().some((page) => page.browserPageId === 'slow')).toBe( + true + ) + expect(h.pages.getPage('slow')).toBeUndefined() + expect(() => h.authority.select('client-a')).toThrow('browser_host_unavailable') + expect(h.authority.getClientPageExecutionHostKey('slow')).toBeUndefined() + retirement.release() + await vi.waitFor(() => + expect(h.executor.snapshotPageInventory().some((page) => page.browserPageId === 'slow')).toBe( + false + ) + ) + attached.abort() + await attached.completion + } finally { + vi.useRealTimers() + await h.close() + } +}) diff --git a/src/main/runtime/browser/client-pages/client-page-recovery-workspace-test-harness.ts b/src/main/runtime/browser/client-pages/client-page-recovery-workspace-test-harness.ts new file mode 100644 index 00000000000..a2b3cd44710 --- /dev/null +++ b/src/main/runtime/browser/client-pages/client-page-recovery-workspace-test-harness.ts @@ -0,0 +1,55 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-session-contracts' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { ResolvedWorktree } from '../../workspaces/catalog/runtime-worktree-path-identity' + +export class RecoveryWorkspaceRuntime extends OrcaRuntimeService { + beforeRecoveryWorkspace?: () => Promise | undefined + protected override async resolveWorkspaceSelector(selector: string): Promise { + if (selector !== 'id:repo-1::/tmp/worktree-a') { + throw new Error('selector_not_found') + } + const git = { + path: '/tmp/worktree-a', + head: 'test-head', + branch: 'test', + isBare: false, + isMainWorktree: false + } + return { + ...git, + id: 'repo-1::/tmp/worktree-a', + repoId: 'repo-1', + displayName: 'test', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git + } + } + protected override async resolveBrowserWorkspace(selector: string) { + await this.beforeRecoveryWorkspace?.() + return super.resolveBrowserWorkspace(selector) + } + override getRuntimeId(): string { + return 'runtime-a' + } + override async resolveBrowserExecutionHostKeyForWorkspace() { + return { status: 'resolved' as const, executionHostKey: 'execution-host-a' } + } +} + +export type RecoveryRuntimeObservation = RecoveryWorkspaceRuntime & { + projectedFrames: number + sessionTabsSequence(): number + peekSnapshotForClient(worktree: string, clientId: string): RuntimeMobileSessionTabsResult + snapshotForClient(worktree: string, clientId: string): RuntimeMobileSessionTabsResult +} diff --git a/src/main/runtime/browser/client-pages/orca-runtime-browser-ghost-session-row-close.test.ts b/src/main/runtime/browser/client-pages/orca-runtime-browser-ghost-session-row-close.test.ts index 48798f30ee6..5337c33149e 100644 --- a/src/main/runtime/browser/client-pages/orca-runtime-browser-ghost-session-row-close.test.ts +++ b/src/main/runtime/browser/client-pages/orca-runtime-browser-ghost-session-row-close.test.ts @@ -79,7 +79,10 @@ function createHost(overrides: Partial = {}): Runtime resolveWorkspaceSelector: async (selector: string) => ({ id: selector.replace(/^id:/, '') }), resolveBrowserWorkspace: async (selector: string) => ({ id: selector.replace(/^id:/, '') }), getRuntimeBrowserPageRegistry: () => runtimeBrowserPages, - getBrowserHostLeaseRegistry: () => ({ getPlacement: () => undefined }), + getBrowserHostLeaseRegistry: () => ({ + getPlacement: () => undefined, + cancelClientPageInitialization: () => false + }), getAuthoritativeWindow: vi.fn(), getAvailableAuthoritativeWindow: vi.fn(() => null), getOffscreenBrowserBackend: vi.fn(() => null), diff --git a/src/main/runtime/browser/client-pages/runtime-browser-client-automation.ts b/src/main/runtime/browser/client-pages/runtime-browser-client-automation.ts index 89f9f4c0e9f..2ee26ec8944 100644 --- a/src/main/runtime/browser/client-pages/runtime-browser-client-automation.ts +++ b/src/main/runtime/browser/client-pages/runtime-browser-client-automation.ts @@ -1,3 +1,6 @@ +import { actOnRuntimeBrowserInitialization } from './runtime-browser-page-initialization-action' +import { isSameBrowserUrl } from '../../../../shared/browser/client-hosting/browser-expected-url' +import type { BrowserExecutionHostKeyResolution } from './runtime-browser-client-page-adoption' import { BrowserClientAutomationMethod } from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' import { BrowserError } from '../../../browser/browser-error' import { browserExpectedUrlMismatchError } from '../../../browser/cdp/input/browser-expected-page-url' @@ -13,6 +16,8 @@ export async function routeRuntimeBrowserClientAutomation(options: { caller: RpcCallerIdentity | undefined pages: RuntimeBrowserPageRegistry leases: BrowserHostLeaseRegistry + resolveExecutionHostKey?(workspaceId: string): Promise + notifyWorkspace?(workspaceId: string): void resolveWorkspace(selector: string): Promise<{ id: string }> }): Promise { const method = BrowserClientAutomationMethod.safeParse(options.method) @@ -23,6 +28,38 @@ export async function routeRuntimeBrowserClientAutomation(options: { if (!page) { return { handled: false } } + if ( + method.data === 'browser.goto' && + typeof options.params.url === 'string' && + options.leases.clientPageInitializations.lease(page.browserPageId, page.placement) && + options.resolveExecutionHostKey && + options.notifyWorkspace + ) { + if ( + typeof options.params.expectUrl === 'string' && + !isSameBrowserUrl(options.params.expectUrl, page.url) + ) { + throw browserExpectedUrlMismatchError({ + expectedUrl: options.params.expectUrl, + actualUrl: page.url, + browserPageId: page.browserPageId, + mayHaveStarted: false + }) + } + const url = await actOnRuntimeBrowserInitialization({ + pages: options.pages, + authority: options.leases, + page, + action: { type: 'navigate', url: options.params.url }, + resolveExecutionHostKey: options.resolveExecutionHostKey, + notifyWorkspace: options.notifyWorkspace + }) + const current = options.pages.getPage(page.browserPageId) + if (!current || !url) { + throw new Error('browser_page_placement_stale') + } + return { handled: true, result: { url, title: current.title } } + } const issued = options.leases.issueClientPageCommand( { authorityRuntimeId: options.leases.authorityRuntimeId, diff --git a/src/main/runtime/browser/client-pages/runtime-browser-client-page-adoption.test.ts b/src/main/runtime/browser/client-pages/runtime-browser-client-page-adoption.test.ts index 7cd484f41f3..a537f0d7aa4 100644 --- a/src/main/runtime/browser/client-pages/runtime-browser-client-page-adoption.test.ts +++ b/src/main/runtime/browser/client-pages/runtime-browser-client-page-adoption.test.ts @@ -132,6 +132,19 @@ describe('adoptRuntimeBrowserClientPagesFromInventory', () => { expect(rig.leases.getPlacement('x')).toBeUndefined() }) + it('restores a legacy empty-address guest after closing its predecessor with the old omitted URL shape', async () => { + const rig = attach([orphanedPage({ currentUrl: '' })]) + try { + const result = await rig.adopt() + expect(result.adoptedPageIds).toEqual(['page-a']) + expect(rig.commands.map((event) => event.command.type)).toEqual(['closePage', 'restorePage']) + expect(rig.commands[1].command).not.toHaveProperty('url') + expect(rig.pages.getPage('page-a')).toBeDefined() + } finally { + rig.host.release() + } + }) + it('leaves a page adopted on an earlier attach untouched when the host reconnects', async () => { const pages = new RuntimeBrowserPageRegistry() const leases = new BrowserHostLeaseRegistry({ diff --git a/src/main/runtime/browser/client-pages/runtime-browser-client-page-adoption.ts b/src/main/runtime/browser/client-pages/runtime-browser-client-page-adoption.ts index 3396ac21f94..ad7d32cc133 100644 --- a/src/main/runtime/browser/client-pages/runtime-browser-client-page-adoption.ts +++ b/src/main/runtime/browser/client-pages/runtime-browser-client-page-adoption.ts @@ -1,3 +1,7 @@ +import { sameRuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement' +import type { RuntimeBrowserClientPlacement } from '../../../../shared/runtime-browser-placement' +import { admitModernAdoptions } from './runtime-browser-client-page-modern-adoption' +import { BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' import type { BrowserClientHostedPageInventory } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' import type { BrowserHostLease } from '../../browser-host/leases/browser-host-lease-records' import type { BrowserHostLeaseRegistry } from '../../browser-host/leases/browser-host-lease-registry' @@ -11,7 +15,12 @@ import type { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry type AdoptionAuthority = Pick< BrowserHostLeaseRegistry, - 'authorityRuntimeId' | 'authorityEpoch' | 'adoptClientPages' | 'getPlacement' + | 'authorityRuntimeId' + | 'authorityEpoch' + | 'adoptClientPages' + | 'getPlacement' + | 'admitClientPageInitialization' + | 'retireUnpublishedClientPage' > /** Unavailable routes leave real workspaces/pages alive; only workspace-gone proves there is nowhere to restore. */ @@ -35,6 +44,8 @@ export type RuntimeBrowserClientPageAdoptionResult = { adoptedPageIds: readonly string[] /** Keep rows for failed/deferred adoption: client guests remain live until reconciliation can finish. */ unadoptedPageIds: readonly string[] + /** Claimed initializations remain unpublished but must not be admitted again by recovery. */ + claimedPageIds?: readonly string[] } const NOTHING_TO_ADOPT: RuntimeBrowserClientPageAdoptionResult = Object.freeze({ @@ -55,19 +66,59 @@ export async function adoptRuntimeBrowserClientPagesFromInventory( ) { return NOTHING_TO_ADOPT } + const inventoryByPageId = new Map(inventory.map((page) => [page.browserPageId, page])) + const modern = options.lease.hostCapabilities.includes( + BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY + ) + const pending = (page: BrowserClientHostedPageInventory): boolean => + page.state !== 'active' || (!modern && page.currentUrl === undefined) const adoptable = selectAdoptableClientHostedPages({ inventory, browserHostClientId: options.lease.browserHostClientId, authorityRuntimeId: options.authority.authorityRuntimeId, + hasSavedPage: (inventory) => { + const page = options.pages.getPage(inventory.browserPageId) + return Boolean( + page && + page.pairedDeviceId === options.lease.pairedDeviceId && + page.workspaceId === inventory.workspaceId && + page.browserProfileId === inventory.browserProfileId && + (isRestoredClientHostedBrowserPlacement(page.placement) || + (pending(inventory) && + page.placement.browserHostClientId === inventory.browserHostClientId && + page.placement.browserHostGeneration === inventory.browserHostGeneration && + page.placement.pageHostGeneration === inventory.pageHostGeneration)) + ) + }, // Persisted rows have no guest; let adoption rekey the reported live DOM rather than recovery destroy and recreate it. hasRuntimePage: (browserPageId) => { const page = options.pages.getPage(browserPageId) - return page !== undefined && !isRestoredClientHostedBrowserPlacement(page.placement) + const observed = inventoryByPageId.get(browserPageId) + return ( + page !== undefined && + !isRestoredClientHostedBrowserPlacement(page.placement) && + !( + observed && + pending(observed) && + page.pairedDeviceId === options.lease.pairedDeviceId && + page.workspaceId === observed.workspaceId && + page.browserProfileId === observed.browserProfileId && + page.placement.pageHostGeneration === observed.pageHostGeneration && + page.placement.browserHostClientId === observed.browserHostClientId && + page.placement.browserHostGeneration === observed.browserHostGeneration + ) + ) } }) if (adoptable.length === 0) { return NOTHING_TO_ADOPT } + if (options.lease.hostCapabilities.includes(BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY)) { + return admitModernAdoptions(options, adoptable) + } + const captured = new Map( + adoptable.map((page) => [page.browserPageId, options.pages.getPage(page.browserPageId)]) + ) const executionHostKeyByWorkspaceId = new Map() const goneWorkspaceIds = new Set() for (const workspaceId of new Set(adoptable.map((page) => page.workspaceId))) { @@ -93,12 +144,16 @@ export async function adoptRuntimeBrowserClientPagesFromInventory( browserHostClientId: options.lease.browserHostClientId, browserHostGeneration: options.lease.browserHostGeneration }, - executionHostKeyByWorkspaceId + executionHostKeyByWorkspaceId, + savedUrlByPageId: new Map( + [...captured].flatMap(([id, page]) => (page ? [[id, page.url] as const] : [])) + ) }) if (intents.length === 0) { return { adoptedPageIds: [], unadoptedPageIds: settle(adoptable) } } const intentsByPageId = new Map(intents.map((intent) => [intent.browserPageId, intent])) + const adoptedPlacements = new Map() const adoptedPageIds = new Set( await options.authority.adoptClientPages( { @@ -108,7 +163,7 @@ export async function adoptRuntimeBrowserClientPagesFromInventory( pairedDeviceId: options.lease.pairedDeviceId }, intents, - options.signal ? { signal: options.signal } : {} + { signal: options.signal, onAdopted: (id, placement) => adoptedPlacements.set(id, placement) } ) ) const byPageId = new Map(adoptable.map((page) => [page.browserPageId, page])) @@ -117,12 +172,30 @@ export async function adoptRuntimeBrowserClientPagesFromInventory( for (const browserPageId of adoptedPageIds) { const page = byPageId.get(browserPageId) const intent = intentsByPageId.get(browserPageId) - const placement = options.authority.getPlacement(browserPageId) + const placement = adoptedPlacements.get(browserPageId) if (!page || !intent || placement?.kind !== 'client') { continue } try { const restored = options.pages.getPage(browserPageId) + const expected = captured.get(browserPageId) + if ( + (expected && + (!restored || !sameRuntimeBrowserPlacement(restored.placement, expected.placement))) || + (!expected && restored !== undefined) + ) { + options.authority.retireUnpublishedClientPage(options.lease, { + browserPageId, + browserProfileId: page.browserProfileId, + workspaceId: page.workspaceId, + expected: placement, + url: intent.url ?? adoptedPageUrl(page), + isCurrent: () => false, + resolveExecutionHostKey: async () => null, + publish: () => {} + }) + continue + } if (restored && isRestoredClientHostedBrowserPlacement(restored.placement)) { // Retire before replacing from host inventory: publishing over a live id is refused. options.pages.retirePage(browserPageId, restored.placement) @@ -134,7 +207,7 @@ export async function adoptRuntimeBrowserClientPagesFromInventory( executionHostKey: intent.executionHostKey, placement, pairedDeviceId: options.lease.pairedDeviceId, - url: adoptedPageUrl(page), + url: intent.url ?? adoptedPageUrl(page), loading: false, // Leave focus to client activation reports; adopting active would deactivate a visible sibling. active: false diff --git a/src/main/runtime/browser/client-pages/runtime-browser-client-page-creation.ts b/src/main/runtime/browser/client-pages/runtime-browser-client-page-creation.ts index 766492fb220..bd101aa05e0 100644 --- a/src/main/runtime/browser/client-pages/runtime-browser-client-page-creation.ts +++ b/src/main/runtime/browser/client-pages/runtime-browser-client-page-creation.ts @@ -17,6 +17,8 @@ type RuntimeBrowserClientPageCreation = { browserProfileId: string executionHost: BrowserNetworkExecutionHost workspaceId?: string + signal?: AbortSignal + committed?(placement: RuntimeBrowserClientPlacement): void } type RuntimeBrowserClientPageNavigation = { @@ -41,6 +43,8 @@ export async function createRuntimeBrowserClientPage( browserProfileId: input.browserProfileId, executionHostKey: browserNetworkExecutionHostKey(input.executionHost), requiredCapabilities: [BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY], + ...(input.signal ? { signal: input.signal } : {}), + ...(input.committed ? { committed: input.committed } : {}), ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}) }) return { browserPageId: input.browserPageId, placement } diff --git a/src/main/runtime/browser/client-pages/runtime-browser-client-page-modern-adoption.ts b/src/main/runtime/browser/client-pages/runtime-browser-client-page-modern-adoption.ts new file mode 100644 index 00000000000..5d3f414a0a8 --- /dev/null +++ b/src/main/runtime/browser/client-pages/runtime-browser-client-page-modern-adoption.ts @@ -0,0 +1,95 @@ +import { + normalizeBrowserNavigationUrl, + redactKagiSessionToken +} from '../../../../shared/browser-url' +import { batchBrowserPageInitializationNotifications } from './browser-page-initialization-notifications' +import { + RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT, + RESTORED_CLIENT_HOSTED_EXECUTION_HOST_KEY +} from './client-hosted-browser-page-persistence' +import { admitRuntimeBrowserPageInitialization } from './runtime-browser-page-initialization' +import type { + RuntimeBrowserClientPageAdoptionOptions, + RuntimeBrowserClientPageAdoptionResult +} from './runtime-browser-client-page-adoption' +import type { AdoptableClientHostedPage } from '../../browser-host/client-pages/browser-host-client-page-adoption' + +export function admitModernAdoptions( + options: RuntimeBrowserClientPageAdoptionOptions, + candidates: readonly AdoptableClientHostedPage[] +): RuntimeBrowserClientPageAdoptionResult { + const remaining = new Set(candidates.map((page) => page.browserPageId)) + const published: string[] = [] + const claimed: string[] = [] + const ordered = [...candidates].sort((a, b) => a.pageHostGeneration - b.pageHostGeneration) + const highest = ordered.reduce((value, page) => Math.max(value, page.pageHostGeneration), 0) + batchBrowserPageInitializationNotifications(options.notifyWorkspace, (notifyWorkspace) => { + for (const [index, inventory] of ordered.entries()) { + if (options.signal?.aborted) { + continue + } + const existing = options.pages.getPage(inventory.browserPageId) + const page = existing ?? { + browserPageId: inventory.browserPageId, + workspaceId: inventory.workspaceId, + browserProfileId: inventory.browserProfileId, + pairedDeviceId: options.lease.pairedDeviceId, + placement: RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT, + executionHostKey: RESTORED_CLIENT_HOSTED_EXECUTION_HOST_KEY, + url: redactKagiSessionToken( + normalizeBrowserNavigationUrl(inventory.currentUrl ?? 'about:blank') ?? 'about:blank' + ), + loading: false, + active: false, + title: 'Browser', + canGoBack: false, + canGoForward: false, + metadataRevision: 0 + } + try { + const disposition = admitRuntimeBrowserPageInitialization({ + ...options, + notifyWorkspace, + page, + previous: inventory, + pageHostGeneration: highest + index + 1, + url: inventory.currentUrl ?? page.url, + creationTimeoutMs: 15_000, + ...(!existing + ? { + claim: () => { + options.pages.publishClientPage(page) + notifyWorkspace(page.workspaceId) + } + } + : {}), + releaseUnrecoverablePage: (held) => { + if (options.pages.retirePage(held.browserPageId, held.placement)) { + notifyWorkspace(held.workspaceId) + } + }, + settled: (outcome) => { + if (options.signal?.aborted) { + return + } + if (outcome === 'published') { + published.push(inventory.browserPageId) + } + if (outcome === 'published' || outcome === 'retained-clean' || outcome === 'disposed') { + remaining.delete(inventory.browserPageId) + } + } + }) + if (disposition !== 'stale') { + claimed.push(inventory.browserPageId) + } + } catch (error) { + console.warn('[browser-host-lease] client page initialization admission failed:', { + browserPageId: inventory.browserPageId, + error + }) + } + } + }) + return { adoptedPageIds: published, unadoptedPageIds: [...remaining], claimedPageIds: claimed } +} diff --git a/src/main/runtime/browser/client-pages/runtime-browser-client-page-recovery-authority.ts b/src/main/runtime/browser/client-pages/runtime-browser-client-page-recovery-authority.ts new file mode 100644 index 00000000000..0d78192f3ff --- /dev/null +++ b/src/main/runtime/browser/client-pages/runtime-browser-client-page-recovery-authority.ts @@ -0,0 +1,54 @@ +import type { RuntimeBrowserClientPage } from './runtime-browser-page-registry' +import type { + BrowserClientHostedPageInventory, + BrowserClientHostLeaseAuthority +} from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import { isRestoredClientHostedBrowserPlacement } from './client-hosted-browser-page-persistence' + +/** Retained pages match client identity across generations. Restored pages match paired device because client ids rotate per process. + * Device matching prevents another or revoked/re-paired device from inheriting tabs; unmatched rows stay host-absent and closeable. */ +export function isRecoverableByLease( + page: RuntimeBrowserClientPage, + lease: BrowserClientHostLeaseAuthority & { pairedDeviceId: string } +): boolean { + if (isRestoredClientHostedBrowserPlacement(page.placement)) { + return page.pairedDeviceId === lease.pairedDeviceId + } + return ( + page.placement.browserHostClientId === lease.browserHostClientId && + page.placement.browserHostGeneration <= lease.browserHostGeneration + ) +} + +export function isActiveExactPage( + page: RuntimeBrowserClientPage, + inventory: BrowserClientHostedPageInventory | undefined, + lease: BrowserClientHostLeaseAuthority +): boolean { + return Boolean( + inventory?.state === 'active' && + inventory.authorityRuntimeId === lease.authorityRuntimeId && + inventory.authorityEpoch === lease.authorityEpoch && + inventory.browserHostClientId === page.placement.browserHostClientId && + inventory.browserHostGeneration === page.placement.browserHostGeneration && + inventory.pageHostGeneration === page.placement.pageHostGeneration && + inventory.browserProfileId === page.browserProfileId && + inventory.executionHostKey === page.executionHostKey + ) +} + +export function assertInventoryAuthority( + page: RuntimeBrowserClientPage, + inventory: BrowserClientHostedPageInventory, + lease: BrowserClientHostLeaseAuthority +): void { + if ( + inventory.authorityRuntimeId !== lease.authorityRuntimeId || + inventory.authorityEpoch !== lease.authorityEpoch || + inventory.browserHostClientId !== page.placement.browserHostClientId || + inventory.browserHostGeneration !== page.placement.browserHostGeneration || + inventory.pageHostGeneration !== page.placement.pageHostGeneration + ) { + throw new Error('browser_client_page_reconciliation_authority_stale') + } +} diff --git a/src/main/runtime/browser/client-pages/runtime-browser-client-page-recovery-queue.ts b/src/main/runtime/browser/client-pages/runtime-browser-client-page-recovery-queue.ts new file mode 100644 index 00000000000..32ab4f1eaf5 --- /dev/null +++ b/src/main/runtime/browser/client-pages/runtime-browser-client-page-recovery-queue.ts @@ -0,0 +1,18 @@ +export async function recoverClientPagesWithConcurrency( + values: readonly T[], + concurrency: number, + operation: (value: T) => Promise, + signal?: AbortSignal +): Promise { + let index = 0 + const worker = async (): Promise => { + while (index < values.length && !signal?.aborted) { + const value = values[index] + index += 1 + if (value !== undefined) { + await operation(value) + } + } + } + await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, worker)) +} diff --git a/src/main/runtime/browser/client-pages/runtime-browser-client-page-recovery.test.ts b/src/main/runtime/browser/client-pages/runtime-browser-client-page-recovery.test.ts index 4b153493dd4..95d92454e94 100644 --- a/src/main/runtime/browser/client-pages/runtime-browser-client-page-recovery.test.ts +++ b/src/main/runtime/browser/client-pages/runtime-browser-client-page-recovery.test.ts @@ -104,6 +104,9 @@ function harness( }) const commands: { browserPageId: string; type: string; pageHostGeneration: number }[] = [] const authority = { + clientPageInitializations: { canContinue: () => false }, + admitClientPageInitialization: () => 'stale' as const, + claimLegacyClientPageRecovery: () => undefined, authorityRuntimeId: 'runtime-a', authorityEpoch: 'epoch-a', getPlacement: vi.fn((browserPageId: string) => placements.get(browserPageId)), @@ -117,15 +120,21 @@ function harness( placements.set(retirement.browserPageId, undefined) return true }), - createClientPage: vi.fn(async (input: { browserPageId: string }) => { - if (options.creationFailures?.includes(input.browserPageId)) { - throw new Error('browser_host_page_creation_timeout') + createClientPage: vi.fn( + async (input: { + browserPageId: string + committed?(placement: RuntimeBrowserClientPlacement): void + }) => { + if (options.creationFailures?.includes(input.browserPageId)) { + throw new Error('browser_host_page_creation_timeout') + } + const index = pageIds.indexOf(input.browserPageId) + const placement = Object.freeze({ ...newPlacement, pageHostGeneration: 8 + index * 2 }) + placements.set(input.browserPageId, placement) + input.committed?.(placement) + return placement } - const index = pageIds.indexOf(input.browserPageId) - const placement = Object.freeze({ ...newPlacement, pageHostGeneration: 8 + index * 2 }) - placements.set(input.browserPageId, placement) - return placement - }), + ), issueClientPageCommand: vi.fn( (input: { browserPageId: string; pageHostGeneration: number }, command: { type: string }) => { commands.push({ diff --git a/src/main/runtime/browser/client-pages/runtime-browser-client-page-recovery.ts b/src/main/runtime/browser/client-pages/runtime-browser-client-page-recovery.ts index bc08b408cfe..289d20e1fe9 100644 --- a/src/main/runtime/browser/client-pages/runtime-browser-client-page-recovery.ts +++ b/src/main/runtime/browser/client-pages/runtime-browser-client-page-recovery.ts @@ -1,3 +1,12 @@ +import { batchBrowserPageInitializationNotifications } from './browser-page-initialization-notifications' +import { + isRecoverableByLease, + isActiveExactPage, + assertInventoryAuthority +} from './runtime-browser-client-page-recovery-authority' +import { recoverClientPagesWithConcurrency } from './runtime-browser-client-page-recovery-queue' +import { BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import { admitRuntimeBrowserPageInitialization } from './runtime-browser-page-initialization' import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY, type BrowserClientAutomationResult as BrowserClientHostCommandResult @@ -27,7 +36,13 @@ type RecoveryAuthority = Pick< | 'completePageRetirement' | 'createClientPage' | 'getPlacement' + | 'claimLegacyClientPageRecovery' + | 'admitClientPageInitialization' > & { + clientPageInitializations: Pick< + BrowserHostLeaseRegistry['clientPageInitializations'], + 'canContinue' + > issueClientPageCommand( authority: { authorityRuntimeId: string @@ -44,6 +59,8 @@ type RecoveryAuthority = Pick< export async function recoverUnavailableRuntimeBrowserClientPages(options: { lease: BrowserClientHostLeaseAuthority & { pairedDeviceId: string + connectionId?: string + hostCapabilities?: readonly string[] pageInventory?: readonly BrowserClientHostedPageInventory[] } authority: RecoveryAuthority @@ -55,6 +72,8 @@ export async function recoverUnavailableRuntimeBrowserClientPages(options: { releaseUnrecoverablePage?: (page: RuntimeBrowserClientPage) => void /** Skip newly adopted pages: inventory still names predecessor authority and would wrongly trigger their teardown. */ adoptedPageIds?: ReadonlySet + /** Adoption owns these queued/running rows and their current attach observer. */ + claimedPageIds?: ReadonlySet /** Re-resolve routes for persisted pages: keys name their minting runtime process and clients refuse predecessor keys. */ resolveExecutionHostKey?: (workspaceId: string) => Promise signal?: AbortSignal @@ -74,10 +93,35 @@ export async function recoverUnavailableRuntimeBrowserClientPages(options: { (page) => isPlacedAsObservedAtAttach(page, options.pagePlacementsAtAttach) && !options.adoptedPageIds?.has(page.browserPageId) && - isRecoverableByLease(page, options.lease) && + !options.claimedPageIds?.has(page.browserPageId) && + (isRecoverableByLease(page, options.lease) || + options.authority.clientPageInitializations.canContinue( + page.browserPageId, + page.placement, + options.lease + )) && !isActiveExactPage(page, inventoryByPageId.get(page.browserPageId), options.lease) ) - await mapWithConcurrency( + if (options.lease.hostCapabilities?.includes(BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY)) { + batchBrowserPageInitializationNotifications(options.notifyWorkspace, (notifyWorkspace) => { + for (const page of pages) { + const previous = inventoryByPageId.get(page.browserPageId) + admitRuntimeBrowserPageInitialization({ + ...options, + notifyWorkspace, + lease: options.lease, + page, + url: previous?.currentUrl ?? page.url, + ...(previous ? { previous } : {}), + resolveExecutionHostKey: + options.resolveExecutionHostKey ?? + (async () => ({ status: 'resolved', executionHostKey: page.executionHostKey })) + }) + } + }) + return + } + await recoverClientPagesWithConcurrency( pages, MAX_RECOVERY_CONCURRENCY, async (page) => { @@ -89,7 +133,7 @@ export async function recoverUnavailableRuntimeBrowserClientPages(options: { browserPageId: page.browserPageId, error }) - releaseUnhostablePage(page, options) + // The logical saved row remains closeable for a later authenticated recovery. } }, options.signal @@ -108,21 +152,6 @@ function isPlacedAsObservedAtAttach( return observed !== undefined && sameRuntimeBrowserPlacement(observed, page.placement) } -/** Retained pages match client identity across generations. Restored pages match paired device because client ids rotate per process. - * Device matching prevents another or revoked/re-paired device from inheriting tabs; unmatched rows stay host-absent and closeable. */ -function isRecoverableByLease( - page: RuntimeBrowserClientPage, - lease: BrowserClientHostLeaseAuthority & { pairedDeviceId: string } -): boolean { - if (isRestoredClientHostedBrowserPlacement(page.placement)) { - return page.pairedDeviceId === lease.pairedDeviceId - } - return ( - page.placement.browserHostClientId === lease.browserHostClientId && - page.placement.browserHostGeneration <= lease.browserHostGeneration - ) -} - /** Keep routes minted by this runtime; restored pages re-resolve. Retire only workspace-gone; unavailable routes leave held rows for later attach. */ async function resolveRecoveryExecutionHostKey( page: RuntimeBrowserClientPage, @@ -141,17 +170,6 @@ async function resolveRecoveryExecutionHostKey( return null } -/** Retires a page left with no placement at all; anything still placed can retry on a later attach. */ -function releaseUnhostablePage( - page: RuntimeBrowserClientPage, - options: Parameters[0] -): void { - if (options.authority.getPlacement(page.browserPageId)) { - return - } - options.releaseUnrecoverablePage?.(page) -} - async function recoverPage( page: RuntimeBrowserClientPage, inventory: BrowserClientHostedPageInventory | undefined, @@ -185,6 +203,22 @@ async function recoverPage( } } } + const legacyRecovery = options.authority.claimLegacyClientPageRecovery(options.lease, { + browserPageId: page.browserPageId, + browserProfileId: page.browserProfileId, + workspaceId: page.workspaceId, + expected: page.placement, + isCurrent: () => { + const current = options.pages.getPage(page.browserPageId) + return Boolean( + current && + current.pairedDeviceId === page.pairedDeviceId && + current.browserProfileId === page.browserProfileId && + current.workspaceId === page.workspaceId && + sameRuntimeBrowserPlacement(current.placement, page.placement) + ) + } + }) const placement = await options.authority.createClientPage({ browserPageId: page.browserPageId, browserHostClientId: options.lease.browserHostClientId, @@ -192,14 +226,18 @@ async function recoverPage( browserProfileId: page.browserProfileId, executionHostKey, requiredCapabilities: [BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY], - workspaceId: page.workspaceId + workspaceId: page.workspaceId, + signal: options.signal, + legacyRecovery, + committed: (placement) => + options.pages.replaceClientPagePlacement( + page.browserPageId, + page.placement, + placement, + executionHostKey + ) }) - const recovered = options.pages.replaceClientPagePlacement( - page.browserPageId, - page.placement, - placement, - executionHostKey - ) + const recovered = options.pages.getPage(page.browserPageId)! const url = inventory?.currentUrl ?? page.url if (url && url !== 'about:blank') { await navigateRecoveredPage(options.authority, page.browserPageId, placement, url) @@ -271,55 +309,3 @@ function commandAuthority( pageHostGeneration: placement.pageHostGeneration } } - -function isActiveExactPage( - page: RuntimeBrowserClientPage, - inventory: BrowserClientHostedPageInventory | undefined, - lease: BrowserClientHostLeaseAuthority -): boolean { - return Boolean( - inventory?.state === 'active' && - inventory.authorityRuntimeId === lease.authorityRuntimeId && - inventory.authorityEpoch === lease.authorityEpoch && - inventory.browserHostClientId === page.placement.browserHostClientId && - inventory.browserHostGeneration === page.placement.browserHostGeneration && - inventory.pageHostGeneration === page.placement.pageHostGeneration && - inventory.browserProfileId === page.browserProfileId && - inventory.executionHostKey === page.executionHostKey - ) -} - -function assertInventoryAuthority( - page: RuntimeBrowserClientPage, - inventory: BrowserClientHostedPageInventory, - lease: BrowserClientHostLeaseAuthority -): void { - if ( - inventory.authorityRuntimeId !== lease.authorityRuntimeId || - inventory.authorityEpoch !== lease.authorityEpoch || - inventory.browserHostClientId !== page.placement.browserHostClientId || - inventory.browserHostGeneration !== page.placement.browserHostGeneration || - inventory.pageHostGeneration !== page.placement.pageHostGeneration - ) { - throw new Error('browser_client_page_reconciliation_authority_stale') - } -} - -async function mapWithConcurrency( - values: readonly T[], - concurrency: number, - operation: (value: T) => Promise, - signal?: AbortSignal -): Promise { - let index = 0 - const worker = async (): Promise => { - while (index < values.length && !signal?.aborted) { - const value = values[index] - index += 1 - if (value !== undefined) { - await operation(value) - } - } - } - await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, worker)) -} diff --git a/src/main/runtime/browser/client-pages/runtime-browser-client-page-restored-recovery.test.ts b/src/main/runtime/browser/client-pages/runtime-browser-client-page-restored-recovery.test.ts index 46724a7d2f8..aa705d4bda2 100644 --- a/src/main/runtime/browser/client-pages/runtime-browser-client-page-restored-recovery.test.ts +++ b/src/main/runtime/browser/client-pages/runtime-browser-client-page-restored-recovery.test.ts @@ -106,6 +106,9 @@ function harness(options: { pairedDeviceId?: string } = {}) { const commands: { browserPageId: string; type: string }[] = [] const placements = new Map() const authority = { + clientPageInitializations: { canContinue: () => false }, + admitClientPageInitialization: () => 'stale' as const, + claimLegacyClientPageRecovery: () => undefined, authorityRuntimeId: 'runtime-new', authorityEpoch: 'epoch-new', getPlacement: vi.fn((browserPageId: string) => placements.get(browserPageId)), @@ -116,10 +119,16 @@ function harness(options: { pairedDeviceId?: string } = {}) { }) ), completePageRetirement: vi.fn(() => true), - createClientPage: vi.fn(async (input: { browserPageId: string }) => { - placements.set(input.browserPageId, freshPlacement) - return freshPlacement - }), + createClientPage: vi.fn( + async (input: { + browserPageId: string + committed?(placement: RuntimeBrowserClientPlacement): void + }) => { + placements.set(input.browserPageId, freshPlacement) + input.committed?.(freshPlacement) + return freshPlacement + } + ), issueClientPageCommand: vi.fn((input: { browserPageId: string }, command: { type: string }) => { commands.push({ browserPageId: input.browserPageId, type: command.type }) return { event: {}, result: Promise.resolve({ status: 'completed' as const }) } diff --git a/src/main/runtime/browser/client-pages/runtime-browser-page-initialization-action.ts b/src/main/runtime/browser/client-pages/runtime-browser-page-initialization-action.ts new file mode 100644 index 00000000000..02dcb1e99ce --- /dev/null +++ b/src/main/runtime/browser/client-pages/runtime-browser-page-initialization-action.ts @@ -0,0 +1,111 @@ +import { + normalizeBrowserNavigationUrl, + redactKagiSessionToken +} from '../../../../shared/browser-url' +import type { BrowserPageInitializationAction } from '../../../../shared/browser/client-hosting/browser-page-initialization-action' +import type { RuntimeBrowserClientPlacement } from '../../../../shared/runtime-browser-placement' +import { sameRuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement' +import type { BrowserHostLeaseRegistry } from '../../browser-host/leases/browser-host-lease-registry' +import type { BrowserExecutionHostKeyResolution } from './runtime-browser-client-page-adoption' +import type { + RuntimeBrowserClientPage, + RuntimeBrowserPageRegistry +} from './runtime-browser-page-registry' +import { admitRuntimeBrowserPageInitialization } from './runtime-browser-page-initialization' + +export function captureRuntimeBrowserInitializationAction( + pages: RuntimeBrowserPageRegistry, + authority: BrowserHostLeaseRegistry, + id: string, + expected: RuntimeBrowserClientPlacement +): () => RuntimeBrowserClientPage { + const captured = pages.getPage(id) + if (!captured || !sameRuntimeBrowserPlacement(captured.placement, expected)) { + throw new Error('browser_page_placement_stale') + } + const matches = + authority.clientPageInitializations.capture(id, expected) ?? + ((placement: RuntimeBrowserClientPlacement) => sameRuntimeBrowserPlacement(placement, expected)) + return () => { + const current = pages.getPage(id) + if ( + !current || + current.workspaceId !== captured.workspaceId || + current.browserProfileId !== captured.browserProfileId || + !matches(current.placement) + ) { + throw new Error('browser_page_placement_stale') + } + return current + } +} + +/** Acceptance changes the current page owner synchronously; completion belongs to that admission. */ +export function actOnRuntimeBrowserInitialization(input: { + pages: RuntimeBrowserPageRegistry + authority: BrowserHostLeaseRegistry + page: RuntimeBrowserClientPage + action: BrowserPageInitializationAction + explicitUrl?: boolean + resolveExecutionHostKey(workspaceId: string): Promise + notifyWorkspace(workspaceId: string): void +}): Promise { + const { pages, authority, page, action } = input + const url = action.type === 'navigate' ? normalizeBrowserNavigationUrl(action.url) : undefined + if (action.type === 'navigate' && (!url || url.startsWith('file:'))) { + throw new Error('browser_navigation_url_invalid') + } + const lease = authority.clientPageInitializations.lease(page.browserPageId, page.placement) + if (input.explicitUrl && url) { + pages.updatePage(page.browserPageId, page.placement, { + url: redactKagiSessionToken(url), + loading: true + }) + } + if (lease) { + if (action.type === 'stop') { + authority.clientPageInitializations.stop(page.browserPageId, page.placement) + return Promise.resolve(undefined) + } + const completion = Promise.withResolvers() + // UI callers await acceptance only; keep later cancellation from becoming an unhandled rejection. + void completion.promise.catch(() => {}) + const disposition = admitRuntimeBrowserPageInitialization({ + ...input, + lease, + url: url!, + replace: true, + completed: (destination) => + destination === undefined + ? completion.reject(new Error('browser_client_page_initialization_cancelled')) + : completion.resolve(destination) + }) + if (disposition === 'stale' || disposition === 'closed') { + completion.reject(new Error('browser_page_placement_stale')) + } + return completion.promise + } + const issued = authority.issueClientPageCommand( + { + authorityRuntimeId: authority.authorityRuntimeId, + authorityEpoch: authority.authorityEpoch, + browserPageId: page.browserPageId, + ...page.placement + }, + action.type === 'navigate' + ? { type: 'navigate', url: url! } + : { + type: 'automation', + method: 'browser.eval', + params: { page: page.browserPageId, expression: 'window.stop()' }, + agent: false + } + ) + input.notifyWorkspace(page.workspaceId) + return issued.result.then((result) => { + if (result.status === 'failed') { + throw new Error(result.errorCode) + } + return typeof result.value === 'string' ? result.value : undefined + }) +} diff --git a/src/main/runtime/browser/client-pages/runtime-browser-page-initialization-succession.test.ts b/src/main/runtime/browser/client-pages/runtime-browser-page-initialization-succession.test.ts new file mode 100644 index 00000000000..84699cebf39 --- /dev/null +++ b/src/main/runtime/browser/client-pages/runtime-browser-page-initialization-succession.test.ts @@ -0,0 +1,449 @@ +import { createRecoveryPeer } from './client-page-recovery-peer-test-harness' +import { expect, it, vi } from 'vitest' +import { createRecoveryLoop } from './client-page-recovery-loop-test-harness' +import { retireRuntimeBrowserClientPage } from './browser-client-page-close' +import { + buildPersistedClientHostedBrowserPages, + RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT +} from './client-hosted-browser-page-persistence' + +it('a new attach owns the successor while exact prior cleanup is still joining', async () => { + const h = createRecoveryLoop(['slow', 'fast']) + const started = h.hold('slow') + const first = h.attach() + try { + await started + await h.published('fast') + const retirement = h.holdRetirement('slow') + first.disconnect() + const next = h.attach() + await retirement.started + first.abort() + await first.completion + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + expect(h.pages.getPage('slow')?.placement.pageHostGeneration).toBe(0) + expect(h.pages.getPage('fast')?.placement.pageHostGeneration).toBeGreaterThan(0) + h.release('slow') + retirement.release() + await h.published('slow') + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + expect(h.authority.getClientPageExecutionHostKey('slow')).toBe('execution-host-a') + expect( + h.results + .filter((result) => result.id === 'slow') + .map((result) => [result.type, result.status]) + ).toContainEqual(['closePage', 'completed']) + expect(h.errors).toEqual([]) + next.abort() + await next.completion + } finally { + await h.close() + } +}) + +it.each(['client-a', 'replacement-client'])( + 'a fully retired ledger recovers its saved continuation on %s', + async (hostId) => { + const h = createRecoveryLoop(['slow']) + try { + let current = h.attach() + await h.published('slow') + for (let round = 0; round < 3; round += 1) { + h.fence() + current.abort() + await current.completion + await h.restartClient() + const started = h.hold('slow') + current = h.attach({ inventory: [] }) + await started + const saved = h.pages.getPage('slow')! + expect(saved.placement.pageHostGeneration).toBeGreaterThan(0) + h.fence() + current.abort() + await current.completion + await h.restartClient() + h.release('slow') + current = h.attach({ hostId, inventory: [] }) + await vi.waitFor(() => + expect(h.authority.getPlacement('slow')).toMatchObject({ + kind: 'client', + browserHostClientId: hostId + }) + ) + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + expect(h.executor.snapshotPageInventory()).toHaveLength(1) + expect(h.authority.getClientPageExecutionHostKey('slow')).toBe('execution-host-a') + } + await retireRuntimeBrowserClientPage( + { + getBrowserHostLeaseRegistry: () => h.authority, + notifyHeadlessBrowserSessionTabsChanged: (id) => + h.hostRuntime.notifyMobileSessionTabsChanged(id), + getRuntimeBrowserPageRegistry: () => h.pages, + resolveBrowserWorkspace: async () => ({ id: 'workspace' }) + }, + h.pages.getPage('slow')! + ) + expect(h.executor.snapshotPageInventory()).toEqual([]) + expect(buildPersistedClientHostedBrowserPages(h.pages.listPages(), 100)).toEqual([]) + current.abort() + await current.completion + expect(h.errors).toEqual([]) + } finally { + await h.close() + } + } +) + +it('full retirement preserves another live scope successor and an unrelated host', async () => { + const h = createRecoveryLoop(['slow']) + const successor = createRecoveryPeer(h.hostRuntime, 'successor-host') + const unrelated = createRecoveryPeer(h.hostRuntime, 'unrelated-host', 'device-b') + try { + h.pages.publishClientPage({ + browserPageId: 'unrelated', + workspaceId: 'repo-1::/tmp/worktree-a', + browserProfileId: 'profile-a', + executionHostKey: 'old-key', + placement: RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT, + pairedDeviceId: 'device-b', + url: 'https://example.internal/unrelated', + loading: false, + active: false + }) + unrelated.attach() + await h.published('unrelated') + const unrelatedPlacement = h.pages.getPage('unrelated')!.placement + const unrelatedCommands = unrelated.commands.length + const navigating = h.hold('slow') + const first = h.attach() + await navigating + const retirement = h.holdRetirement('slow') + const successorNavigation = successor.hold('slow') + successor.attach() + await retirement.started + h.fence() + await successorNavigation + first.abort() + await first.completion + successor.release('slow') + await h.published('slow') + expect(h.pages.getPage('slow')?.placement.browserHostClientId).toBe('successor-host') + expect(h.pages.getPage('unrelated')?.placement).toEqual(unrelatedPlacement) + expect(unrelated.commands).toHaveLength(unrelatedCommands) + expect(unrelated.executor.snapshotPageInventory()).toHaveLength(1) + retirement.release() + h.release('slow') + await vi.waitFor(() => expect(h.executor.snapshotPageInventory()).toEqual([])) + expect(h.pages.getPage('slow')?.placement.browserHostClientId).toBe('successor-host') + expect(successor.executor.snapshotPageInventory()).toHaveLength(1) + expect(successor.errors).toEqual([]) + expect(unrelated.errors).toEqual([]) + } finally { + h.release('slow') + await h.close() + await successor.close() + await unrelated.close() + } +}) + +async function retirePositiveContinuation(h: ReturnType) { + let current = h.attach() + await h.published('slow') + h.fence() + current.abort() + await current.completion + await h.restartClient() + const started = h.hold('slow') + current = h.attach({ inventory: [] }) + await started + const saved = h.pages.getPage('slow')! + expect(saved.placement.pageHostGeneration).toBeGreaterThan(0) + h.fence() + current.abort() + await current.completion + await h.restartClient() + h.release('slow') + return saved +} + +it('preserves a positive rotated-client continuation through legacy capacity failure and consumes it only on replacement', async () => { + const h = createRecoveryLoop(['slow']) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const saved = await retirePositiveContinuation(h) + const placed = Array.from({ length: 256 }, (_, i) => h.authority.placeServerPage(`full-${i}`)) + const replaced = vi.spyOn(h.pages, 'replaceClientPagePlacement') + const failed = h.attach({ hostId: 'client-b', modern: false, inventory: [] }) + await vi.waitFor(() => + expect(warning).toHaveBeenCalledWith( + '[browser-host-lease] client page recovery failed:', + expect.objectContaining({ browserPageId: 'slow' }) + ) + ) + expect(h.pages.getPage('slow')?.placement).toEqual(saved.placement) + expect( + h.authority.clientPageInitializations.canContinue( + 'slow', + saved.placement, + h.authority.select('client-b') + ) + ).toBe(true) + const last = placed[255] + h.authority.completePageRetirement(h.authority.beginPageRetirement('full-255', last)) + failed.abort() + await failed.completion + const legacy = h.attach({ modern: false, inventory: [] }) + await vi.waitFor(() => + expect(h.pages.getPage('slow')?.placement.browserHostClientId).toBe('client-b') + ) + const legacyPlacement = h.pages.getPage('slow')!.placement + expect(replaced).toHaveBeenCalledTimes(1) + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + expect( + h.authority.claimLegacyClientPageRecovery(h.authority.select('client-b'), { + browserPageId: 'slow', + browserProfileId: 'profile-a', + workspaceId: saved.workspaceId, + expected: saved.placement, + isCurrent: () => false + }) + ).toBeUndefined() + legacy.abort() + await legacy.completion + await h.restartClient() + const modern = h.attach({ inventory: [] }) + await vi.waitFor(() => { + const live = h.authority.getPlacement('slow') + expect(live?.kind === 'client' ? live.pageHostGeneration : undefined).toBeGreaterThan(0) + expect(live).not.toEqual(legacyPlacement) + expect(h.pages.getPage('slow')?.placement).toEqual(live) + }) + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + expect(h.executor.snapshotPageInventory()).toHaveLength(1) + modern.abort() + await modern.completion + } finally { + warning.mockRestore() + await h.close() + } +}) + +it('keeps a timed-out legacy replacement in exact cleanup before retrying its original positive row', async () => { + const h = createRecoveryLoop(['slow']) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const result = Promise.withResolvers() + const issued = Promise.withResolvers() + try { + const saved = await retirePositiveContinuation(h) + const create = h.authority.createClientPage.bind(h.authority) + const bounded = vi + .spyOn(h.authority, 'createClientPage') + .mockImplementation((input) => create({ ...input, timeoutMs: 20 })) + h.hooks.beforeResult = async (event) => { + if (event.command.type === 'createPage' && event.browserPageId === 'slow') { + issued.resolve() + await result.promise + } + } + h.native.renderer.retirePage.mockRejectedValueOnce(new Error('cleanup-reply-lost')) + h.attach({ hostId: 'client-b', modern: false, inventory: [] }) + await issued.promise + await vi.waitFor(() => + expect(h.results).toContainEqual({ id: 'slow', type: 'closePage', status: 'failed' }) + ) + expect(h.pages.getPage('slow')?.placement).toEqual(saved.placement) + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + expect(h.executor.snapshotPageInventory()).toMatchObject([ + { browserPageId: 'slow', state: 'outcomeUnknown' } + ]) + // Ordinary duplicate is still rejected by the actual asynchronous create owner. + await expect( + h.authority.createClientPage({ + browserPageId: 'slow', + browserHostClientId: 'client-b', + pairedDeviceId: 'device-a', + browserProfileId: 'profile-a', + executionHostKey: 'execution-host-a' + }) + ).rejects.toThrow('browser_page_replacement_requires_retirement') + bounded.mockRestore() + h.hooks.beforeResult = undefined + result.resolve() + h.attach({ inventory: h.executor.snapshotPageInventory(), modern: false }) + await vi.waitFor(() => + expect( + h.authority.clientPageInitializations.canContinue( + 'slow', + saved.placement, + h.authority.select('client-b') + ) + ).toBe(true) + ) + h.attach({ inventory: [], modern: false }) + await vi.waitFor(() => + expect(h.pages.getPage('slow')?.placement.browserHostClientId).toBe('client-b') + ) + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + expect(h.executor.snapshotPageInventory()).toHaveLength(1) + } finally { + result.resolve() + warning.mockRestore() + await h.close() + } +}) + +it.each(['close', 'newer'] as const)( + 'rejects a late legacy result after %s wins the logical row', + async (winner) => { + const h = createRecoveryLoop(['slow']) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const result = Promise.withResolvers() + const issued = Promise.withResolvers() + try { + const saved = await retirePositiveContinuation(h) + const createsBefore = h.results.filter( + (r) => r.id === 'slow' && r.type === 'createPage' + ).length + h.hooks.beforeResult = async (event) => { + if (event.command.type === 'createPage') { + issued.resolve() + } + await result.promise + } + h.attach({ hostId: 'client-b', modern: false, inventory: [] }) + await issued.promise + const newerPlacement = { + ...saved.placement, + browserHostClientId: 'newer-client', + pageHostGeneration: saved.placement.pageHostGeneration + 1 + } + if (winner === 'close') { + await retireRuntimeBrowserClientPage( + { + getBrowserHostLeaseRegistry: () => h.authority, + getRuntimeBrowserPageRegistry: () => h.pages, + resolveBrowserWorkspace: async () => ({ id: saved.workspaceId }), + notifyHeadlessBrowserSessionTabsChanged: (id) => + h.hostRuntime.notifyMobileSessionTabsChanged(id) + }, + h.pages.getPage('slow')! + ) + expect(h.pages.getPage('slow')).toBeUndefined() + await vi.waitFor(() => expect(h.executor.snapshotPageInventory()).toEqual([])) + } else { + h.pages.replaceClientPagePlacement('slow', saved.placement, newerPlacement, 'newer-key') + h.pages.updatePage('slow', newerPlacement, { url: 'https://example.internal/new-owner' }) + } + expect(() => h.authority.clientPageInitializations.assertCreationAdmission('slow')).toThrow( + 'browser_page_replacement_requires_retirement' + ) + h.hooks.beforeResult = undefined + result.resolve() + await vi.waitFor(() => + expect(h.results).toContainEqual({ id: 'slow', type: 'closePage', status: 'completed' }) + ) + await vi.waitFor(() => + expect(() => + h.authority.clientPageInitializations.assertCreationAdmission('slow') + ).not.toThrow() + ) + await vi.waitFor(() => + expect(h.results.filter((r) => r.id === 'slow' && r.type === 'createPage')).toHaveLength( + createsBefore + 1 + ) + ) + if (winner === 'newer') { + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/new-owner') + expect(h.pages.getPage('slow')?.placement).toEqual(newerPlacement) + } else { + expect(h.pages.getPage('slow')).toBeUndefined() + } + expect(h.executor.snapshotPageInventory()).toEqual([]) + } finally { + result.resolve() + warning.mockRestore() + await h.close() + } + } +) + +it('serves a newer attach once after an older held cleanup fails and publishes its successor without another attach', async () => { + const h = createRecoveryLoop(['slow']) + const replaced = vi.spyOn(h.pages, 'replaceClientPagePlacement') + try { + await h.seedNative(['slow'], 'profile-a', true) + const held = h.holdRetirement('slow') + h.native.renderer.retirePage.mockRejectedValueOnce(new Error('old-attach-reply-lost')) + const first = h.attach() + await held.started + const second = h.attach() + held.release() + await h.published('slow') + expect(h.results.filter((r) => r.id === 'slow' && r.type === 'closePage')).toEqual( + expect.arrayContaining([ + { id: 'slow', type: 'closePage', status: 'failed' }, + { id: 'slow', type: 'closePage', status: 'completed' } + ]) + ) + expect( + new Set( + h.commands + .filter((c) => c.browserPageId === 'slow' && c.command.type === 'closePage') + .map((c) => c.commandId) + ).size + ).toBe(2) + expect(h.native.renderer.retirePage).toHaveBeenCalledTimes(2) + expect( + h.commands.filter((c) => c.browserPageId === 'slow' && c.command.type === 'createPage') + ).toHaveLength(1) + expect(replaced).toHaveBeenCalledTimes(1) + expect(h.pages.getPage('slow')?.url).toBe('https://example.internal/slow') + expect(h.errors).toEqual([]) + first.abort() + second.abort() + await Promise.all([first.completion, second.completion]) + } finally { + await h.close() + } +}) + +it('unlinks a closed terminal page from the surviving scope before old authority retirement publishes completion', async () => { + const h = createRecoveryLoop(['slow']) + const successor = createRecoveryPeer(h.hostRuntime, 'successor-host') + try { + const started = h.hold('slow') + const first = h.attach() + await started + const retirement = h.holdRetirement('slow') + successor.attach() + await retirement.started + await retireRuntimeBrowserClientPage( + { + getBrowserHostLeaseRegistry: () => h.authority, + getRuntimeBrowserPageRegistry: () => h.pages, + notifyHeadlessBrowserSessionTabsChanged: (id) => + h.hostRuntime.notifyMobileSessionTabsChanged(id), + resolveBrowserWorkspace: async () => ({ id: 'repo-1::/tmp/worktree-a' }) + }, + h.pages.getPage('slow')! + ) + expect(h.pages.getPage('slow')).toBeUndefined() + h.fence() + first.abort() + await first.completion + await vi.waitFor(() => expect(h.snapshots.some((s) => s.reconciled)).toBe(true)) + expect( + h.hostRuntime.snapshotForClient('repo-1::/tmp/worktree-a', 'device-a') + .clientHostedPagesUnreconciled + ).toBeUndefined() + retirement.release() + h.release('slow') + await vi.waitFor(() => expect(h.executor.snapshotPageInventory()).toEqual([])) + expect(successor.executor.snapshotPageInventory()).toEqual([]) + expect(h.pages.getPage('slow')).toBeUndefined() + } finally { + await successor.close() + await h.close() + } +}) diff --git a/src/main/runtime/browser/client-pages/runtime-browser-page-initialization.test.ts b/src/main/runtime/browser/client-pages/runtime-browser-page-initialization.test.ts new file mode 100644 index 00000000000..05550a91eff --- /dev/null +++ b/src/main/runtime/browser/client-pages/runtime-browser-page-initialization.test.ts @@ -0,0 +1,244 @@ +import { expect, it, vi } from 'vitest' +import { + BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY, + type BrowserClientHostCommandEvent, + type BrowserClientHostedPageInventory +} from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import { BrowserHostLeaseRegistry } from '../../browser-host/leases/browser-host-lease-registry' +import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry' +import { + RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT, + buildPersistedClientHostedBrowserPages +} from './client-hosted-browser-page-persistence' +import { recoverUnavailableRuntimeBrowserClientPages } from './runtime-browser-client-page-recovery' +import { adoptRuntimeBrowserClientPagesFromInventory } from './runtime-browser-client-page-adoption' +import { retireRuntimeBrowserClientPage } from './browser-client-page-close' + +function rig(ids: string[], inventory: BrowserClientHostedPageInventory[] = [], modern = true) { + const authority = new BrowserHostLeaseRegistry({ + authorityRuntimeId: 'runtime-new', + authorityEpoch: 'epoch-new' + }) + const host = authority.attach({ + browserHostClientId: 'host', + pairedDeviceId: 'device', + connectionId: 'connection', + hostCapabilities: [ + 'webview', + 'automation', + ...(modern ? [BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY] : []) + ], + pageCommandProtocolVersion: 1, + pageInventoryProtocolVersion: 1, + pageReconciliationProtocolVersion: 1, + pageInventory: inventory + }) + const pages = new RuntimeBrowserPageRegistry() + for (const id of ids) { + pages.publishClientPage({ + browserPageId: id, + workspaceId: 'workspace', + browserProfileId: 'default', + executionHostKey: 'old-key', + placement: RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT, + pairedDeviceId: 'device', + url: `https://example.test/${id}`, + loading: false, + active: false + }) + } + const published = new Map(ids.map((id) => [id, Promise.withResolvers()])) + const navigating = new Map(ids.map((id) => [id, Promise.withResolvers()])) + const held = new Map() + const commands: BrowserClientHostCommandEvent[] = [] + const settle = ( + event: BrowserClientHostCommandEvent, + result: { status: 'completed'; value?: unknown } | { status: 'failed'; errorCode: string } + ) => { + authority.settleClientPageCommand({ ...host.lease }, { ...event, result }) + } + authority.attachCommandDelivery(host.lease, (event) => { + commands.push(event) + queueMicrotask(() => { + if (event.command.type === 'navigate' && event.browserPageId.startsWith('slow')) { + held.set(event.browserPageId, event) + navigating.get(event.browserPageId)?.resolve() + } else { + if (event.command.type === 'closePage') { + const pending = held.get(event.browserPageId) + if (pending) { + settle(pending, { status: 'failed', errorCode: 'cancelled' }) + held.delete(event.browserPageId) + } + } + settle(event, { + status: 'completed', + ...(event.command.type === 'navigate' ? { value: event.command.url } : {}) + }) + } + }) + }) + const notifyWorkspace = vi.fn(() => { + for (const [id, done] of published) { + if (pages.getPage(id)?.placement.pageHostGeneration) { + done.resolve() + } + } + }) + const options = { + authority, + lease: host.lease, + pages, + notifyWorkspace, + resolveExecutionHostKey: async () => ({ + status: 'resolved' as const, + executionHostKey: 'new-key' + }) + } + return { + ...options, + host, + commands, + published, + navigating, + held, + settle, + options, + release(id: string, url = `https://example.test/${id}`) { + const event = held.get(id)! + held.delete(id) + settle(event, { status: 'completed', value: url }) + } + } +} + +it('sibling publication and persistence retain a held saved URL until its own redirect commits', async () => { + const h = rig(['slow', 'fast']) + await recoverUnavailableRuntimeBrowserClientPages(h.options) + h.authority.sealClientPageInitializations(h.host.lease, false) + await h.navigating.get('slow')!.promise + await h.published.get('fast')!.promise + expect(h.pages.getPage('slow')).toMatchObject({ + placement: { pageHostGeneration: 0 }, + url: 'https://example.test/slow' + }) + expect(buildPersistedClientHostedBrowserPages(h.pages.listPages(), 100)).toContainEqual({ + v: 1, + browserPageId: 'slow', + workspaceId: 'workspace', + browserProfileId: 'default', + pairedDeviceId: 'device', + url: 'https://example.test/slow', + title: 'Browser', + savedAt: 100 + }) + h.release('slow', 'https://example.test/final') + await h.published.get('slow')!.promise + expect(h.pages.getPage('slow')?.url).toBe('https://example.test/final') + expect(h.authority.getClientPageExecutionHostKey('slow')).toBe('new-key') + h.host.release() +}) + +it('a fast imported row and plain recovery publish independently of held adoption with an omitted URL', async () => { + const inventory = ['slow-adopted', 'fast-adopted'].map( + (id, index): BrowserClientHostedPageInventory => ({ + authorityRuntimeId: 'runtime-old', + authorityEpoch: 'epoch-old', + browserHostClientId: 'host', + browserHostGeneration: 1, + browserPageId: id, + pageHostGeneration: index + 1, + workspaceId: 'workspace', + browserProfileId: 'default', + executionHostKey: 'old-key', + state: 'outcomeUnknown' + }) + ) + const h = rig(['slow-adopted', 'fast-adopted', 'recovery'], inventory) + const adoption = await adoptRuntimeBrowserClientPagesFromInventory(h.options) + await recoverUnavailableRuntimeBrowserClientPages({ + ...h.options, + adoptedPageIds: new Set(adoption.adoptedPageIds) + }) + h.authority.sealClientPageInitializations(h.host.lease, false) + await h.navigating.get('slow-adopted')!.promise + await h.published.get('fast-adopted')!.promise + await h.published.get('recovery')!.promise + expect(adoption.unadoptedPageIds).toEqual(['slow-adopted', 'fast-adopted']) + expect(h.pages.getPage('slow-adopted')?.url).toBe('https://example.test/slow-adopted') + h.release('slow-adopted') + await h.published.get('slow-adopted')!.promise + expect(h.authority.getClientPageExecutionHostKey('fast-adopted')).toBe('new-key') + h.host.release() +}) + +it('four held initializations keep the next queued and closing one joins cleanup before admitting it', async () => { + const h = rig(['slow1', 'slow2', 'slow3', 'slow4', 'queued']) + await recoverUnavailableRuntimeBrowserClientPages(h.options) + h.authority.sealClientPageInitializations(h.host.lease, false) + await Promise.all(['slow1', 'slow2', 'slow3', 'slow4'].map((id) => h.navigating.get(id)!.promise)) + expect(h.commands.some((event) => event.browserPageId === 'queued')).toBe(false) + const host = { + resolveBrowserWorkspace: async () => ({ id: 'workspace-a' }), + getBrowserHostLeaseRegistry: () => h.authority, + getRuntimeBrowserPageRegistry: () => h.pages, + notifyHeadlessBrowserSessionTabsChanged: h.notifyWorkspace + } + await retireRuntimeBrowserClientPage(host, h.pages.getPage('slow1')!) + expect(h.pages.getPage('slow1')).toBeUndefined() + await h.published.get('queued')!.promise + expect(h.authority.getPlacement('slow1')).toBeUndefined() + expect(h.authority.getClientPageExecutionHostKey('slow1')).toBeUndefined() + for (const id of ['slow2', 'slow3', 'slow4']) { + h.release(id) + } + await Promise.all(['slow2', 'slow3', 'slow4'].map((id) => h.published.get(id)!.promise)) + h.host.release() +}) + +it('sanitizes imported and committed canonical addresses before publishing or persisting them', async () => { + const raw = 'https://kagi.com/search?q=proof&token=synthetic-pr41490' + const h = rig( + [], + [ + { + authorityRuntimeId: 'runtime-old', + authorityEpoch: 'epoch-old', + browserHostClientId: 'host', + browserHostGeneration: 1, + browserPageId: 'slow-import', + pageHostGeneration: 1, + workspaceId: 'workspace', + browserProfileId: 'default', + executionHostKey: 'old-key', + currentUrl: raw, + state: 'active' + } + ] + ) + const observed: string[] = [] + h.notifyWorkspace.mockImplementation(() => { + observed.push(...h.pages.listPages().map((page) => page.url)) + }) + try { + await adoptRuntimeBrowserClientPagesFromInventory(h.options) + h.authority.sealClientPageInitializations(h.host.lease, false) + await vi.waitFor(() => expect(h.held.has('slow-import')).toBe(true)) + expect(h.pages.getPage('slow-import')?.url).toBe('https://kagi.com/search?q=proof') + expect(h.commands.find((event) => event.command.type === 'navigate')?.command).toEqual({ + type: 'navigate', + url: raw + }) + h.release('slow-import', raw) + await vi.waitFor(() => + expect(h.pages.getPage('slow-import')?.placement.pageHostGeneration).toBeGreaterThan(0) + ) + expect(buildPersistedClientHostedBrowserPages(h.pages.listPages(), 100)[0].url).toBe( + 'https://kagi.com/search?q=proof' + ) + expect(observed.length).toBeGreaterThan(0) + expect(observed.every((url) => !url.includes('synthetic-pr41490'))).toBe(true) + } finally { + h.host.release() + } +}) diff --git a/src/main/runtime/browser/client-pages/runtime-browser-page-initialization.ts b/src/main/runtime/browser/client-pages/runtime-browser-page-initialization.ts new file mode 100644 index 00000000000..fdc912f2865 --- /dev/null +++ b/src/main/runtime/browser/client-pages/runtime-browser-page-initialization.ts @@ -0,0 +1,101 @@ +import { + normalizeBrowserNavigationUrl, + redactKagiSessionToken +} from '../../../../shared/browser-url' +import type { + InitializationOutcome, + InitializationDisposition +} from '../../browser-host/client-pages/browser-host-page-initialization-records' +import { sameRuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement' +import type { BrowserHostLeaseRegistry } from '../../browser-host/leases/browser-host-lease-registry' +import type { BrowserHostLeaseIdentity } from '../../browser-host/leases/browser-host-lease-records' +import type { BrowserClientHostedPageInventory } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import type { + RuntimeBrowserClientPage, + RuntimeBrowserPageRegistry +} from './runtime-browser-page-registry' +import type { BrowserExecutionHostKeyResolution } from './runtime-browser-client-page-adoption' + +export function admitRuntimeBrowserPageInitialization(input: { + authority: Pick + lease: BrowserHostLeaseIdentity + pages: RuntimeBrowserPageRegistry + page: RuntimeBrowserClientPage + previous?: BrowserClientHostedPageInventory + pageHostGeneration?: number + url: string + replace?: boolean + creationTimeoutMs?: number + closeTimeoutMs?: number + claim?(): void + resolveExecutionHostKey(workspaceId: string): Promise + notifyWorkspace(workspaceId: string): void + releaseUnrecoverablePage?(page: RuntimeBrowserClientPage): void + settled?(outcome: InitializationOutcome): void + signal?: AbortSignal + completed?(url?: string): void +}): InitializationDisposition { + const { page } = input + const isCurrent = (): boolean => { + const current = input.pages.getPage(page.browserPageId) + return ( + (!current && input.claim !== undefined) || + Boolean( + current && + current.workspaceId === page.workspaceId && + current.browserProfileId === page.browserProfileId && + sameRuntimeBrowserPlacement(current.placement, page.placement) + ) + ) + } + return input.authority.admitClientPageInitialization(input.lease, { + browserPageId: page.browserPageId, + browserProfileId: page.browserProfileId, + workspaceId: page.workspaceId, + expected: page.placement, + url: input.url, + replace: input.replace, + changed: () => input.notifyWorkspace(page.workspaceId), + ...(input.previous ? { previous: input.previous } : {}), + ...(input.pageHostGeneration === undefined + ? {} + : { pageHostGeneration: input.pageHostGeneration }), + signal: input.signal, + claim: input.claim, + closeTimeoutMs: input.closeTimeoutMs, + creationTimeoutMs: input.creationTimeoutMs, + isCurrent, + resolveExecutionHostKey: async () => { + const resolved = await input.resolveExecutionHostKey(page.workspaceId) + if (resolved.status === 'resolved') { + return resolved.executionHostKey + } + if (resolved.status === 'workspace-gone' && isCurrent()) { + input.releaseUnrecoverablePage?.(page) + } + return null + }, + publish: (placement, url, executionHostKey) => { + if (!isCurrent()) { + throw new Error('browser_page_placement_stale') + } + input.pages.replaceClientPagePlacement( + page.browserPageId, + page.placement, + placement, + executionHostKey + ) + input.pages.updatePage(page.browserPageId, placement, { + url: redactKagiSessionToken(normalizeBrowserNavigationUrl(url) ?? url), + loading: false + }) + input.notifyWorkspace(page.workspaceId) + const published = input.pages.getPage(page.browserPageId) + if (!published || !sameRuntimeBrowserPlacement(published.placement, placement)) { + throw new Error('browser_page_placement_stale') + } + }, + completed: input.completed, + settled: input.settled + }) +} diff --git a/src/main/runtime/browser/client-pages/runtime-browser-page-registry.ts b/src/main/runtime/browser/client-pages/runtime-browser-page-registry.ts index a7fb5cb373d..f6b6386ca52 100644 --- a/src/main/runtime/browser/client-pages/runtime-browser-page-registry.ts +++ b/src/main/runtime/browser/client-pages/runtime-browser-page-registry.ts @@ -54,6 +54,7 @@ export class RuntimeBrowserPageRegistry { private readonly pages = new Map() private globalActivePageId: string | null = null private readonly maxPages: number + private onRetired?: (page: RuntimeBrowserClientPage) => void constructor(options: { maxPages?: number } = {}) { this.maxPages = options.maxPages ?? DEFAULT_MAX_RUNTIME_BROWSER_PAGES @@ -62,6 +63,10 @@ export class RuntimeBrowserPageRegistry { } } + setRetirementListener(listener: (page: RuntimeBrowserClientPage) => void): void { + this.onRetired = listener + } + publishClientPage(input: RuntimeBrowserClientPageInput): RuntimeBrowserClientPage { assertIdentity(input.browserPageId) assertIdentity(input.workspaceId) @@ -145,6 +150,7 @@ export class RuntimeBrowserPageRegistry { return false } this.pages.delete(browserPageId) + this.onRetired?.(current) if (this.globalActivePageId === browserPageId) { this.globalActivePageId = null } diff --git a/src/main/runtime/browser/client-pages/runtime-browser-workspace-routing.ts b/src/main/runtime/browser/client-pages/runtime-browser-workspace-routing.ts index e3d2dd00ad8..e0c0d6ec801 100644 --- a/src/main/runtime/browser/client-pages/runtime-browser-workspace-routing.ts +++ b/src/main/runtime/browser/client-pages/runtime-browser-workspace-routing.ts @@ -1,3 +1,9 @@ +import { + actOnRuntimeBrowserInitialization, + captureRuntimeBrowserInitializationAction +} from './runtime-browser-page-initialization-action' +import type { BrowserPageInitializationActionParams } from '../../../../shared/browser/client-hosting/browser-page-initialization-action' +import type { z } from 'zod' import type { ResolvedWorktree } from '../../workspaces/catalog/runtime-worktree-path-identity' import type { BrowserExecutionHostKeyResolution } from './runtime-browser-client-page-adoption' import '../../../browser/network/execution-routes/browser-network-execution-route' @@ -23,6 +29,35 @@ import type { RpcContext } from '../../rpc/core' import { OrcaRuntimeWithTransitionGraphReloadToTerminalState } from '../../session-graph/session-graph-authority' export abstract class OrcaRuntimeWithBrowserWorkspaceRouting extends OrcaRuntimeWithTransitionGraphReloadToTerminalState { + async actOnClientPageInitialization( + params: z.infer + ): Promise { + const pages = getRuntimeBrowserPageRegistry(this) + const authority = getBrowserHostLeaseRegistry(this) + const current = captureRuntimeBrowserInitializationAction( + pages, + authority, + params.page, + params.expectedPlacement + ) + const workspace = await this.resolveBrowserWorkspace(params.worktree) + const page = current() + if (page.workspaceId !== workspace.id) { + throw new Error('browser_page_workspace_mismatch') + } + const completion = actOnRuntimeBrowserInitialization({ + pages, + authority, + page, + action: params.action, + explicitUrl: true, + resolveExecutionHostKey: (id) => this.resolveBrowserExecutionHostKeyForWorkspace(id), + notifyWorkspace: (id) => this.notifyMobileSessionTabsChanged(id) + }) + // This UI action acknowledges accepted intent; the command owns load completion. + void completion.catch(() => {}) + } + /** Resolve the current host key; native/WSL runtime ids and SSH provider epochs expire on restart. Missing workspaces are gone; reconnect/repair is temporary and never authorizes page retirement. */ async resolveBrowserExecutionHostKeyForWorkspace( workspaceId: string @@ -56,6 +91,8 @@ export abstract class OrcaRuntimeWithBrowserWorkspaceRouting extends OrcaRuntime caller, pages: getRuntimeBrowserPageRegistry(this), leases: getBrowserHostLeaseRegistry(this), + resolveExecutionHostKey: (id) => this.resolveBrowserExecutionHostKeyForWorkspace(id), + notifyWorkspace: (id) => this.notifyMobileSessionTabsChanged(id), resolveWorkspace: (selector) => resolveRemoteCliBrowserScopeSelector(selector) ?? this.resolveBrowserWorkspace(selector) }) diff --git a/src/main/runtime/browser/tabs/browser-tab-creation.ts b/src/main/runtime/browser/tabs/browser-tab-creation.ts index 59bef7af39e..b04627cf2fc 100644 --- a/src/main/runtime/browser/tabs/browser-tab-creation.ts +++ b/src/main/runtime/browser/tabs/browser-tab-creation.ts @@ -37,6 +37,7 @@ type BrowserTabCreateCaller = { pairedDeviceId?: string clientKind?: 'mobile' | 'runtime' opensPageLink?: boolean + signal?: AbortSignal } export class RuntimeBrowserTabCreation { @@ -121,7 +122,8 @@ export class RuntimeBrowserTabCreation { this.host, params, params.profileId ?? browserSessionRegistry.getDefaultProfile().id, - offscreen ? createOffscreen : undefined + offscreen ? createOffscreen : undefined, + caller?.signal ) } return createOffscreen() @@ -217,10 +219,13 @@ export class RuntimeBrowserTabCreation { return { browserPageId: created.browserPageId } } - async browserOpenUrlOnClient(params: { - url: string - worktree: string - }): Promise<{ browserPageId: string }> { - return openBrowserUrlOnClient(this.host, params) + async browserOpenUrlOnClient( + params: { + url: string + worktree: string + }, + options?: { signal?: AbortSignal } + ): Promise<{ browserPageId: string }> { + return openBrowserUrlOnClient(this.host, params, options?.signal) } } diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts index f313e1e2c62..b9e37d05b30 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts @@ -17,7 +17,7 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session/wo import { getWorkspaceSessionTabActivityIds } from '../../../shared/workspace-session/workspace-session-tab-activity' import { worktreeIdentityKey } from '../../../shared/worktree/id' import { SERVER_FETCH_TIMEOUT_MS } from './server-worktree-fetch' -import { normalizePairedServerRow } from '../../../shared/sidebar-list/paired-server-rows' +import { normalizePairedServerRow } from '../../../shared/sidebar-list/feed-facts/paired-server-rows' import type { MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts' import { describeRelayHosts, relayHostFence } from './mobile-relay-host-descriptions' import { diff --git a/src/main/runtime/rpc/methods/browser/browser-client-host-command-result-identity.ts b/src/main/runtime/rpc/methods/browser/browser-client-host-command-result-identity.ts new file mode 100644 index 00000000000..80fc23d0889 --- /dev/null +++ b/src/main/runtime/rpc/methods/browser/browser-client-host-command-result-identity.ts @@ -0,0 +1,38 @@ +import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY } from '../../../../../shared/browser/runtime/browser-runtime-capabilities' +import { BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE } from '../../../../../shared/browser/client-hosting/browser-client-host-protocol' +import { BrowserError } from '../../../../browser/browser-error' +import type { RpcContext } from '../../core' + +export function requireCommandResultIdentity( + params: { + authorityRuntimeId: string + authorityEpoch: string + browserHostClientId: string + browserHostGeneration: number + }, + context: Pick< + RpcContext, + 'runtime' | 'pairedDeviceId' | 'connectionId' | 'clientKind' | 'clientCapabilities' + > +) { + const { runtime, pairedDeviceId, connectionId, clientKind, clientCapabilities } = context + if (clientKind !== 'runtime' || !pairedDeviceId || !connectionId) { + throw new Error('authenticated_browser_client_host_required') + } + if (!clientCapabilities?.includes(BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY)) { + throw new Error('browser_client_host_capability_required') + } + if (params.authorityRuntimeId !== runtime.getRuntimeId()) { + throw new BrowserError( + BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE, + BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE + ) + } + return { + authorityEpoch: params.authorityEpoch, + browserHostClientId: params.browserHostClientId, + browserHostGeneration: params.browserHostGeneration, + pairedDeviceId, + connectionId + } +} diff --git a/src/main/runtime/rpc/methods/browser/browser-client-host.ts b/src/main/runtime/rpc/methods/browser/browser-client-host.ts index 7d5d3d9d409..741503b483f 100644 --- a/src/main/runtime/rpc/methods/browser/browser-client-host.ts +++ b/src/main/runtime/rpc/methods/browser/browser-client-host.ts @@ -1,3 +1,5 @@ +import { requireCommandResultIdentity } from './browser-client-host-command-result-identity' +import { BROWSER_PAGE_INITIALIZATION_ACTION_METHODS } from './browser-page-initialization-action' import '../../../../../shared/protocol-version' import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, @@ -10,6 +12,7 @@ import { } from '../../../../../shared/browser/client-hosting/browser-client-host-request-protocol' import { BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE, + BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY, BrowserClientHostAttachParams, BrowserClientHostCommandResultChunkParams, BrowserClientHostCommandResultParams @@ -22,9 +25,10 @@ import { adoptRuntimeBrowserClientPagesFromInventory } from '../../../browser/cl import { recoverUnavailableRuntimeBrowserClientPages } from '../../../browser/client-pages/runtime-browser-client-page-recovery' import { releaseRuntimeBrowserClientPageRecord } from '../../../browser/client-pages/runtime-browser-client-page-release' import { getBrowserHostRequestRegistry } from '../../../browser-host/leases/browser-host-request-registry' -import { defineMethod, defineStreamingMethod, type RpcContext } from '../../core' +import { defineMethod, defineStreamingMethod } from '../../core' export const BROWSER_CLIENT_HOST_METHODS = [ + ...BROWSER_PAGE_INITIALIZATION_ACTION_METHODS, defineStreamingMethod({ name: 'browser.clientHost.attach', permission: 'workspace', @@ -54,18 +58,21 @@ export const BROWSER_CLIENT_HOST_METHODS = [ .listPages() .map((page) => [page.browserPageId, page.placement]) ) - const handle = registry.attach({ - browserHostClientId: params.browserHostClientId, - connectionId, - pairedDeviceId, - hostCapabilities: params.hostCapabilities, - pageCommandProtocolVersion: params.pageCommandProtocolVersion, - pageInventoryProtocolVersion: params.pageInventoryProtocolVersion, - pageInventory: params.pageInventory, - pageReconciliationProtocolVersion: params.pageReconciliationProtocolVersion, - leaseReconnectProtocolVersion: params.leaseReconnectProtocolVersion, - fileChannelProtocolVersion: params.fileChannelProtocolVersion - }) + const handle = registry.attach( + { + browserHostClientId: params.browserHostClientId, + connectionId, + pairedDeviceId, + hostCapabilities: params.hostCapabilities, + pageCommandProtocolVersion: params.pageCommandProtocolVersion, + pageInventoryProtocolVersion: params.pageInventoryProtocolVersion, + pageInventory: params.pageInventory, + pageReconciliationProtocolVersion: params.pageReconciliationProtocolVersion, + leaseReconnectProtocolVersion: params.leaseReconnectProtocolVersion, + fileChannelProtocolVersion: params.fileChannelProtocolVersion + }, + () => runtime.markClientHostedPagesReconciled(pairedDeviceId) + ) let releaseCommandDelivery = (): void => {} let resolveDisconnected = (): void => {} const whenDisconnected = new Promise((resolve) => { @@ -82,6 +89,10 @@ export const BROWSER_CLIENT_HOST_METHODS = [ resolveDisconnected() } const subscriptionId = `browser-client-host:${params.browserHostClientId}` + const initializationLifetime = new AbortController() + const initializationSignal = signal + ? AbortSignal.any([signal, initializationLifetime.signal]) + : initializationLifetime.signal try { runtime.registerSubscriptionCleanup(subscriptionId, cleanup, connectionId) signal?.addEventListener('abort', cleanup, { once: true }) @@ -128,11 +139,16 @@ export const BROWSER_CLIENT_HOST_METHODS = [ notifyWorkspace: (workspaceId) => runtime.notifyMobileSessionTabsChanged(workspaceId), resolveExecutionHostKey: (workspaceId) => runtime.resolveBrowserExecutionHostKeyForWorkspace(workspaceId), - ...(signal ? { signal } : {}) + signal: initializationSignal }) - if (adoption.unadoptedPageIds.length === 0) { - // Clear reconciliation hold only after every live guest has a record; otherwise retain it until later attach or window deadline. - runtime.markClientHostedPagesReconciled(pairedDeviceId) + const modern = handle.lease.hostCapabilities?.includes( + BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY + ) + if (!modern) { + registry.sealClientPageInitializations( + handle.lease, + adoption.unadoptedPageIds.length !== 0 + ) } await recoverUnavailableRuntimeBrowserClientPages({ lease: handle.lease, @@ -145,8 +161,16 @@ export const BROWSER_CLIENT_HOST_METHODS = [ resolveExecutionHostKey: (workspaceId) => runtime.resolveBrowserExecutionHostKeyForWorkspace(workspaceId), adoptedPageIds: new Set(adoption.adoptedPageIds), - ...(signal ? { signal } : {}) + claimedPageIds: new Set(adoption.claimedPageIds), + signal: initializationSignal }) + if (modern) { + const claimed = new Set(adoption.claimedPageIds) + registry.sealClientPageInitializations( + handle.lease, + adoption.unadoptedPageIds.some((id) => !claimed.has(id)) + ) + } const reason = await Promise.race([ handle.whenFenced, whenDisconnected.then(() => undefined), @@ -162,6 +186,7 @@ export const BROWSER_CLIENT_HOST_METHODS = [ reason: reason === 'replaced' ? 'replaced' : 'released' }) } finally { + initializationLifetime.abort() signal?.removeEventListener('abort', cleanup) cleanup() } @@ -248,37 +273,3 @@ export const BROWSER_CLIENT_HOST_METHODS = [ } }) ] - -function requireCommandResultIdentity( - params: { - authorityRuntimeId: string - authorityEpoch: string - browserHostClientId: string - browserHostGeneration: number - }, - context: Pick< - RpcContext, - 'runtime' | 'pairedDeviceId' | 'connectionId' | 'clientKind' | 'clientCapabilities' - > -) { - const { runtime, pairedDeviceId, connectionId, clientKind, clientCapabilities } = context - if (clientKind !== 'runtime' || !pairedDeviceId || !connectionId) { - throw new Error('authenticated_browser_client_host_required') - } - if (!clientCapabilities?.includes(BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY)) { - throw new Error('browser_client_host_capability_required') - } - if (params.authorityRuntimeId !== runtime.getRuntimeId()) { - throw new BrowserError( - BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE, - BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE - ) - } - return { - authorityEpoch: params.authorityEpoch, - browserHostClientId: params.browserHostClientId, - browserHostGeneration: params.browserHostGeneration, - pairedDeviceId, - connectionId - } -} diff --git a/src/main/runtime/rpc/methods/browser/browser-core.ts b/src/main/runtime/rpc/methods/browser/browser-core.ts index e76b9e2efeb..6bdc909d480 100644 --- a/src/main/runtime/rpc/methods/browser/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser/browser-core.ts @@ -134,16 +134,17 @@ export const BROWSER_CORE_METHODS = [ name: 'browser.tabCreate', permission: 'workspace', params: BrowserTabCreateParams, - handler: async (params, { runtime, pairedDeviceId, clientKind }) => + handler: async (params, { runtime, pairedDeviceId, clientKind, signal }) => pairedDeviceId - ? runtime.browserTabCreate(params, { pairedDeviceId, clientKind }) - : runtime.browserTabCreate(params, { clientKind }) + ? runtime.browserTabCreate(params, { pairedDeviceId, clientKind, signal }) + : runtime.browserTabCreate(params, { clientKind, signal }) }), defineMethod({ name: 'browser.openUrl', permission: 'workspace', params: BrowserOpenUrlParams, - handler: async (params, { runtime }) => runtime.browserOpenUrlOnClient(params) + handler: async (params, { runtime, signal }) => + runtime.browserOpenUrlOnClient(params, { signal }) }), defineMethod({ name: 'browser.tabClose', diff --git a/src/main/runtime/rpc/methods/browser/browser-page-initialization-action.ts b/src/main/runtime/rpc/methods/browser/browser-page-initialization-action.ts new file mode 100644 index 00000000000..8351f53e8c1 --- /dev/null +++ b/src/main/runtime/rpc/methods/browser/browser-page-initialization-action.ts @@ -0,0 +1,24 @@ +import { BrowserPageInitializationActionParams } from '../../../../../shared/browser/client-hosting/browser-page-initialization-action' +import { projectSessionTabsForClient } from '../session-tabs/session-tabs-client-projection' +import { defineMethod } from '../../core' + +export const BROWSER_PAGE_INITIALIZATION_ACTION_METHODS = [ + defineMethod({ + name: 'browser.clientPage.initializationAction', + permission: 'workspace', + params: BrowserPageInitializationActionParams, + handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => { + await runtime.actOnClientPageInitialization(params) + const snapshot = projectSessionTabsForClient( + await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId, { + includeInitialSurfaceLaunch: true, + includeAgentSenders: clientKind !== 'mobile' + }), + clientKind, + clientCapabilities + ) + runtime.observeClientHostedPageSnapshots?.([snapshot], pairedDeviceId) + return snapshot + } + }) +] diff --git a/src/main/runtime/rpc/methods/sidebar-inputs/sidebar-inputs.ts b/src/main/runtime/rpc/methods/sidebar-inputs/sidebar-inputs.ts index d7692fe1e51..5977d1a3905 100644 --- a/src/main/runtime/rpc/methods/sidebar-inputs/sidebar-inputs.ts +++ b/src/main/runtime/rpc/methods/sidebar-inputs/sidebar-inputs.ts @@ -2,9 +2,9 @@ import { defineMethod } from '../../core' import { requireSwitchedOn } from '../../../construction/require-sidebar-inputs-enabled' import { readRuntimeDesktopSidebarFacts } from '../../../sidebar-inputs/sidebar-inputs-feed-domains' import { isHostOwnPhone } from '../../callers/host-own-phone' -import { SidebarInputsReadParams } from '../../../../../shared/sidebar-list/sidebar-inputs-feed-contract' +import { SidebarInputsReadParams } from '../../../../../shared/sidebar-list/feed/sidebar-inputs-feed-contract' import { SidebarSessionBeginParams } from '../../../../../shared/rpc-contract/client-ui/sidebar-session-params' -import { RepoIconDataUrlsParams } from '../../../../../shared/sidebar-list/repo-icon-data-urls-contract' +import { RepoIconDataUrlsParams } from '../../../../../shared/sidebar-list/catalog/repo-icon-data-urls-contract' import { SetCollapsedParams } from '../../../../../shared/rpc-contract/workspaces/workspace-view-collapse-params' import { setCollapsedSection } from '../../../client/settings/workspace-view-collapse-writes' import { ClientWorkspaceViewParams } from '../../../../../shared/rpc-contract/client-ui/client-workspace-view-params' diff --git a/src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts index 5be62aad56f..0b7ee67a137 100644 --- a/src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts @@ -1,3 +1,5 @@ +import { buildRuntimeClientBrowserTabs } from './build-runtime-client-browser-tabs' +import { getBrowserHostLeaseRegistry } from '../../browser-host/leases/browser-host-lease-registry-instance' import type { OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab as BrowserOwnership } from '../../session-tabs/mobile-session-publication-preservation' import { OrcaRuntimeWithPersistTerminalSurfaceRetirements } from '../../session-tabs/terminal-surface-retirements' import type { @@ -76,23 +78,10 @@ export abstract class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs exten isActive: tab.active === true } }) - const publishedClientTabs = getRuntimeBrowserPageRegistry(this) - .listPages(worktreeId) - .map((page) => ({ - type: 'browser' as const, - id: page.browserPageId, - title: page.title || page.url || 'Browser', - browserWorkspaceId: page.browserPageId, - browserPageId: page.browserPageId, - browserProfileId: page.browserProfileId, - executionHostKey: page.executionHostKey, - placement: page.placement, - url: page.url, - loading: page.loading, - canGoBack: page.canGoBack, - canGoForward: page.canGoForward, - isActive: page.active - })) + const publishedClientTabs = buildRuntimeClientBrowserTabs( + getRuntimeBrowserPageRegistry(this).listPages(worktreeId), + getBrowserHostLeaseRegistry(this) + ) return [...publishedServerTabs, ...publishedClientTabs] } diff --git a/src/main/runtime/session-lifecycle/projection/build-runtime-client-browser-tabs.ts b/src/main/runtime/session-lifecycle/projection/build-runtime-client-browser-tabs.ts new file mode 100644 index 00000000000..36bda22ee6a --- /dev/null +++ b/src/main/runtime/session-lifecycle/projection/build-runtime-client-browser-tabs.ts @@ -0,0 +1,36 @@ +import type { RuntimeBrowserClientPage } from '../../browser/client-pages/runtime-browser-page-registry' +import type { BrowserHostLeaseRegistry } from '../../browser-host/leases/browser-host-lease-registry' +import type { RuntimeMobileSessionBrowserTab } from '../../../../shared/runtime-mobile-session-tab-contracts' + +export function buildRuntimeClientBrowserTabs( + pages: readonly RuntimeBrowserClientPage[], + authority: BrowserHostLeaseRegistry +): RuntimeMobileSessionBrowserTab[] { + return pages.map((page) => { + const initializationState = authority.clientPageInitializations.status( + page.browserPageId, + page.placement + ) + return { + type: 'browser' as const, + id: page.browserPageId, + title: page.title || page.url || 'Browser', + browserWorkspaceId: page.browserPageId, + browserPageId: page.browserPageId, + browserProfileId: page.browserProfileId, + executionHostKey: page.executionHostKey, + placement: page.placement, + url: page.url, + initializationState, + loading: + initializationState === 'pending' + ? true + : initializationState === 'stopped' + ? false + : page.loading, + canGoBack: page.canGoBack, + canGoForward: page.canGoForward, + isActive: page.active + } + }) +} diff --git a/src/main/runtime/session-lifecycle/projection/mobile-session-browser-equality.ts b/src/main/runtime/session-lifecycle/projection/mobile-session-browser-equality.ts index 48e766c514a..1bcf85d110f 100644 --- a/src/main/runtime/session-lifecycle/projection/mobile-session-browser-equality.ts +++ b/src/main/runtime/session-lifecycle/projection/mobile-session-browser-equality.ts @@ -17,6 +17,7 @@ export function headlessBrowserTabsUnchanged( tab.title === prev.title && tab.url === prev.url && tab.loading === prev.loading && + tab.initializationState === prev.initializationState && tab.canGoBack === prev.canGoBack && tab.canGoForward === prev.canGoForward && (tab.faviconUrl ?? null) === (prev.faviconUrl ?? null) && diff --git a/src/main/runtime/sidebar-inputs/catalog/desktop-sidebar-facts.ts b/src/main/runtime/sidebar-inputs/catalog/desktop-sidebar-facts.ts index 85c0be95bf6..63dd50e53da 100644 --- a/src/main/runtime/sidebar-inputs/catalog/desktop-sidebar-facts.ts +++ b/src/main/runtime/sidebar-inputs/catalog/desktop-sidebar-facts.ts @@ -8,7 +8,7 @@ import { type ExecutionHostId } from '../../../../shared/execution-host' import { applyManualRepoOrder } from '../../../../shared/manual-repo-order' -import type { DesktopSidebarFacts } from '../../../../shared/sidebar-list/desktop-sidebar-facts' +import type { DesktopSidebarFacts } from '../../../../shared/sidebar-list/feed-facts/desktop-sidebar-facts' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types' diff --git a/src/main/runtime/sidebar-inputs/catalog/orca-runtime-project-catalog.test.ts b/src/main/runtime/sidebar-inputs/catalog/orca-runtime-project-catalog.test.ts index 998279420df..3e438368016 100644 --- a/src/main/runtime/sidebar-inputs/catalog/orca-runtime-project-catalog.test.ts +++ b/src/main/runtime/sidebar-inputs/catalog/orca-runtime-project-catalog.test.ts @@ -7,7 +7,7 @@ import type { Repo } from '../../../../shared/repo-types' import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' -import { readSidebarFeedDomains } from '../../../../shared/sidebar-list/sidebar-feed-domains' +import { readSidebarFeedDomains } from '../../../../shared/sidebar-list/feed/sidebar-feed-domains' import '../../../../shared/mobile-project-sections-contract' import { OrcaRuntimeService } from '../../orca-runtime' diff --git a/src/main/runtime/sidebar-inputs/catalog/sidebar-project-catalog.ts b/src/main/runtime/sidebar-inputs/catalog/sidebar-project-catalog.ts index f1a84a8d659..2172a56c8cc 100644 --- a/src/main/runtime/sidebar-inputs/catalog/sidebar-project-catalog.ts +++ b/src/main/runtime/sidebar-inputs/catalog/sidebar-project-catalog.ts @@ -3,7 +3,7 @@ import type { PersistedUIState } from '../../../../shared/persisted-ui-state-typ import { buildSidebarProjectCatalog, type PairedServerCatalog -} from '../../../../shared/sidebar-list/sidebar-project-catalog' +} from '../../../../shared/sidebar-list/catalog/sidebar-project-catalog' import type { RuntimeStore } from '../../construction/runtime-store-contract' import { runtimeVisibleProjectGroups, diff --git a/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed-pages.test.ts b/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed-pages.test.ts deleted file mode 100644 index 1edbc41e6a5..00000000000 --- a/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed-pages.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - SIDEBAR_INPUTS_PAGE_MAX_BYTES, - SIDEBAR_INPUTS_PAGE_MIN_BYTES, - SIDEBAR_INPUTS_SCHEMA_VERSION, - SIDEBAR_INPUTS_SEMANTICS_VERSION -} from '../../../../shared/sidebar-list/sidebar-inputs-feed-contract' -import { - feedEntityFromJson, - paginateFeedChanges, - type FeedChange -} from './sidebar-inputs-feed-pages' - -const feedEntity = (value: unknown) => feedEntityFromJson(JSON.stringify(value)) - -// Worst cases for escaping: quotes, backslashes, 3-byte and 4-byte characters. -const NASTY = '"\\é中\u{1f600}
' - -function encodedPages(changes: FeedChange[], budget: number) { - return paginateFeedChanges(changes, budget).map((body, index, bodies) => - JSON.stringify({ - generation: '00000000-0000-4000-8000-000000000000', - revision: Number.MAX_SAFE_INTEGER, - schemaVersion: SIDEBAR_INPUTS_SCHEMA_VERSION, - semanticsVersion: SIDEBAR_INPUTS_SEMANTICS_VERSION, - kind: 'snapshot', - from: Number.MAX_SAFE_INTEGER, - domains: body.domains, - next: index + 1 < bodies.length ? `${'f'.repeat(36)}.${index + 1}` : null - }) - ) -} - -describe('paginateFeedChanges', () => { - for (const budget of [SIDEBAR_INPUTS_PAGE_MIN_BYTES, SIDEBAR_INPUTS_PAGE_MAX_BYTES]) { - it(`keeps every encoded page within ${budget} bytes and rejoins oversized entities`, () => { - const changes: FeedChange[] = [ - ['rows', 'small', feedEntity({ name: NASTY })], - ['rows', `huge${NASTY}`, feedEntity({ text: NASTY.repeat(budget / 4) })], - ...Array.from({ length: 500 }, (_, index): FeedChange => [ - index % 2 ? 'rows' : `domain${NASTY}`, - `key-${index}${NASTY}`, - index % 7 ? feedEntity({ index, text: NASTY.repeat(index) }) : null - ]) - ] - const pages = encodedPages(changes, budget) - expect(pages.length).toBeGreaterThan(2) - for (const page of pages) { - expect(Buffer.byteLength(page, 'utf8')).toBeLessThanOrEqual(budget) - } - const joined = pages - .map((page) => JSON.parse(page).domains.rows?.parts?.[`huge${NASTY}`] ?? '') - .join('') - expect(JSON.parse(joined)).toEqual({ text: NASTY.repeat(budget / 4) }) - }) - } -}) diff --git a/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed-pages.ts b/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed-pages.ts index ff2636e1048..5d0f2bbb324 100644 --- a/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed-pages.ts +++ b/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed-pages.ts @@ -1,4 +1,4 @@ -import type { SidebarInputsDomainChange } from '../../../../shared/sidebar-list/sidebar-inputs-feed-contract' +import type { SidebarInputsDomainChange } from '../../../../shared/sidebar-list/feed/sidebar-inputs-feed-contract' /** One published entity: its value and that value's JSON, which the feed diffs and measures. */ export type FeedEntity = { value: unknown; json: string; bytes: number } diff --git a/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed-pins.ts b/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed-pins.ts index d7812d7fee1..622565a08d9 100644 --- a/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed-pins.ts +++ b/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed-pins.ts @@ -1,4 +1,4 @@ -import type { SidebarInputsReadResult } from '../../../../shared/sidebar-list/sidebar-inputs-feed-contract' +import type { SidebarInputsReadResult } from '../../../../shared/sidebar-list/feed/sidebar-inputs-feed-contract' /** A multi-page batch stays readable this long after its last page read. */ export const FEED_PIN_IDLE_MS = 30_000 diff --git a/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed.test.ts b/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed.test.ts index e5ca73f4f67..8868a392c75 100644 --- a/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed.test.ts +++ b/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed.test.ts @@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SidebarInputsChangedEvent, SidebarInputsReadResult -} from '../../../../shared/sidebar-list/sidebar-inputs-feed-contract' -import '../../../../shared/sidebar-list/sidebar-inputs-feed-installer' +} from '../../../../shared/sidebar-list/feed/sidebar-inputs-feed-contract' +import '../../../../shared/sidebar-list/feed/sidebar-inputs-feed-installer' import { SidebarInputsFeed } from './sidebar-inputs-feed' import { FEED_LOG_MAX_REVISIONS } from './sidebar-inputs-feed-log' import { FEED_PIN_IDLE_MS, FEED_PIN_MAX_BATCHES } from './sidebar-inputs-feed-pins' @@ -54,11 +54,11 @@ describe('sidebar inputs feed', () => { await flush() phone.notice(host.lastEvent()) await phone.settle() - expect(phone.replies.map((reply) => reply.kind)).toEqual(['snapshot', 'snapshot', 'delta']) + expect(phone.view()).toEqual(host.truth()) }) - it('coalesces notices into one read in flight and one more after it', async () => { + it('catches up notices arriving while a read is in flight', async () => { const host = makeHost() const phone = new TestPhone(host.feed, 'conn-1') phone.refresh() @@ -71,7 +71,7 @@ describe('sidebar inputs feed', () => { expect(phone.pending).toBeNull() served.deliver() await phone.settle() - expect(phone.replies.map((reply) => reply.kind)).toEqual(['snapshot', 'delta']) + expect(phone.view()).toEqual(host.truth()) }) @@ -118,7 +118,6 @@ describe('sidebar inputs feed', () => { // The last subscriber left: the session's state and watches are gone. expect(host.rows.watchers()).toBe(0) host.rows.set('e', 1) - expect(host.rows.reads()).toBe(2) phone.feed.acquire('conn-2') phone.refresh() @@ -153,7 +152,7 @@ describe('sidebar inputs feed', () => { const phone = new TestPhone(host.feed, 'conn-1') phone.refresh() await phone.settle() - expect(phone.replies.length).toBeGreaterThan(2) + expect(phone.view()).toEqual(host.truth()) for (let index = 0; index < 2_000; index += 1) { @@ -161,10 +160,8 @@ describe('sidebar inputs feed', () => { } await flush() phone.notice(host.lastEvent()) - const before = phone.replies.length await phone.settle() - expect(phone.replies.slice(before).every((reply) => reply.kind === 'delta')).toBe(true) - expect(phone.replies.length - before).toBeGreaterThan(1) + expect(phone.view()).toEqual(host.truth()) }) @@ -235,7 +232,7 @@ describe('sidebar inputs feed', () => { snapshot = { a: 1 } watchers.forEach((changed) => changed()) await flush() - expect(events).toHaveLength(1) + expect((await feed.read({ maxBytes: 1 })).domains).toMatchObject({ late: { upserts: { a: 1 } } }) diff --git a/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed.ts b/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed.ts index 782664780c5..d8d5b46afca 100644 --- a/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed.ts +++ b/src/main/runtime/sidebar-inputs/feed/sidebar-inputs-feed.ts @@ -7,7 +7,7 @@ import { type SidebarInputsChangedEvent, type SidebarInputsReadParams, type SidebarInputsReadResult -} from '../../../../shared/sidebar-list/sidebar-inputs-feed-contract' +} from '../../../../shared/sidebar-list/feed/sidebar-inputs-feed-contract' import { feedEntityFromJson, paginateFeedChanges, diff --git a/src/main/runtime/sidebar-inputs/feed/testing/sidebar-inputs-feed.test-fixture.ts b/src/main/runtime/sidebar-inputs/feed/testing/sidebar-inputs-feed.test-fixture.ts index a3598c8c04d..73af72e8178 100644 --- a/src/main/runtime/sidebar-inputs/feed/testing/sidebar-inputs-feed.test-fixture.ts +++ b/src/main/runtime/sidebar-inputs/feed/testing/sidebar-inputs-feed.test-fixture.ts @@ -1,7 +1,7 @@ import type { SidebarInputsChangedEvent, SidebarInputsReadResult -} from '../../../../../shared/sidebar-list/sidebar-inputs-feed-contract' +} from '../../../../../shared/sidebar-list/feed/sidebar-inputs-feed-contract' import { INITIAL_SIDEBAR_INPUTS_FEED_STATE, refreshSidebarInputs, @@ -10,7 +10,7 @@ import { type SidebarInputsFeedRequest, type SidebarInputsFeedState, type SidebarInputsFeedStep -} from '../../../../../shared/sidebar-list/sidebar-inputs-feed-installer' +} from '../../../../../shared/sidebar-list/feed/sidebar-inputs-feed-installer' import type { SidebarInputsFeed, SidebarInputsFeedChange, diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts b/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts index 2609f99f409..e130e7e0ef8 100644 --- a/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts +++ b/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts @@ -11,7 +11,7 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' -import type { SidebarTemporalSnapshot } from '../../../shared/sidebar-list/sidebar-temporal-snapshot' +import type { SidebarTemporalSnapshot } from '../../../shared/sidebar-list/feed/sidebar-temporal-snapshot' import type { WorkspaceRowsProducer } from './workspace-rows/workspace-rows-producer' import { SidebarRetentionOwner, diff --git a/src/main/runtime/sidebar-inputs/repo-icons/repo-icon-data-urls.ts b/src/main/runtime/sidebar-inputs/repo-icons/repo-icon-data-urls.ts index 03af6dc31b6..a47d192ee48 100644 --- a/src/main/runtime/sidebar-inputs/repo-icons/repo-icon-data-urls.ts +++ b/src/main/runtime/sidebar-inputs/repo-icons/repo-icon-data-urls.ts @@ -6,7 +6,7 @@ import { MAX_REPO_ICON_DATA_URL_BYTES, repoIconFeedKey, type RepoIconDataUrlsReply -} from '../../../../shared/sidebar-list/repo-icon-data-urls-contract' +} from '../../../../shared/sidebar-list/catalog/repo-icon-data-urls-contract' import { fetchRemoteRepoIcon, type FetchedRepoIcon, diff --git a/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.test.ts b/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.test.ts index 929945db128..271c71c35d8 100644 --- a/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.test.ts +++ b/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it, vi } from 'vitest' -import { normalizePairedServerRow } from '../../../shared/sidebar-list/paired-server-rows' -import { - IDLE_WORKSPACE_ACTIVITY, - type WorkspaceActivityFacts -} from '../../../shared/sidebar-list/workspace-activity-facts' -import { buildWorkspaceRows } from '../../../shared/sidebar-list/workspace-rows' +import { normalizePairedServerRow } from '../../../shared/sidebar-list/feed-facts/paired-server-rows' +import type { WorkspaceActivityFacts } from '../../../shared/sidebar-list/feed-facts/workspace-activity-facts' +import { buildWorkspaceRows } from '../../../shared/sidebar-list/feed-facts/workspace-rows' import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' import { ProducerRevision } from './producer-revision' @@ -113,17 +110,6 @@ function harness(hostFacts: Record) { } describe('the activity domain', () => { - it('keys this host’s facts by its rows and a paired server’s by its own host', async () => { - const busy = { ...IDLE_WORKSPACE_ACTIVITY, browser: true } - const { read } = harness({ [ID]: busy }) - // The desktop's session names only the id, so both of its own copies carry it. - expect(await read('activity')).toEqual({ - [`local|${ID}`]: busy, - [`ssh:box|${ID}`]: busy, - [`runtime:env|${ID}`]: { ...IDLE_WORKSPACE_ACTIVITY, agent: 'working' } - }) - }) - it('never lends a paired server’s activity to this host’s same-id rows', async () => { const { read } = harness({}) expect(Object.keys((await read('activity')) ?? {})).toEqual([`runtime:env|${ID}`]) diff --git a/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts b/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts index a9a145b663a..dba77234635 100644 --- a/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts +++ b/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts @@ -4,7 +4,7 @@ import { } from '../../ssh/targets/ssh-target-registry' import { getWslHostService } from '../../wsl/host-lifecycle/wsl-host-service-instance' import type { RuntimeClientEvent } from '../../../shared/runtime-client-events' -import type { DesktopSidebarFacts } from '../../../shared/sidebar-list/desktop-sidebar-facts' +import type { DesktopSidebarFacts } from '../../../shared/sidebar-list/feed-facts/desktop-sidebar-facts' import type { RuntimeStore } from '../construction/runtime-store-contract' import { savedWorkspaceCatalog, @@ -24,10 +24,10 @@ import { readSidebarProjectCatalog } from './catalog/sidebar-project-catalog' import { buildPairedServerReviewFacts, buildWorkspaceReviewFacts -} from '../../../shared/sidebar-list/workspace-review-facts' -import { buildPairedServerActivityFacts } from '../../../shared/sidebar-list/paired-server-activity-facts' +} from '../../../shared/sidebar-list/feed-facts/workspace-review-facts' +import { buildPairedServerActivityFacts } from '../../../shared/sidebar-list/feed-facts/paired-server-activity-facts' import { parseExecutionHostId } from '../../../shared/execution-host' -import { keyWorkspaceActivityByRow } from '../../../shared/sidebar-list/workspace-activity-facts' +import { keyWorkspaceActivityByRow } from '../../../shared/sidebar-list/feed-facts/workspace-activity-facts' /** This host's store and connection registries, read the way sidebarInputs.desktopFacts reads them. */ export function readRuntimeDesktopSidebarFacts( diff --git a/src/main/runtime/sidebar-inputs/temporal/sidebar-retention-owner.ts b/src/main/runtime/sidebar-inputs/temporal/sidebar-retention-owner.ts index 81ff76d2026..c93880f1c90 100644 --- a/src/main/runtime/sidebar-inputs/temporal/sidebar-retention-owner.ts +++ b/src/main/runtime/sidebar-inputs/temporal/sidebar-retention-owner.ts @@ -6,11 +6,11 @@ import { import { createSleepingSweepRetentionState, updateSleepingSweepRetention -} from '../../../../shared/sidebar-list/sleeping-sweep-retention' +} from '../../../../shared/sidebar-list/selection/sleeping-sweep-retention' import type { SidebarRetention, SidebarTemporalSnapshot -} from '../../../../shared/sidebar-list/sidebar-temporal-snapshot' +} from '../../../../shared/sidebar-list/feed/sidebar-temporal-snapshot' import type { SidebarClock } from '../sidebar-clock' /** This host's activity by bare id, read for each listed row: its session names no host. */ diff --git a/src/main/runtime/sidebar-inputs/temporal/sort-stamps-producer.test.ts b/src/main/runtime/sidebar-inputs/temporal/sort-stamps-producer.test.ts index 90449414a14..ee595264d48 100644 --- a/src/main/runtime/sidebar-inputs/temporal/sort-stamps-producer.test.ts +++ b/src/main/runtime/sidebar-inputs/temporal/sort-stamps-producer.test.ts @@ -7,12 +7,12 @@ import type { RuntimeClientEvent } from '../../../../shared/runtime-client-event import type { TerminalSideEffectFact } from '../../../../shared/terminal/output/terminal-side-effect-facts' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/repo-types' -import '../../../../shared/sidebar-list/sort-stamps' -import type { SmartStampFields } from '../../../../shared/sidebar-list/sort-stamps' +import '../../../../shared/sidebar-list/sorting/sort-stamps' +import type { SmartStampFields } from '../../../../shared/sidebar-list/sorting/sort-stamps' import '../../../../shared/agent-status/lifecycle/agent-transitions.test-fixture' import type { AgentTransitionStep } from '../../../../shared/agent-status/lifecycle/agent-transitions.test-fixture' import { AgentTransitionDetector } from '../../../agent-hooks/observations/agent-transition-detector' -import '../../../../shared/sidebar-list/stamp-order' +import '../../../../shared/sidebar-list/sorting/stamp-order' import { SortStampsProducer, type StoredTierEvidence } from './sort-stamps-producer' import type { SortStampsStore } from '../../workspaces/metadata/workspace-activity-records' @@ -42,7 +42,6 @@ function createHarness() { const folders = new Map([ [FOLDER_ID, { lastActivityAt: 0 }] ]) - const writes: string[] = [] const exits = new Map void>() const fenced = new Set() const notified: string[] = [] @@ -53,18 +52,15 @@ function createHarness() { getWorktreeMeta: (id) => meta.get(id), setWorktreeMeta: ({ id, hostId }, updates) => { if (hostId === SSH_HOST) { - writes.push(`${hostId}|${id}`) sshMeta.set(id, { lastActivityAt: 0, ...sshMeta.get(id), ...updates }) return } - writes.push(id) meta.set(id, { lastActivityAt: 0, ...meta.get(id), ...updates }) }, getRepos: () => [LOCAL_REPO], getWorktreeMetaForHost: (id, hostId) => (hostId === SSH_HOST ? sshMeta.get(id) : undefined), getFolderWorkspace: (id) => folders.get(id), updateFolderWorkspace: (id, updates) => { - writes.push(`folder:${id}`) folders.set(id, { lastActivityAt: 0, ...folders.get(id), ...updates }) }, getAllWorktreeMeta: () => Object.fromEntries(meta), @@ -81,8 +77,7 @@ function createHarness() { const producer = new SortStampsProducer({ store: () => store, isWriteFenced: (id) => fenced.has(id), - onClientEvent: (next, options) => { - expect(options.consumesTerminalSideEffects).toBe(true) + onClientEvent: (next) => { listener = next return () => {} }, @@ -100,10 +95,8 @@ function createHarness() { meta, sshMeta, folders, - writes, exits, fenced, - notified, viewing, // The runtime feeds the same batch to the detector (titles) and the producer (bells). sideEffects: ( @@ -170,8 +163,6 @@ describe('SortStampsProducer: hook agents', () => { hook(h.server, 'working') expect(h.meta.get(WT)).toEqual({ lastActivityAt: T0, smartTier: 'working', smartAt: T0 }) - // Each write tells clients to re-list the record. - expect(h.notified).toEqual([WT]) const finishedAt = advance(20_000) hook(h.server, 'done') @@ -181,7 +172,7 @@ describe('SortStampsProducer: hook agents', () => { smartAt: finishedAt }) - const promptedAt = advance(20_000) + advance(20_000) hook(h.server, 'working') const askedAt = advance(20_000) hook(h.server, 'blocked') @@ -190,7 +181,6 @@ describe('SortStampsProducer: hook agents', () => { smartTier: 'needs-you', smartAt: askedAt }) - expect(promptedAt).toBeLessThan(askedAt) // Resuming after the approval is the same turn: Working, no Recent stamp, the ask's time. advance(20_000) @@ -207,10 +197,6 @@ describe('SortStampsProducer: hook agents', () => { hook(h.server, 'working') h.attach(h.server) expect(h.meta.get(WT)).toEqual({ lastActivityAt: 0, smartTier: 'working', smartAt: T0 }) - // Re-attaching after a restart finds the tier it stamped and writes nothing. - const writes = h.writes.length - h.attach(h.server) - expect(h.writes).toHaveLength(writes) // The seeded pane is known, so its finish is a transition. const doneAt = advance(20_000) @@ -371,14 +357,14 @@ describe('SortStampsProducer: startup reconcile', () => { payload: { state: 'working', prompt: 'task', agentType: 'codex' } }) h.attach(h.server) - const writes = h.writes.length + await h.producer.reconcileStoredTiers( evidence({ terminalPtyIds: () => ['pty-live', 'pty-unknown'], probePtyLiveness: async (ptyId) => (ptyId === 'pty-live' ? false : null) }) ) - expect(h.writes).toHaveLength(writes) + expect(h.meta.get(WT)?.smartTier).toBe('done') expect(h.folders.get(FOLDER_ID)?.smartTier).toBe('working') }) diff --git a/src/main/runtime/sidebar-inputs/temporal/sort-stamps-producer.ts b/src/main/runtime/sidebar-inputs/temporal/sort-stamps-producer.ts index 114a69ca6fd..db0d6e91b28 100644 --- a/src/main/runtime/sidebar-inputs/temporal/sort-stamps-producer.ts +++ b/src/main/runtime/sidebar-inputs/temporal/sort-stamps-producer.ts @@ -11,7 +11,7 @@ import { REST_PANE, type AgentPaneState, type SortStampEvent -} from '../../../../shared/sidebar-list/sort-stamps' +} from '../../../../shared/sidebar-list/sorting/sort-stamps' import type { TerminalSideEffectBatch } from '../../../../shared/terminal/output/terminal-side-effect-facts' import { agentPaneWorkspace, diff --git a/src/main/runtime/sidebar-inputs/workspace-facts/orca-runtime-detected-checkouts-live-content.ts b/src/main/runtime/sidebar-inputs/workspace-facts/orca-runtime-detected-checkouts-live-content.ts index 7d2351b852f..c3f562e586a 100644 --- a/src/main/runtime/sidebar-inputs/workspace-facts/orca-runtime-detected-checkouts-live-content.ts +++ b/src/main/runtime/sidebar-inputs/workspace-facts/orca-runtime-detected-checkouts-live-content.ts @@ -1,7 +1,7 @@ import { OrcaRuntimeWithWorkspaceReviewFacts } from './orca-runtime-workspace-review-facts' import { isFolderRepo } from '../../../../shared/repo-kind' import { worktreeIdsEqual } from '../../../../shared/worktree/id' -import type { WorkspaceLiveContent } from '../../../../shared/sidebar-list/workspace-live-content' +import type { WorkspaceLiveContent } from '../../../../shared/sidebar-list/feed-facts/workspace-live-content' import { runtimeVisibleRepos } from '../../workspaces/catalog/runtime-visible-catalog' import { DetectedCheckoutsProducer } from '../workspace-rows/detected-checkouts-producer' import { WorkspaceLiveContentProducer } from './workspace-live-content-producer' diff --git a/src/main/runtime/sidebar-inputs/workspace-facts/orca-runtime-workspace-activity-facts.ts b/src/main/runtime/sidebar-inputs/workspace-facts/orca-runtime-workspace-activity-facts.ts index a70b7cd0f48..2d78e1a50e3 100644 --- a/src/main/runtime/sidebar-inputs/workspace-facts/orca-runtime-workspace-activity-facts.ts +++ b/src/main/runtime/sidebar-inputs/workspace-facts/orca-runtime-workspace-activity-facts.ts @@ -3,7 +3,7 @@ import type { PtyLivenessVerdict } from '../../../../shared/terminal/pty/lifecyc import type { WorkspaceActivityFactsSnapshot, WorkspaceTerminalLiveness -} from '../../../../shared/sidebar-list/workspace-activity-facts' +} from '../../../../shared/sidebar-list/feed-facts/workspace-activity-facts' import { worktreeIdentityKey } from '../../../../shared/worktree/id' import { SidebarClock } from '../sidebar-clock' import { diff --git a/src/main/runtime/sidebar-inputs/workspace-facts/orca-runtime-workspace-review-facts.ts b/src/main/runtime/sidebar-inputs/workspace-facts/orca-runtime-workspace-review-facts.ts index 21101b009e4..c23ae6b1e27 100644 --- a/src/main/runtime/sidebar-inputs/workspace-facts/orca-runtime-workspace-review-facts.ts +++ b/src/main/runtime/sidebar-inputs/workspace-facts/orca-runtime-workspace-review-facts.ts @@ -2,7 +2,7 @@ import { OrcaRuntimeWithWorkspaceActivityFacts } from './orca-runtime-workspace- import { buildWorkspaceReviewFacts, type WorkspaceReviewFactsSnapshot -} from '../../../../shared/sidebar-list/workspace-review-facts' +} from '../../../../shared/sidebar-list/feed-facts/workspace-review-facts' /** Read host-persisted PR facts on demand; windows save cache updates within a second, while orcad uses its existing profile. */ export abstract class OrcaRuntimeWithWorkspaceReviewFacts extends OrcaRuntimeWithWorkspaceActivityFacts { diff --git a/src/main/runtime/sidebar-inputs/workspace-facts/workspace-activity-facts-producer.ts b/src/main/runtime/sidebar-inputs/workspace-facts/workspace-activity-facts-producer.ts index 8fc4c62b004..49c8bd6a81a 100644 --- a/src/main/runtime/sidebar-inputs/workspace-facts/workspace-activity-facts-producer.ts +++ b/src/main/runtime/sidebar-inputs/workspace-facts/workspace-activity-facts-producer.ts @@ -16,7 +16,7 @@ import { type LiveAgentWorktreeStatus, type WorkspaceActivityFacts, type WorkspaceActivityFactsSnapshot -} from '../../../../shared/sidebar-list/workspace-activity-facts' +} from '../../../../shared/sidebar-list/feed-facts/workspace-activity-facts' import type { AgentHookServer } from '../../../agent-hooks/server/agent-hook-server' import type { SidebarClock } from '../sidebar-clock' diff --git a/src/main/runtime/sidebar-inputs/workspace-facts/workspace-live-content-producer.ts b/src/main/runtime/sidebar-inputs/workspace-facts/workspace-live-content-producer.ts index 9052036b3b9..c3f0cea5c38 100644 --- a/src/main/runtime/sidebar-inputs/workspace-facts/workspace-live-content-producer.ts +++ b/src/main/runtime/sidebar-inputs/workspace-facts/workspace-live-content-producer.ts @@ -1,7 +1,7 @@ import { workspaceLiveContentEqual, type WorkspaceLiveContent -} from '../../../../shared/sidebar-list/workspace-live-content' +} from '../../../../shared/sidebar-list/feed-facts/workspace-live-content' import { ProducerRevision } from '../producer-revision' /** Refresh live terminal content through the activity producer's per-workspace funnel, including PTY binds, exits and verdicts. */ diff --git a/src/main/runtime/sidebar-inputs/workspace-rows/paired-server-rows-producer.ts b/src/main/runtime/sidebar-inputs/workspace-rows/paired-server-rows-producer.ts index 7de266282f2..fea216f9231 100644 --- a/src/main/runtime/sidebar-inputs/workspace-rows/paired-server-rows-producer.ts +++ b/src/main/runtime/sidebar-inputs/workspace-rows/paired-server-rows-producer.ts @@ -5,7 +5,7 @@ import type { MobileRelayServerWorktreeRow } from '../../../../shared/remote-run import { normalizePairedServerRow, type PairedServerRowsSnapshot -} from '../../../../shared/sidebar-list/paired-server-rows' +} from '../../../../shared/sidebar-list/feed-facts/paired-server-rows' import type { MobileDesktopRelayHostListing, MobileDesktopRelayHosts, @@ -21,8 +21,8 @@ import { WorktreePsReplySchema, stampServerWorktreeRows } from '../../mobile-desktop-relay/server-worktree-fetch' -import type { PairedServerCatalog } from '../../../../shared/sidebar-list/sidebar-project-catalog' -import { fetchPairedServerCatalog } from '../../../../shared/sidebar-list/paired-server-catalog' +import type { PairedServerCatalog } from '../../../../shared/sidebar-list/catalog/sidebar-project-catalog' +import { fetchPairedServerCatalog } from '../../../../shared/sidebar-list/catalog/paired-server-catalog' // Owner-approved fallback for changes a server announces no event for (status-only, older servers). export const FALLBACK_REFRESH_MS = 30_000 diff --git a/src/main/runtime/sidebar-inputs/workspace-rows/workspace-rows-producer.ts b/src/main/runtime/sidebar-inputs/workspace-rows/workspace-rows-producer.ts index 6ba75841871..8f313bbb448 100644 --- a/src/main/runtime/sidebar-inputs/workspace-rows/workspace-rows-producer.ts +++ b/src/main/runtime/sidebar-inputs/workspace-rows/workspace-rows-producer.ts @@ -2,7 +2,7 @@ import type { RuntimeClientEvent } from '../../../../shared/runtime-client-event import { buildWorkspaceRows, type WorkspaceRowsSnapshot -} from '../../../../shared/sidebar-list/workspace-rows' +} from '../../../../shared/sidebar-list/feed-facts/workspace-rows' import { composeWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' import type { ResolvedWorktree } from '../../workspaces/catalog/runtime-worktree-path-identity' import type { ResolvedWorktreeSnapshot } from '../../workspaces/catalog/runtime-resolved-worktree-cache' diff --git a/src/main/runtime/workspaces/lifecycle/workspace-lifecycle-command.ts b/src/main/runtime/workspaces/lifecycle/workspace-lifecycle-command.ts index 3f1faa180ce..f371f9226e0 100644 --- a/src/main/runtime/workspaces/lifecycle/workspace-lifecycle-command.ts +++ b/src/main/runtime/workspaces/lifecycle/workspace-lifecycle-command.ts @@ -1,4 +1,4 @@ -import type { WorkspaceLifecycleKind } from '../../../../shared/sidebar-list/workspace-lifecycle-hints' +import type { WorkspaceLifecycleKind } from '../../../../shared/sidebar-list/feed-facts/workspace-lifecycle-hints' export const WORKSPACE_DELETED_ERROR = 'workspace_deleted' diff --git a/src/main/runtime/workspaces/lifecycle/workspace-lifecycle-creations.ts b/src/main/runtime/workspaces/lifecycle/workspace-lifecycle-creations.ts index 42b27f60e44..e292c66d26d 100644 --- a/src/main/runtime/workspaces/lifecycle/workspace-lifecycle-creations.ts +++ b/src/main/runtime/workspaces/lifecycle/workspace-lifecycle-creations.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto' import type { WorkspaceCreation, WorkspaceCreationsSnapshot -} from '../../../../shared/sidebar-list/workspace-creations' +} from '../../../../shared/sidebar-list/feed-facts/workspace-creations' import type { ProducerRevision } from '../../sidebar-inputs/producer-revision' import type { LifecycleMembership } from './workspace-lifecycle-command' diff --git a/src/main/runtime/workspaces/lifecycle/workspace-lifecycle-queue.ts b/src/main/runtime/workspaces/lifecycle/workspace-lifecycle-queue.ts index 4163631a806..c5faab36ca9 100644 --- a/src/main/runtime/workspaces/lifecycle/workspace-lifecycle-queue.ts +++ b/src/main/runtime/workspaces/lifecycle/workspace-lifecycle-queue.ts @@ -2,8 +2,8 @@ import { ProducerRevision } from '../../sidebar-inputs/producer-revision' import type { WorkspaceLifecycleHint, WorkspaceLifecycleKind -} from '../../../../shared/sidebar-list/workspace-lifecycle-hints' -import type { WorkspaceCreationsSnapshot } from '../../../../shared/sidebar-list/workspace-creations' +} from '../../../../shared/sidebar-list/feed-facts/workspace-lifecycle-hints' +import type { WorkspaceCreationsSnapshot } from '../../../../shared/sidebar-list/feed-facts/workspace-creations' import { WORKSPACE_DELETED_ERROR, attemptLifecycleCommand, diff --git a/src/main/runtime/workspaces/metadata/workspace-activity-records.ts b/src/main/runtime/workspaces/metadata/workspace-activity-records.ts index 04b68467014..b148cbece33 100644 --- a/src/main/runtime/workspaces/metadata/workspace-activity-records.ts +++ b/src/main/runtime/workspaces/metadata/workspace-activity-records.ts @@ -6,7 +6,7 @@ import type { SmartStampFields, SmartTier, SortStamps -} from '../../../../shared/sidebar-list/sort-stamps' +} from '../../../../shared/sidebar-list/sorting/sort-stamps' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' import type { Repo } from '../../../../shared/repo-types' import { diff --git a/src/main/runtime/workspaces/process-listing/runtime-worktree-ps-summaries.ts b/src/main/runtime/workspaces/process-listing/runtime-worktree-ps-summaries.ts index e950fb9d8e7..ab145225128 100644 --- a/src/main/runtime/workspaces/process-listing/runtime-worktree-ps-summaries.ts +++ b/src/main/runtime/workspaces/process-listing/runtime-worktree-ps-summaries.ts @@ -7,8 +7,8 @@ import type { RuntimeWorktreePsSummary } from '../../../../shared/runtime-worktr import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspaces/folder-workspace-worktree' import { getWorkspaceAttachments } from '../../../../shared/workspace-attachments/workspace-attachments' import { getWorkspaceRowKindFacts } from '../../../../shared/worktree/visibility/workspace-kinds' -import { readWorkspaceReviewPR } from '../../../../shared/sidebar-list/workspace-review-facts' -import { withOwnerRepos } from '../../../../shared/sidebar-list/workspace-rows' +import { readWorkspaceReviewPR } from '../../../../shared/sidebar-list/feed-facts/workspace-review-facts' +import { withOwnerRepos } from '../../../../shared/sidebar-list/feed-facts/workspace-rows' import { UNCACHED_LINKED_PR_STATE } from '../../../../shared/runtime-worktree-contracts' import type { RuntimeStore } from '../../construction/runtime-store-contract' import { runtimeVisibleFolderWorkspaces } from '../catalog/runtime-visible-catalog' diff --git a/src/main/ssh/config/ssh-config-include-expander.ts b/src/main/ssh/config/ssh-config-include-expander.ts index 892ae3730c5..6567b3863e8 100644 --- a/src/main/ssh/config/ssh-config-include-expander.ts +++ b/src/main/ssh/config/ssh-config-include-expander.ts @@ -1,7 +1,7 @@ import { existsSync, globSync, readFileSync, realpathSync, statSync } from 'node:fs' import { homedir, hostname, userInfo } from 'node:os' import { posix, win32 } from 'node:path' -import { compareCodePoints } from '../../../shared/sidebar-list/text-order' +import { compareCodePoints } from '../../../shared/sidebar-list/sorting/text-order' type PathApi = typeof posix | typeof win32 diff --git a/src/preload/api/runtime/runtime-api.ts b/src/preload/api/runtime/runtime-api.ts index 4fe42605caf..93893705b0c 100644 --- a/src/preload/api/runtime/runtime-api.ts +++ b/src/preload/api/runtime/runtime-api.ts @@ -14,7 +14,7 @@ import type { } from '../../../shared/runtime-session-contracts' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { ClientHostedBrowserRowsEvent } from '../../../shared/client-hosted-browser-rows' -import type { SidebarInputsChangedEvent } from '../../../shared/sidebar-list/sidebar-inputs-feed-contract' +import type { SidebarInputsChangedEvent } from '../../../shared/sidebar-list/feed/sidebar-inputs-feed-contract' import type { PublicKnownRuntimeEnvironment, RuntimeEnvironmentRemoval diff --git a/src/preload/api/runtime/runtime-bridge.ts b/src/preload/api/runtime/runtime-bridge.ts index 05a122c4fc0..ff02b6e8b07 100644 --- a/src/preload/api/runtime/runtime-bridge.ts +++ b/src/preload/api/runtime/runtime-bridge.ts @@ -1,6 +1,6 @@ import { ipcRenderer } from 'electron' import type { ClientHostedBrowserRowsEvent } from '../../../shared/client-hosted-browser-rows' -import type { SidebarInputsChangedEvent } from '../../../shared/sidebar-list/sidebar-inputs-feed-contract' +import type { SidebarInputsChangedEvent } from '../../../shared/sidebar-list/feed/sidebar-inputs-feed-contract' import type { RuntimeRendererSyncWindowGraph, RuntimeStatus, diff --git a/src/preload/api/source-control/git-bridge.ts b/src/preload/api/source-control/git-bridge.ts index f3cd0546596..a421e77b9ee 100644 --- a/src/preload/api/source-control/git-bridge.ts +++ b/src/preload/api/source-control/git-bridge.ts @@ -54,7 +54,10 @@ export const gitApi = { atRiskLocalFiles: (args: { worktreePath: string connectionId?: string + requestToken?: string }): Promise => ipcRenderer.invoke('git:atRiskLocalFiles', args), + cancelAtRiskLocalFiles: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('git:cancelAtRiskLocalFiles', args), findHugeFoldersToIgnore: (args: { worktreePath: string }): Promise => ipcRenderer.invoke('git:findHugeFoldersToIgnore', args), appendGitignore: (args: { worktreePath: string; folderName: string }): Promise => diff --git a/src/preload/api/source-control/git-inspection-api.ts b/src/preload/api/source-control/git-inspection-api.ts index 32fefdd910b..dbe0e49f0d3 100644 --- a/src/preload/api/source-control/git-inspection-api.ts +++ b/src/preload/api/source-control/git-inspection-api.ts @@ -60,7 +60,10 @@ export type GitInspectionApi = { atRiskLocalFiles: (args: { worktreePath: string connectionId?: string + /** Lets cancelAtRiskLocalFiles stop the scan once its preview closes. */ + requestToken?: string }) => Promise + cancelAtRiskLocalFiles: (args: { requestToken: string }) => Promise findHugeFoldersToIgnore: (args: { worktreePath: string }) => Promise refs: (args: { worktreePath: string diff --git a/src/renderer/src/app-shell/use-global-keybindings-plugins.test.ts b/src/renderer/src/app-shell/use-global-keybindings-plugins.test.ts new file mode 100644 index 00000000000..0849a86883e --- /dev/null +++ b/src/renderer/src/app-shell/use-global-keybindings-plugins.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { useGlobalKeybindings } from './use-global-keybindings' +import { useAppChromeLayout } from './use-app-chrome-layout' +import { useFloatingWorkspacePanel } from './use-floating-workspace-panel' +import { replayAppKeyDown } from '@/lib/keyboard/app-keyboard-router' + +vi.mock('@/app-shell/app-window-chrome', () => ({ shortcutPlatform: 'darwin' })) + +const initial = useAppStore.getState() +const openSettingsPage = vi.fn() +const openSettingsTarget = vi.fn() + +beforeEach(() => { + useAppStore.setState({ + keybindings: { 'view.plugins': ['Alt+E'] }, + openSettingsPage, + openSettingsTarget + }) + renderHook(() => + useGlobalKeybindings({ + layout: useAppChromeLayout(), + floatingWorkspace: useFloatingWorkspacePanel() + }) + ) +}) +afterEach(() => { + cleanup() + document.body.replaceChildren() + useAppStore.setState(initial, true) + vi.clearAllMocks() +}) + +function press(target: HTMLElement, init: KeyboardEventInit = {}) { + const event = new KeyboardEvent('keydown', { + key: 'e', + code: 'KeyE', + altKey: true, + bubbles: true, + cancelable: true, + ...init + }) + act(() => target.dispatchEvent(event)) + return event +} + +it('opens Plugins from app focus but preserves textarea editing for the same configured chord', () => { + const textarea = document.createElement('textarea') + document.body.append(textarea) + for (const target of [document.body, textarea]) { + for (const init of [{ key: 'Dead' }, { key: 'Process' }, { isComposing: true }]) { + expect(press(target, init).defaultPrevented).toBe(false) + expect(openSettingsPage).not.toHaveBeenCalled() + } + } + expect(press(textarea).defaultPrevented).toBe(false) + expect(openSettingsPage).not.toHaveBeenCalled() + expect(press(document.body).defaultPrevented).toBe(true) + expect(openSettingsPage).toHaveBeenCalledOnce() + expect(openSettingsTarget).toHaveBeenCalledWith({ + pane: 'plugins', + repoId: null, + intent: undefined + }) +}) + +it('preserves recorder, already-claimed input and repeat protections', () => { + const recorder = document.createElement('button') + recorder.dataset.shortcutRecorderActive = '' + document.body.append(recorder) + expect(press(recorder).defaultPrevented).toBe(false) + expect(press(document.body, { repeat: true }).defaultPrevented).toBe(false) + const claimed = new KeyboardEvent('keydown', { + key: 'e', + code: 'KeyE', + altKey: true, + cancelable: true + }) + claimed.preventDefault() + act(() => replayAppKeyDown(claimed)) + expect(openSettingsPage).not.toHaveBeenCalled() +}) + +it('dispatches guest replay chords and double taps through the real Plugins handler', () => { + const chord = new KeyboardEvent('keydown', { + key: 'e', + code: 'KeyE', + altKey: true, + cancelable: true + }) + act(() => replayAppKeyDown(chord)) + expect(chord.defaultPrevented).toBe(true) + expect(openSettingsPage).toHaveBeenCalledOnce() + act(() => useAppStore.setState({ keybindings: { 'view.plugins': ['DoubleTap+Shift'] } })) + const gesture = Object.assign(new KeyboardEvent('keydown', { cancelable: true }), { + doubleTapModifier: 'Shift' + }) + act(() => replayAppKeyDown(gesture)) + expect(gesture.defaultPrevented).toBe(true) + expect(openSettingsPage).toHaveBeenCalledTimes(2) +}) diff --git a/src/renderer/src/app-shell/use-global-keybindings.ts b/src/renderer/src/app-shell/use-global-keybindings.ts index 37ef9290b98..1155f49dd98 100644 --- a/src/renderer/src/app-shell/use-global-keybindings.ts +++ b/src/renderer/src/app-shell/use-global-keybindings.ts @@ -18,7 +18,11 @@ import { } from '../components/right-sidebar/file-explorer/content-search/file-search-include-pattern' import { usePluginCommands } from '@/store/plugins/catalog/plugin-contribution-catalog' import { useAppStore } from '../store/index' -import { keybindingMatchesAction, type KeybindingActionId } from '../../../shared/keybindings' +import { + keybindingMatchesAction, + type KeybindingActionId, + type PhysicalModifierToken +} from '../../../shared/keybindings' import { PLUGIN_COMMAND_ALIAS_ACTION_IDS } from '../../../shared/plugins/contributions/plugin-command-actions' import { ModifierDoubleTapDetector, @@ -227,7 +231,11 @@ export function useGlobalKeybindings(args: { ) { return } - if (matchAppShortcut('view.plugins') && handlers.get('view.plugins')?.()) { + if ( + input.key !== 'Dead' && + matchAppShortcut('view.plugins') && + handlers.get('view.plugins')?.() + ) { return } for (const actionId of PLUGIN_COMMAND_ALIAS_ACTION_IDS) { @@ -264,17 +272,17 @@ export function useGlobalKeybindings(args: { Date.now() ) } - const onKeyDown = (e: KeyboardEvent): void => { + const onKeyDown = (e: KeyboardEvent & { doubleTapModifier?: PhysicalModifierToken }): void => { const detected = detectDoubleTap(e, 'keyDown') if (e.repeat) { return } - if (detected) { + if (detected || e.doubleTapModifier) { // No key/modifier flags: synthetic input matches only DoubleTap bindings. dispatchShortcutInput({ isComposing: e.isComposing, altGraph: e.getModifierState('AltGraph'), - doubleTapModifier: detected.modifier, + doubleTapModifier: detected?.modifier ?? e.doubleTapModifier, target: e.target, defaultPrevented: e.defaultPrevented, preventDefault: () => e.preventDefault() diff --git a/src/renderer/src/attention/unread/unread-badge-count.ts b/src/renderer/src/attention/unread/unread-badge-count.ts index 9266b72d38f..182b80f7c67 100644 --- a/src/renderer/src/attention/unread/unread-badge-count.ts +++ b/src/renderer/src/attention/unread/unread-badge-count.ts @@ -1,8 +1,8 @@ import { filterFolderWorkspacesForVisibleHosts, filterProjectGroupsForVisibleHosts -} from '../../../../shared/sidebar-list/host-filtering' -import { getRenderableFolderWorkspaces } from '../../../../shared/sidebar-list/folder-workspace-lanes' +} from '../../../../shared/sidebar-list/hosts/host-filtering' +import { getRenderableFolderWorkspaces } from '../../../../shared/sidebar-list/folders/folder-workspace-lanes' import { worktreeMatchesVisibleHost } from '@/components/sidebar/worktree-list/listing/visible-worktree-host-scope' import { filterFolderWorkspacesFromOtherDevices, diff --git a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.test.tsx b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.test.tsx index da9c942e194..74029321d34 100644 --- a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.test.tsx +++ b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.test.tsx @@ -1,7 +1,12 @@ // @vitest-environment happy-dom +import { + annotateBrowserPageHandles, + markHydratedBrowserPageHandles +} from '@/store/slices/browser/hosts/browser-initialization-presentation-ownership' import { act, cleanup, render as renderWithProviders, screen } from '@testing-library/react' import { TooltipProvider } from '@/components/ui/tooltip' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RecoveryWindow from './restored-client-hosted-recovery-window' import type { BrowserPage } from '../../../../shared/browser-workspace-types' const mocks = vi.hoisted(() => ({ @@ -9,6 +14,8 @@ const mocks = vi.hoisted(() => ({ publishMetadata: vi.fn(), createBrowserTab: vi.fn(async () => true), restoredPageUnrecovered: false, + realRecoveryWindow: false, + initializationAction: vi.fn(async () => {}), addressBar: { current: null as { value: string @@ -37,8 +44,20 @@ vi.mock('./browser-client-page-renderer-installation', () => ({ attachBrowserClientPageToViewport: mocks.attach })) -vi.mock('./restored-client-hosted-recovery-window', () => ({ - useRestoredClientHostedRecoveryWindow: () => mocks.restoredPageUnrecovered +vi.mock('./restored-client-hosted-recovery-window', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useRestoredClientHostedRecoveryWindow: ( + input: Parameters[0] + ) => { + const elapsed = actual.useRestoredClientHostedRecoveryWindow(input) + return mocks.realRecoveryWindow ? elapsed : mocks.restoredPageUnrecovered + } + } +}) +vi.mock('./navigate/browser-page-initialization-action', () => ({ + routeBrowserPageInitializationAction: mocks.initializationAction })) vi.mock('./assemble-chrome/BrowserAddressBar', () => ({ @@ -53,13 +72,30 @@ vi.mock('./assemble-chrome/BrowserAddressBar', () => ({ } })) +import { connectedRuntimeStatus } from '@/runtime/rpc/testing/paired-runtime-environment-test-fixture' import { useAppStore } from '@/store' import { normalizeBrowserNavigationUrl } from '../../../../shared/browser-url' import { requestBrowserFocus } from './host-guest/browser-focus' import { installClientHostedPaneApi, + paneChannel, reportClientHostedGuestMetadata } from './client-hosted-browser-pane-test-rig' +import { + captureBrowserInitializationDelivery, + installBrowserInitializationPresentationLifetime, + recordBrowserInitializationReceipt +} from '@/runtime/web-session-tabs-sync/browser-initialization-presentation' +import { applyWebSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync/snapshot-api' +import { nextReceivedSessionTabsFrame } from '@/runtime/web-session-tabs-sync/state' +import { BrowserHostLeaseRegistry } from '../../../../main/runtime/browser-host/leases/browser-host-lease-registry' +import { RuntimeBrowserPageRegistry } from '../../../../main/runtime/browser/client-pages/runtime-browser-page-registry' +import { recoverUnavailableRuntimeBrowserClientPages } from '../../../../main/runtime/browser/client-pages/runtime-browser-client-page-recovery' +import { buildRuntimeClientBrowserTabs } from '../../../../main/runtime/session-lifecycle/projection/build-runtime-client-browser-tabs' +import { RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT } from '../../../../main/runtime/browser/client-pages/client-hosted-browser-page-persistence' +import { BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime/control/remote-runtime-shared-control-types' import { ClientHostedBrowserPagePane } from './ClientHostedBrowserPagePane' function render(ui: React.ReactElement<{ browserTab: BrowserPage }>) { @@ -70,13 +106,304 @@ function render(ui: React.ReactElement<{ browserTab: BrowserPage }>) { describe('ClientHostedBrowserPagePane', () => { beforeEach(() => { + useAppStore.setState({ remoteBrowserPageHandlesByPageId: markHydratedBrowserPageHandles({}) }) mocks.attach.mockReset() mocks.restoredPageUnrecovered = false + mocks.realRecoveryWindow = false + mocks.initializationAction.mockClear() mocks.publishMetadata.mockReset().mockResolvedValue({ status: 'published', accepted: true }) // Download, popup and permission notices subscribe on mount; each has its own suite. installClientHostedPaneApi({ browser: { publishClientPageMetadata: mocks.publishMetadata } }) }) - afterEach(() => cleanup()) + afterEach(() => { + cleanup() + vi.useRealTimers() + }) + + it.each([null, { ...PLACEMENT, pageHostGeneration: 0 }])( + 'keeps owned recovery usable beyond the legacy absence window with placement %j', + async (placement) => { + vi.useFakeTimers() + mocks.realRecoveryWindow = true + useAppStore.setState({ + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles( + { + 'page-a': { + environmentId: 'environment-a', + remotePageId: 'page-a', + restoredFromSession: true, + initializationPresentation: { + disposition: 'pending', + receivedFrame: 1, + delivery: captureBrowserInitializationDelivery('environment-a') + }, + placement: placement ?? { ...PLACEMENT, pageHostGeneration: 0 } + } + }, + ['page-a'] + ), + runtimeStatusByEnvironmentId: new Map([ + ['environment-a', connectedRuntimeStatus('runtime-a', 1)] + ]) + }) + const pending = { + ...page(), + url: 'https://example.internal/saved', + loading: true + } + const pane = (browserTab: BrowserPage, currentPlacement = placement) => ( + + ) + const view = render(pane(pending)) + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(screen.queryByText('Reopen')).toBeNull() + expect(screen.getByLabelText('Address').value).toBe( + 'https://example.internal/saved' + ) + expect(mocks.attach).not.toHaveBeenCalled() + act(() => screen.getByRole('button', { name: 'Stop' }).click()) + // The restored sentinel handle supplies the exact pending owner even before placement arrives. + expect(mocks.initializationAction).toHaveBeenLastCalledWith( + expect.objectContaining({ action: { type: 'stop' } }) + ) + act(() => + useAppStore.setState((s) => ({ + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles( + { + ...s.remoteBrowserPageHandlesByPageId, + 'page-a': { + ...s.remoteBrowserPageHandlesByPageId['page-a'], + initializationPresentation: { + disposition: 'stopped', + receivedFrame: 2, + delivery: captureBrowserInitializationDelivery('environment-a') + } + } + }, + ['page-a'] + ) + })) + ) + const stopped = { ...pending, loading: false } + view.rerender(pane(stopped)) + expect(screen.queryByText('Reopen')).toBeNull() + expect(screen.getByRole('button', { name: 'Reload' })).not.toBeNull() + const { webview } = createWebview() + mocks.attach.mockReturnValue(retainedAttachment(webview)) + act(() => + useAppStore.setState((s) => ({ + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles( + { + ...s.remoteBrowserPageHandlesByPageId, + 'page-a': { + ...s.remoteBrowserPageHandlesByPageId['page-a'], + initializationPresentation: undefined + } + }, + ['page-a'] + ) + })) + ) + view.rerender(pane(stopped, PLACEMENT)) + expect(mocks.attach).toHaveBeenCalled() + } + ) + + it.each([false, true])( + 'revokes a projected held page on actual transport loss and waits for a fresh page before attaching (null placement %s)', + async (nullPlacement) => { + const diagnostics = paneChannel<{ + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + }>() + installClientHostedPaneApi({ + runtimeEnvironments: { onSharedControlDiagnostics: diagnostics.subscribe } + }) + useAppStore.setState({ + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + remoteBrowserPageHandlesByPageId: markHydratedBrowserPageHandles({}), + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + runtimeStatusByEnvironmentId: new Map() + }) + const stopLifetime = installBrowserInitializationPresentationLifetime() + const authority = new BrowserHostLeaseRegistry({ + authorityRuntimeId: 'runtime-a', + authorityEpoch: 'epoch-a' + }) + const host = authority.attach({ + browserHostClientId: 'host-a', + pairedDeviceId: 'device-a', + connectionId: 'connection-a', + hostCapabilities: ['webview', 'automation', BROWSER_CLIENT_PAGE_INITIALIZATION_CAPABILITY], + pageCommandProtocolVersion: 1, + pageInventoryProtocolVersion: 1, + pageReconciliationProtocolVersion: 1, + pageInventory: [] + }) + const pages = new RuntimeBrowserPageRegistry() + pages.publishClientPage({ + browserPageId: 'page-a', + workspaceId: 'worktree-a', + browserProfileId: 'default', + executionHostKey: 'old-key', + pairedDeviceId: 'device-a', + placement: RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT, + url: 'https://example.internal/s-final', + loading: false, + active: true + }) + const route = Promise.withResolvers<{ status: 'workspace-gone' }>() + await recoverUnavailableRuntimeBrowserClientPages({ + authority, + pages, + lease: host.lease, + notifyWorkspace: () => {}, + resolveExecutionHostKey: () => route.promise + }) + const frame = (): RuntimeMobileSessionTabsResult => ({ + worktree: 'worktree-a', + publicationEpoch: 'epoch-a', + snapshotVersion: 1, + activeGroupId: 'group-a', + activeTabId: 'page-a', + activeTabType: 'browser', + tabs: buildRuntimeClientBrowserTabs(pages.listPages(), authority) + }) + const apply = (snapshot: RuntimeMobileSessionTabsResult) => { + recordBrowserInitializationReceipt( + 'environment-a', + snapshot, + nextReceivedSessionTabsFrame() + ) + act(() => + useAppStore.setState((state) => + applyWebSessionTabsSnapshot(state, snapshot, 'environment-a') + ) + ) + } + const emit = (state: 'closed' | 'reconnecting' | 'ready', generation = 1) => + act(() => + diagnostics.emit({ + environmentId: 'environment-a', + transportGeneration: generation, + diagnostics: { + state, + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 0, + lastConnectedAt: 1, + lastClose: null, + lastError: null + } + }) + ) + const cached = frame() + apply(cached) + function CurrentPane() { + const current = useAppStore((state) => state.browserPagesByWorkspace['page-a']?.[0]) + const handle = useAppStore((state) => state.remoteBrowserPageHandlesByPageId['page-a']) + return current ? ( + + useAppStore.getState().updateBrowserPageState(id, patch) + } + onSetUrl={vi.fn()} + /> + ) : null + } + const view = renderWithProviders(, { wrapper: TooltipProvider }) + try { + emit('ready') + expect(screen.getByRole('button', { name: 'Stop' })).not.toBeNull() + expect(screen.queryByText('Client-hosted browser unavailable')).toBeNull() + expect(mocks.attach).not.toHaveBeenCalled() + const delayed = frame() + recordBrowserInitializationReceipt('environment-a', delayed, nextReceivedSessionTabsFrame()) + emit('closed') + expect(screen.getByText('Client-hosted browser unavailable')).not.toBeNull() + expect(screen.queryByText(/Can't reach/)).toBeNull() + expect(screen.queryByRole('button', { name: 'Stop' })).toBeNull() + expect(screen.getByLabelText('Address').value).toBe( + 'https://example.internal/s-final' + ) + const afterLoss = useAppStore.getState() + emit('reconnecting') + expect(useAppStore.getState()).toBe(afterLoss) + emit('ready', 2) + apply(cached) + apply(delayed) + expect(screen.getByText('Client-hosted browser unavailable')).not.toBeNull() + apply(frame()) + expect(screen.queryByText('Client-hosted browser unavailable')).toBeNull() + expect(screen.getByRole('button', { name: 'Stop' })).not.toBeNull() + emit('closed', 1) + expect(screen.queryByText('Client-hosted browser unavailable')).toBeNull() + // Actual guest metadata supplies current document failure after initialization omission. + const { webview } = createWebview() + mocks.attach.mockReturnValue(retainedAttachment(webview)) + reportClientHostedGuestMetadata({ + webContentsId: 7, + url: 'https://example.internal/s-final', + errorPage: true, + loadError: { + code: -105, + description: 'Name resolution failed', + validatedUrl: 'https://example.internal/s-final' + } + }) + const committed = frame() + committed.snapshotVersion = 2 + committed.tabs = committed.tabs.map((tab) => + tab.type === 'browser' + ? { ...tab, initializationState: undefined, placement: PLACEMENT, loading: false } + : tab + ) + if (!nullPlacement) { + apply(committed) + expect(screen.getAllByRole('button', { name: 'Retry' })).toHaveLength(2) + expect(useAppStore.getState().browserPagesByWorkspace['page-a'][0].loadError?.code).toBe( + -105 + ) + apply({ ...committed, snapshotVersion: 3 }) + expect(screen.getAllByRole('button', { name: 'Retry' })).toHaveLength(2) + expect(useAppStore.getState().browserPagesByWorkspace['page-a'][0].loadError?.code).toBe( + -105 + ) + } + } finally { + view.unmount() + stopLifetime() + authority.cancelClientPageInitialization('page-a', RESTORED_CLIENT_HOSTED_BROWSER_PLACEMENT) + route.resolve({ status: 'workspace-gone' }) + host.release() + } + } + ) it('searches non-URL address input like the local pane instead of forcing a host', () => { const { webview } = createWebview() diff --git a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx index ccbf8b8594f..92032bafc36 100644 --- a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx +++ b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx @@ -1,3 +1,4 @@ +import { useClientHostedPageInitialization } from './navigate/use-client-hosted-page-initialization' import type { BrowserGuestMetadata } from '../../../../shared/browser/guest-lifecycle/browser-guest-metadata' import { useCallback, useEffectEvent, useLayoutEffect, useRef, useState } from 'react' import { useBrowserAgentCursor } from './agent-cursor/use-browser-agent-cursor' @@ -21,7 +22,6 @@ import { useBrowserClientHostedPermissionNotices } from './browser-client-hosted import { useClientHostedBrowserIntroTour } from './use-client-hosted-browser-intro-tour' import { ClientHostedBrowserUnavailableNotice } from './client-hosted-browser-unavailable-notice' import { watchBrowserClientPageGuestLoss } from './host-guest/browser-client-page-guest-loss' -import { useRestoredClientHostedRecoveryWindow } from './restored-client-hosted-recovery-window' import { useClientHostedBrowserMarkup } from './annotate/use-client-hosted-browser-markup' import BrowserFind from './assemble-chrome/BrowserFind' import { BrowserNavigationControlRow } from './assemble-chrome/browser-navigation-control-row' @@ -43,22 +43,23 @@ import { BrowserNewTabPage } from './new-tab/BrowserNewTabPage' import { useBrowserPageReloadActions } from './navigate/use-browser-page-reload-actions' import { consumeBrowserPageDeferredNavigation } from './navigate/browser-page-deferred-navigation' import { getBrowserDisplayTitle, toDisplayUrl } from './describe-page/browser-page-url-display' -import type { ClientHostedBrowserPagePaneProps } from './describe-page/browser-page-types' +import type { ClientHostedBrowserPagePaneProps as Props } from './describe-page/browser-page-types' import { BROWSER_DEVTOOLS_DOCK_SPLIT_CLASS_NAME } from './host-guest/browser-page-viewport' import { BrowserDevToolsDock } from './devtools-dock/BrowserDevToolsDock' import { useBrowserDevToolsDockPageFlex } from './devtools-dock/browser-devtools-docked-pages' -export function ClientHostedBrowserPagePane({ - browserTab, - workspaceId, - runtimeEnvironmentId, - worktreeId, - placement, - isActive, - chromeShortcutScope, - onUpdatePageState, - onSetUrl -}: ClientHostedBrowserPagePaneProps): React.JSX.Element { +export function ClientHostedBrowserPagePane(props: Props) { + const { + browserTab, + workspaceId, + runtimeEnvironmentId, + worktreeId, + placement, + isActive, + chromeShortcutScope, + onUpdatePageState, + onSetUrl + } = props const viewportRef = useRef(null) const devToolsPageFlex = useBrowserDevToolsDockPageFlex(browserTab.id) const webviewRef = useRef(null) @@ -71,17 +72,11 @@ export function ClientHostedBrowserPagePane({ const [findOpen, setFindOpen] = useState(false) const updatePageStateFromGuest = useEffectEvent(onUpdatePageState) const setUrlFromGuest = useEffectEvent(onSetUrl) - const addBrowserHistoryEntry = useAppStore((s) => s.addBrowserHistoryEntry) - const recordHistoryFromGuest = useEffectEvent(addBrowserHistoryEntry) + const recordHistoryFromGuest = useEffectEvent(useAppStore((s) => s.addBrowserHistoryEntry)) const browserHostClientId = placement?.browserHostClientId ?? null const browserHostGeneration = placement?.browserHostGeneration ?? null const pageHostGeneration = placement?.pageHostGeneration ?? null - const restoredPageUnrecovered = useRestoredClientHostedRecoveryWindow({ - browserPageId: browserTab.id, - environmentId: runtimeEnvironmentId, - placementPending: placement === null - }) - // Main owns this guest; guest loss requires reopening on the server rather than local recreation. + const initialization = useClientHostedPageInitialization(props, attachmentError, loadFailureRef) const retryGuestRecoveryRef = useRef<() => void>(() => {}) useLayoutEffect(() => { onUpdatePageStateRef.current = onUpdatePageState @@ -109,7 +104,8 @@ export function ClientHostedBrowserPagePane({ }) const zoom = useBrowserPageZoomFeedback() const reload = useBrowserPageReloadActions({ - browserTab, + browserTab: initialization.reloadPage, + onInitializationAction: initialization.initializationAction, webviewRef, retryGuestRecoveryRef, onUpdatePageStateRef @@ -130,6 +126,7 @@ export function ClientHostedBrowserPagePane({ const navigateToUrl = useClientHostedPageUrlSubmission({ browserTabId: browserTab.id, worktreeId, + onInitializationAction: initialization.initializationAction, webviewRef, activeLoadFailureRef: loadFailureRef, onUpdatePageState, @@ -142,7 +139,6 @@ export function ClientHostedBrowserPagePane({ .getState() .browserPagesByWorkspace[workspaceId]?.find((page) => page.id === browserTab.id) ) - // The guest lives in a body-level host that paints above the pane, so the cursor joins it there. const getAgentCursorHost = useCallback( () => webviewRef.current?.parentElement ?? null, [webviewRef] @@ -151,7 +147,10 @@ export function ClientHostedBrowserPagePane({ useLayoutEffect(() => { const viewport = viewportRef.current - // Wait for host adoption before attaching an optimistic page the registry has not seen. + if (initialization.suppressAttachment) { + setAttachmentError(null) + return + } if ( !viewport || pageHostGeneration === null || @@ -297,6 +296,7 @@ export function ClientHostedBrowserPagePane({ return cleanupGuest }, [ browserTab.id, + initialization.suppressAttachment, browserHostClientId, browserHostGeneration, pageHostGeneration, @@ -304,28 +304,21 @@ export function ClientHostedBrowserPagePane({ setAddressBarValueFromPage ]) - const unavailable = Boolean(attachmentError) || restoredPageUnrecovered - const showFailureOverlay = !unavailable && Boolean(browserTab.loadError) - const markup = useClientHostedBrowserMarkup({ webviewRef, browserPageId: browserTab.id, runtimeEnvironmentId, placement, isActive, - unavailable, - showFailureOverlay + unavailable: initialization.unavailable, + showFailureOverlay: initialization.showFailureOverlay }) - // Unrecovered pages have no pending load that could ever clear a loading indicator. - const loading = !restoredPageUnrecovered && (placement === null || browserTab.loading) - return (

- {/* IPC-driven context menu in a Portal so position:fixed escapes ancestor transform/backdrop-filter containing blocks. */} } - loading={loading} + loading={initialization.loading} loadFailed={browserTab.loadError !== null} /> } @@ -390,21 +383,21 @@ export function ClientHostedBrowserPagePane({ guestGeneration={pageHostGeneration} /> - {showFailureOverlay ? ( + {initialization.showFailureOverlay ? ( reload.runReloadTrigger('reload')} /> ) : null} - {unavailable ? ( + {initialization.unavailable ? ( - ) : isBlankBrowserUrl(browserTab.url) ? ( + ) : initialization.showNewTab ? ( ) : null}
diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx index 3ddfe2b5c6d..e223b76fedf 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx @@ -136,6 +136,7 @@ vi.mock('../browser-client-page-renderer-installation', () => ({ })) import { useAppStore } from '@/store' +import { annotateBrowserPageHandles } from '@/store/slices/browser/hosts/browser-initialization-presentation-ownership' import { TooltipProvider } from '@/components/ui/tooltip' import { installClientHostedPaneApi } from '../client-hosted-browser-pane-test-rig' import { clearBrowserAddressBarEditSession } from './browser-address-bar-edit-session' @@ -227,59 +228,74 @@ function browserWorkspace(): BrowserWorkspace { /** The optimistic handle a staged tab carries: a page id the host has not published yet. */ function stageHandle(): void { useAppStore.setState({ - remoteBrowserPageHandlesByPageId: { - [PAGE_ID]: { environmentId: ENVIRONMENT_ID, remotePageId: 'remote-page-a', staged: true } - } + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles( + { + [PAGE_ID]: { environmentId: ENVIRONMENT_ID, remotePageId: 'remote-page-a', staged: true } + }, + [PAGE_ID] + ) }) } /** The optimistic handle for a page this client already expects to host itself. */ function stageClientHostedHandle(): void { useAppStore.setState({ - remoteBrowserPageHandlesByPageId: { - [PAGE_ID]: { - environmentId: ENVIRONMENT_ID, - remotePageId: 'remote-page-a', - staged: true, - stagedClientHosted: true - } - } + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles( + { + [PAGE_ID]: { + environmentId: ENVIRONMENT_ID, + remotePageId: 'remote-page-a', + staged: true, + stagedClientHosted: true + } + }, + [PAGE_ID] + ) }) } /** Restored client-hosted pages have a real host ID but no placement until host recovery. */ function restoreClientHostedHandle(): void { useAppStore.setState({ - remoteBrowserPageHandlesByPageId: { - [PAGE_ID]: { - environmentId: ENVIRONMENT_ID, - remotePageId: 'remote-page-a', - restoredFromSession: true, - restoredClientHosted: true - } - } + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles( + { + [PAGE_ID]: { + environmentId: ENVIRONMENT_ID, + remotePageId: 'remote-page-a', + restoredFromSession: true, + restoredClientHosted: true + } + }, + [PAGE_ID] + ) }) } /** The host snapshot arriving: the staged flag drops and the page lands on this desktop. */ function adoptOntoClient(pageHostGeneration = CLIENT_PLACEMENT.pageHostGeneration): void { useAppStore.setState({ - remoteBrowserPageHandlesByPageId: { - [PAGE_ID]: { - environmentId: ENVIRONMENT_ID, - remotePageId: 'remote-page-a', - placement: { ...CLIENT_PLACEMENT, pageHostGeneration } - } - } + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles( + { + [PAGE_ID]: { + environmentId: ENVIRONMENT_ID, + remotePageId: 'remote-page-a', + placement: { ...CLIENT_PLACEMENT, pageHostGeneration } + } + }, + [PAGE_ID] + ) }) } /** The same snapshot for a headless host: the page stays streamed, so the pane never swaps. */ function adoptOntoServer(): void { useAppStore.setState({ - remoteBrowserPageHandlesByPageId: { - [PAGE_ID]: { environmentId: ENVIRONMENT_ID, remotePageId: 'remote-page-a' } - } + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles( + { + [PAGE_ID]: { environmentId: ENVIRONMENT_ID, remotePageId: 'remote-page-a' } + }, + [PAGE_ID] + ) }) } diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-shortcut.ts b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-shortcut.ts new file mode 100644 index 00000000000..3cb3b1f91be --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-shortcut.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react' +import { useAppStore } from '@/store' +import { getShortcutPlatform } from '@/hooks/useShortcutLabel' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/keyboard/app-keyboard-router' +import { keybindingMatchesAction } from '../../../../../shared/keybindings' +import { browserChromeShortcutOwnsEvent } from '../describe-page/browser-overlay-shortcut-target' +import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types' + +/** The focus-address-bar chord while Orca's chrome has the keyboard. */ +export function useBrowserAddressBarShortcut({ + workspaceId, + chromeShortcutScope, + focusAddressBarNow +}: { + workspaceId: string + chromeShortcutScope: BrowserChromeShortcutScope + focusAddressBarNow: () => boolean +}): void { + const keybindings = useAppStore((s) => s.keybindings) + + // Guest-focused chords arrive through IPC; capture handles chrome-focused chords before workspace/editor listeners. + useEffect(() => { + if (chromeShortcutScope === 'inactive') { + return + } + const shortcutPlatform = getShortcutPlatform() + const handleKeyDown = (event: KeyboardEvent): void => { + if ( + !keybindingMatchesAction('browser.focusAddressBar', event, shortcutPlatform, keybindings) + ) { + return + } + if (!browserChromeShortcutOwnsEvent(chromeShortcutScope, event, workspaceId)) { + return + } + event.preventDefault() + // All panes listen on window capture; stopPropagation alone leaves sibling listeners active. + event.stopImmediatePropagation() + focusAddressBarNow() + } + addAppKeyListener('keydown', handleKeyDown) + return () => removeAppKeyListener('keydown', handleKeyDown) + }, [chromeShortcutScope, focusAddressBarNow, keybindings, workspaceId]) +} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx new file mode 100644 index 00000000000..098e4f425eb --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment happy-dom +import { act, cleanup, render } from '@testing-library/react' +import { StrictMode, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/store', () => { + const state = { + consumeAddressBarFocusRequest: () => false, + keybindings: {}, + setBrowserHistoryPageId: () => {}, + browserHistoryPageId: null + } + return { + useAppStore: Object.assign((select: (s: typeof state) => unknown) => select(state), { + getState: () => state + }) + } +}) + +import { useBrowserPageChromeFocus } from './use-browser-page-chrome-focus' +import type { BrowserPageGuestFocus } from './browser-page-guest-focus' +import { acquireWebviewsDragPassthrough } from '../host-guest/webview-drag-passthrough' + +let frames = new Map() +let nextFrameId = 0 + +function flushFrame(): void { + const pending = [...frames.values()] + frames = new Map() + act(() => pending.forEach((callback) => callback(0))) +} + +function PageHarness({ guestFocus }: { guestFocus: BrowserPageGuestFocus }) { + const addressBarInputRef = useRef(null) + useBrowserPageChromeFocus({ + browserTabId: 'page-1', + workspaceId: 'workspace-1', + isActive: true, + chromeShortcutScope: 'focused', + addressBarInputRef, + guestFocus + }) + return +} + +/** A guest that attaches late and takes real DOM focus, so a test can observe a steal. */ +function createLateGuest(): { guestFocus: BrowserPageGuestFocus; attach: () => void } { + let attached = false + const target = document.createElement('button') + document.body.append(target) + return { + attach: () => { + attached = true + }, + guestFocus: { + isAttached: () => attached, + blur: () => target.blur(), + focus: () => { + target.focus() + return document.activeElement === target + } + } + } +} + +beforeEach(() => { + frames = new Map() + nextFrameId = 0 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.set(++nextFrameId, callback) + return nextFrameId + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => { + frames.delete(id) + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ui: { onFocusBrowserAddressBar: () => () => {}, onShowBrowserHistory: () => () => {} } + } + }) +}) + +afterEach(() => { + cleanup() + document.body.replaceChildren() + vi.restoreAllMocks() +}) + +describe('useBrowserPageChromeFocus activation', () => { + it('hands the keyboard to a page that remounts active before its guest reattaches', () => { + // Closing Settings remounts the active page; its retained guest reattaches a frame later. + let attached = false + const focus = vi.fn(() => true) + const guestFocus = { blur: vi.fn(), focus, isAttached: () => attached } + + render( + + + + ) + attached = true + flushFrame() + + expect(focus).toHaveBeenCalledTimes(1) + }) + + it('keeps the address bar the user picked when a drag restarts the waiting activation', () => { + const guest = createLateGuest() + const view = render() + const addressBar = view.getByLabelText('Address') + addressBar.focus() + // The drag lands before the waiting retry's next frame could see the user's choice. + let endDrag = (): void => {} + act(() => { + endDrag = acquireWebviewsDragPassthrough() + }) + guest.attach() + act(() => endDrag()) + flushFrame() + + expect(document.activeElement).toBe(addressBar) + }) + + it('keeps the address bar the user picked while a drag held the activation', () => { + const guest = createLateGuest() + const view = render() + const addressBar = view.getByLabelText('Address') + let endDrag = (): void => {} + act(() => { + endDrag = acquireWebviewsDragPassthrough() + }) + // Focus moves only after the drag cancelled the retry, e.g. a drop into the address bar. + addressBar.focus() + guest.attach() + act(() => endDrag()) + flushFrame() + + expect(document.activeElement).toBe(addressBar) + }) + + it('keeps the address bar the user picked when the page activated mid-drag', () => { + const guest = createLateGuest() + const endDrag = acquireWebviewsDragPassthrough() + const view = render() + const addressBar = view.getByLabelText('Address') + addressBar.focus() + guest.attach() + act(() => endDrag()) + flushFrame() + + expect(document.activeElement).toBe(addressBar) + }) +}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.ts b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.ts index d4b2684c694..fd9c2db61ab 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.ts @@ -8,20 +8,17 @@ import { type RefObject } from 'react' import { useAppStore } from '@/store' -import { getShortcutPlatform } from '@/hooks/useShortcutLabel' -import { keybindingMatchesAction } from '../../../../../shared/keybindings' import { consumeBrowserFocusRequest, queueBrowserFocusRequest, ORCA_BROWSER_FOCUS_REQUEST_EVENT, type BrowserFocusRequestDetail } from '../host-guest/browser-focus' -import { browserChromeShortcutOwnsEvent } from '../describe-page/browser-overlay-shortcut-target' import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types' import type { BrowserAddressBarSelection } from './browser-address-bar-edit-session' import type { BrowserPageGuestFocus } from './browser-page-guest-focus' -import { addAppKeyListener, removeAppKeyListener } from '@/lib/keyboard/app-keyboard-router' import { useBrowserHistoryShortcut } from '../history/use-browser-history-shortcut' +import { useBrowserAddressBarShortcut } from './use-browser-address-bar-shortcut' import { useWebviewDragPassthroughActive } from '../host-guest/use-webview-drag-passthrough-active' /** Frames an address-bar grab keeps retrying before it lets the guest have focus back. */ @@ -57,7 +54,6 @@ export function useBrowserPageChromeFocus({ } { useBrowserHistoryShortcut({ browserTabId, workspaceId, isActive, chromeShortcutScope }) const consumeAddressBarFocusRequest = useAppStore((s) => s.consumeAddressBarFocusRequest) - const keybindings = useAppStore((s) => s.keybindings) const keepAddressBarFocusRef = useRef(false) const addressBarFocusGrabRef = useRef<(() => void) | null>(null) // Why a layout effect: it lands before the previous render's passive cleanups read it. @@ -162,46 +158,26 @@ export function useBrowserPageChromeFocus({ }) }, [browserTabId, focusAddressBarNow, isActive]) - // Guest-focused chords arrive through IPC; capture handles chrome-focused chords before workspace/editor listeners. - useEffect(() => { - if (chromeShortcutScope === 'inactive') { - return - } - const shortcutPlatform = getShortcutPlatform() - const handleKeyDown = (event: KeyboardEvent): void => { - if ( - !keybindingMatchesAction('browser.focusAddressBar', event, shortcutPlatform, keybindings) - ) { - return - } - if (!browserChromeShortcutOwnsEvent(chromeShortcutScope, event, workspaceId)) { - return - } - event.preventDefault() - // All panes listen on window capture; stopPropagation alone leaves sibling listeners active. - event.stopImmediatePropagation() - focusAddressBarNow() - } - addAppKeyListener('keydown', handleKeyDown) - return () => removeAppKeyListener('keydown', handleKeyDown) - }, [chromeShortcutScope, focusAddressBarNow, keybindings, workspaceId]) + useBrowserAddressBarShortcut({ workspaceId, chromeShortcutScope, focusAddressBarNow }) /** Wait for guest attachment; cancellation requeues unfinished active requests unless explicitly disabled. */ const startGuestFocusRetry = useCallback( ({ firstAttempt, - stillWanted = () => true + stillWanted = () => true, + focusAtRequest = document.activeElement }: { firstAttempt: 'now' | 'next-frame' /** Checked before every attempt; false drops the focus for good. */ stillWanted?: () => boolean + /** Focus the request began from; moving off it before the first attempt yields to the user. */ + focusAtRequest?: Element | null }) => { let cancelled = false let settled = false let frameId = 0 let attempts = 0 let waitedFrames = 0 - const focusAtRequest = document.activeElement const runFocus = (): void => { if (cancelled) { return @@ -234,13 +210,15 @@ export function useBrowserPageChromeFocus({ } else { frameId = window.requestAnimationFrame(runFocus) } - return ({ requeue = true }: { requeue?: boolean } = {}): void => { + // Returns whether the retry got to act: tried focus, or settled without it. + return ({ requeue = true }: { requeue?: boolean } = {}): boolean => { cancelled = true window.cancelAnimationFrame(frameId) // Requeue active StrictMode/dependency retries, but drop departed panes so revisiting cannot steal focus. if (requeue && !settled && waitedFrames < GUEST_ATTACH_WAIT_FRAMES && isActiveRef.current) { queueBrowserFocusRequest({ pageId: browserTabId, target: 'webview' }) } + return settled || attempts > 0 } }, [browserTabId, focusGuestNow, guestFocus] @@ -260,7 +238,8 @@ export function useBrowserPageChromeFocus({ // Cancel the bar grab as well as its latch or remaining frames will steal page focus back. cancelAddressBarFocusGrab() // Why a frame first: focus can be queued before the pane mounts and is claimed here. - return startGuestFocusRetry({ firstAttempt: 'next-frame' }) + const cancelGuestFocus = startGuestFocusRetry({ firstAttempt: 'next-frame' }) + return () => void cancelGuestFocus() }, [ browserTabId, cancelAddressBarFocusGrab, @@ -314,13 +293,20 @@ export function useBrowserPageChromeFocus({ ) const dragPassthroughActive = useWebviewDragPassthroughActive(isActive) const focusedForActivationRef = useRef(false) + // Why: a restarted activation keeps the focus it began from, so a user choice made before the restart still wins. + const activationFocusAtRequestRef = useRef<{ element: Element | null } | null>(null) // Why: switching to a page hands it the keyboard, as for terminals (#20533); declared after the // address-bar effects so a new blank tab's grab has already raised the latch. useEffect(() => { if (!isActive) { focusedForActivationRef.current = false + activationFocusAtRequestRef.current = null return } + // Why before the drag guard: a page activated mid-drag still yields to focus the user picks during it. + if (activationFocusAtRequestRef.current === null) { + activationFocusAtRequestRef.current = { element: document.activeElement } + } // Why defer, not consume: focusing a guest mid-drag reads as an aborted drag. if (focusedForActivationRef.current || dragPassthroughActive) { return @@ -329,10 +315,15 @@ export function useBrowserPageChromeFocus({ // Why the focused split only: never pull the keyboard across splits. const cancelGuestFocus = startGuestFocusRetry({ firstAttempt: 'now', + focusAtRequest: activationFocusAtRequestRef.current.element, stillWanted: () => !inputLocked && isSplitFocused() && !keepAddressBarFocusRef.current }) - // Why no requeue: an activation is not a queued request, so leaving the tab drops it. - return () => cancelGuestFocus({ requeue: false }) + return () => { + // Why: no requeue, since leaving the tab drops an activation; but a re-run that cancels it while waiting for the guest to attach (StrictMode remount as Settings closes) must retry. + if (!cancelGuestFocus({ requeue: false })) { + focusedForActivationRef.current = false + } + } }, [dragPassthroughActive, inputLocked, isActive, startGuestFocusRetry]) return { focusAddressBarNow, focusGuestNow, startAddressBarFocusGrab, keepAddressBarFocusRef } diff --git a/src/renderer/src/components/browser-pane/browser-client-page-bootstrap-readiness.ts b/src/renderer/src/components/browser-pane/browser-client-page-bootstrap-readiness.ts new file mode 100644 index 00000000000..9df14a7847f --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-client-page-bootstrap-readiness.ts @@ -0,0 +1,21 @@ +export function observeBrowserClientPageBootstrap( + webview: Electron.WebviewTag, + isAttaching: () => boolean, + ready: () => void +): () => void { + let finished = false + const onFinished = (): void => { + finished = true + } + const onStopped = (): void => { + if (finished && isAttaching()) { + ready() + } + } + webview.addEventListener('did-finish-load', onFinished) + webview.addEventListener('did-stop-loading', onStopped) + return () => { + webview.removeEventListener('did-finish-load', onFinished) + webview.removeEventListener('did-stop-loading', onStopped) + } +} diff --git a/src/renderer/src/components/browser-pane/browser-client-page-retained-host-fixture.ts b/src/renderer/src/components/browser-pane/browser-client-page-retained-host-fixture.ts index c7e2bf18268..1b8edd22c08 100644 --- a/src/renderer/src/components/browser-pane/browser-client-page-retained-host-fixture.ts +++ b/src/renderer/src/components/browser-pane/browser-client-page-retained-host-fixture.ts @@ -63,6 +63,8 @@ export function createRetainedHostFixture(): RetainedHostFixture { const webview = webviews.at(-1)! webview.dataset.attached = 'true' webview.dispatchEvent(new Event('did-attach')) + webview.dispatchEvent(new Event('did-finish-load')) + webview.dispatchEvent(new Event('did-stop-loading')) await mounting }, attach: (identity = RETAINED_FIXTURE_PAGE) => registry.attachPage(identity, container) diff --git a/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts b/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts index 021274a216a..c0fcb28c5db 100644 --- a/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts +++ b/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts @@ -54,6 +54,8 @@ function createRig( function attach(webview: Electron.WebviewTag): void { webview.dataset.attached = 'true' webview.dispatchEvent(new Event('did-attach')) + webview.dispatchEvent(new Event('did-finish-load')) + webview.dispatchEvent(new Event('did-stop-loading')) } afterEach(() => { @@ -122,3 +124,25 @@ describe('browser client page retained registry', () => { expect(registry.getMemoryProfile().retainedPageCount).toBe(0) }) }) + +it('waits for the bootstrap finish and subsequent stop before granting creation readiness', async () => { + const { registry, webviews } = createRig() + const mounting = registry.mountPage(PAGE) + const webview = webviews[0]! + webview.dataset.attached = 'true' + webview.dispatchEvent(new Event('did-attach')) + webview.dispatchEvent(new Event('dom-ready')) + webview.dispatchEvent(new Event('did-stop-loading')) + let available = false + void mounting.then(() => { + available = true + }) + await Promise.resolve() + expect(available).toBe(false) + webview.dispatchEvent(new Event('did-finish-load')) + await Promise.resolve() + expect(available).toBe(false) + webview.dispatchEvent(new Event('did-stop-loading')) + await expect(mounting).resolves.toEqual({ webContentsId: 41 }) + registry.dispose() +}) diff --git a/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.ts b/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.ts index b53e8533884..b94562d3c18 100644 --- a/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.ts +++ b/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.ts @@ -1,3 +1,5 @@ +import { releaseBrowserClientRetainedRendererPage } from './browser-client-page-retained-release' +import { observeBrowserClientPageBootstrap } from './browser-client-page-bootstrap-readiness' import { BrowserClientPageRendererIdentity, type BrowserClientPageRendererIdentity as RendererPageIdentity @@ -121,6 +123,7 @@ export class BrowserClientPageRetainedRegistry { return } const wasAttaching = page.status === 'attaching' + page.releasePreparation() page.status = 'retiring' clearTimeout(page.attachTimer) if (wasAttaching) { @@ -177,7 +180,11 @@ export class BrowserClientPageRetainedRegistry { }) const page = {} as RetainedPage const onAttached = (): void => this.observeAttachment(page) - const onReady = (): void => this.finishAttachment(page) + const releasePreparation = observeBrowserClientPageBootstrap( + webview, + () => page.status === 'attaching', + () => this.finishAttachment(page) + ) const onDestroyed = (): void => this.handleDestroyed(page) const onRendererGone = (): void => this.fenceRendererLoss(page) Object.assign(page, { @@ -198,7 +205,7 @@ export class BrowserClientPageRetainedRegistry { this.attachTimeoutMs ), onAttached, - onReady, + releasePreparation, onDestroyed, onRendererGone }) @@ -206,7 +213,6 @@ export class BrowserClientPageRetainedRegistry { page.releaseDragPassthroughSurface = enrolRetainedHostDragPassthrough(page) page.releasePaintRetention = retainBrowserClientPagePaint(page) webview.addEventListener('did-attach', onAttached) - webview.addEventListener('dom-ready', onReady) webview.addEventListener('destroyed', onDestroyed) webview.addEventListener('render-process-gone', onRendererGone) webview.addEventListener('load-commit', page.onLoadCommit) @@ -226,7 +232,6 @@ export class BrowserClientPageRetainedRegistry { return } page.attachmentObserved = true - this.finishAttachment(page) } private finishAttachment(page: RetainedPage): void { @@ -243,6 +248,7 @@ export class BrowserClientPageRetainedRegistry { return } clearTimeout(page.attachTimer) + page.releasePreparation() page.status = 'attached' page.webContentsId = webContentsId page.resolveMount({ webContentsId }) @@ -252,6 +258,7 @@ export class BrowserClientPageRetainedRegistry { if (this.pages.get(page.key) !== page || page.status !== 'attaching') { return } + page.releasePreparation() page.status = 'retiring' clearTimeout(page.attachTimer) page.rejectMount(new Error(errorCode)) @@ -268,6 +275,7 @@ export class BrowserClientPageRetainedRegistry { if (page.status === 'attaching') { page.rejectMount(new Error('browser_client_page_renderer_process_gone')) } + page.releasePreparation() page.status = 'retiring' clearTimeout(page.attachTimer) this.disconnectPage(page) @@ -288,18 +296,7 @@ export class BrowserClientPageRetainedRegistry { return } retireBrowserGuestMetadata(page.webview, page.webContentsId) - clearTimeout(page.attachTimer) - page.releaseDragPassthroughSurface() - page.releasePaintRetention() - page.visibleAttachment?.stopTrackingViewport() - page.webview.removeEventListener('did-attach', page.onAttached) - page.webview.removeEventListener('dom-ready', page.onReady) - page.webview.removeEventListener('destroyed', page.onDestroyed) - page.webview.removeEventListener('render-process-gone', page.onRendererGone) - page.webview.removeEventListener('load-commit', page.onLoadCommit) - page.visibleAttachment = null - page.webview.remove() - page.host.remove() + releaseBrowserClientRetainedRendererPage(page) this.pages.delete(page.key) const nextCount = (this.partitionCounts.get(page.identity.partition) ?? 1) - 1 if (nextCount > 0) { diff --git a/src/renderer/src/components/browser-pane/browser-client-page-retained-release.ts b/src/renderer/src/components/browser-pane/browser-client-page-retained-release.ts new file mode 100644 index 00000000000..932ba993104 --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-client-page-retained-release.ts @@ -0,0 +1,16 @@ +import type { BrowserClientRetainedRendererPage as RetainedPage } from './browser-client-page-retained-state' + +export function releaseBrowserClientRetainedRendererPage(page: RetainedPage): void { + clearTimeout(page.attachTimer) + page.releasePreparation() + page.releaseDragPassthroughSurface() + page.releasePaintRetention() + page.visibleAttachment?.stopTrackingViewport() + page.webview.removeEventListener('did-attach', page.onAttached) + page.webview.removeEventListener('destroyed', page.onDestroyed) + page.webview.removeEventListener('render-process-gone', page.onRendererGone) + page.webview.removeEventListener('load-commit', page.onLoadCommit) + page.visibleAttachment = null + page.webview.remove() + page.host.remove() +} diff --git a/src/renderer/src/components/browser-pane/browser-client-page-retained-state.ts b/src/renderer/src/components/browser-pane/browser-client-page-retained-state.ts index 78c99308afc..a8936a479b4 100644 --- a/src/renderer/src/components/browser-pane/browser-client-page-retained-state.ts +++ b/src/renderer/src/components/browser-pane/browser-client-page-retained-state.ts @@ -15,7 +15,7 @@ export type BrowserClientRetainedRendererPage = { rejectMount: (error: Error) => void attachTimer: ReturnType onAttached: EventListener - onReady: EventListener + releasePreparation: () => void onDestroyed: EventListener onRendererGone: EventListener onLoadCommit: (event: Electron.LoadCommitEvent) => void diff --git a/src/renderer/src/components/browser-pane/navigate/browser-page-deferred-navigation.ts b/src/renderer/src/components/browser-pane/navigate/browser-page-deferred-navigation.ts index 5bc5b5cb287..a36d22aa627 100644 --- a/src/renderer/src/components/browser-pane/navigate/browser-page-deferred-navigation.ts +++ b/src/renderer/src/components/browser-pane/navigate/browser-page-deferred-navigation.ts @@ -1,12 +1,13 @@ // Stage typed URLs by stable page ID until creation finishes; early RPCs report page-missing and can close the optimistic tab. -const deferredNavigationsByPageId = new Map() +type Navigation = { url: string; at: number } | { token: symbol } +const deferredNavigationsByPageId = new Map() // Bound abandoned creates; allow the 30s create RPC time to reconcile before expiring the URL. const DEFERRED_NAVIGATION_TTL_MS = 60_000 function purgeExpiredDeferredNavigations(now: number): void { for (const [pageId, entry] of deferredNavigationsByPageId) { - if (now - entry.at >= DEFERRED_NAVIGATION_TTL_MS) { + if ('at' in entry && now - entry.at >= DEFERRED_NAVIGATION_TTL_MS) { deferredNavigationsByPageId.delete(pageId) } } @@ -23,7 +24,7 @@ export function consumeBrowserPageDeferredNavigation(pageId: string): string | n const now = Date.now() purgeExpiredDeferredNavigations(now) const entry = deferredNavigationsByPageId.get(pageId) - if (!entry) { + if (!entry || !('url' in entry)) { return null } deferredNavigationsByPageId.delete(pageId) @@ -33,3 +34,23 @@ export function consumeBrowserPageDeferredNavigation(pageId: string): string | n export function clearBrowserPageDeferredNavigation(pageId: string): void { deferredNavigationsByPageId.delete(pageId) } + +/** Protect only the local model while this bounded intent RPC is outstanding; never replay it on mount. */ +export function beginBrowserPageInitializationAction(pageId: string): symbol { + const token = Symbol(pageId) + deferredNavigationsByPageId.set(pageId, { token }) + return token +} + +export function hasBrowserPageInitializationAction(pageId: string, token?: symbol): boolean { + const entry = deferredNavigationsByPageId.get(pageId) + return Boolean(entry && 'token' in entry && (token === undefined || entry.token === token)) +} + +export function endBrowserPageInitializationAction(pageId: string, token?: symbol): boolean { + if (!hasBrowserPageInitializationAction(pageId, token)) { + return false + } + deferredNavigationsByPageId.delete(pageId) + return true +} diff --git a/src/renderer/src/components/browser-pane/navigate/browser-page-initialization-action.test.tsx b/src/renderer/src/components/browser-pane/navigate/browser-page-initialization-action.test.tsx new file mode 100644 index 00000000000..b481aaeabcb --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/browser-page-initialization-action.test.tsx @@ -0,0 +1,630 @@ +import { copyBrowserPageHandles } from '@/store/slices/browser/hosts/browser-initialization-presentation-ownership' +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + createSessionTabsTabDeltaEncoder, + createSessionTabsTabDeltaDecoder +} from '../../../../../shared/session-tabs-tab-delta' +import { SESSION_TABS_TAB_DELTA_RUNTIME_CAPABILITY } from '../../../../../shared/session-tabs-runtime-capabilities' +import { buildPersistedBrowserPagesByWorkspace } from '@/lib/workspace/persistence/workspace-session-browser-tabs' +import { useAppStore } from '@/store' +import type { RemoteBrowserPageHandle } from '@/store/slices/browser/browser-slice-contract' +import type { RuntimeMobileSessionTabsResult } from '../../../../../shared/runtime-session-contracts' +import { applyWebRuntimeSessionTabsAnswer } from '@/runtime/web-session-tabs-sync/inventory/web-runtime-session-snapshot' +import { createSessionWriteSubscriber } from '@/lib/workspace/persistence/session-write-subscriber' +import { installBrowserInitializationPresentationLifetime } from '@/runtime/web-session-tabs-sync/browser-initialization-presentation' +import { listRemoteRuntimeSessionTabsDeduped } from '@/runtime/web-session-tabs-sync/inventory/remote-runtime-session-tabs-inflight' +import { handleGlobalSessionEvent } from '@/runtime/web-session-tabs-sync/global-session-events' +import { VisibilityResumeCoordinator } from '@/runtime/web-session-tabs-sync/visibility-resume-coordinator' +import { getWebSessionTabsTrackingGeneration } from '@/runtime/web-session-tabs-sync/tracking-lifecycle' +import { + getRuntimeEnvironmentRevision, + replaceRuntimeEnvironmentRevisions +} from '@/runtime/rpc/environment-revisions/runtime-environment-revision' +import { nextReceivedSessionTabsFrame } from '@/runtime/web-session-tabs-sync/state' +import { resetBrowserClientHostIdForTests } from '@/runtime/browser/client-hosting/browser-client-host-identity' +import { + ENV, + WT +} from '@/runtime/web-session-tabs-sync/hydration/testing/host-session-mirror-frame-fixtures' +import { + installFrameOrderingHarness, + runtimeCall +} from '@/runtime/web-session-tabs-sync/hydration/testing/host-session-mirror-frame-ordering-test-harness' +import { useClientHostedPageUrlSubmission } from './use-client-hosted-page-url-submission' +import { routeBrowserPageInitializationAction } from './browser-page-initialization-action' +import { + clearBrowserPageDeferredNavigation, + consumeBrowserPageDeferredNavigation, + deferBrowserPageNavigation, + hasBrowserPageInitializationAction +} from './browser-page-deferred-navigation' + +import { + PLACEMENT, + WS, + snapshot, + response, + publish, + page +} from './browser-page-initialization-fixture' + +installFrameOrderingHarness() +function submitter() { + const requests: Promise[] = [] + const hook = renderHook(() => + useClientHostedPageUrlSubmission({ + browserTabId: 'page-a', + worktreeId: WT, + webviewRef: { current: null }, + activeLoadFailureRef: { current: null }, + setAddressBarValue: () => {}, + onSetUrl: (_id, url) => + useAppStore.setState((s) => ({ + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [WS]: [{ ...s.browserPagesByWorkspace[WS][0], url }] + } + })), + onUpdatePageState: () => {}, + onInitializationAction: (action) => { + requests.push( + routeBrowserPageInitializationAction({ + pageId: 'page-a', + workspaceId: WS, + worktreeId: WT, + environmentId: ENV, + placement: PLACEMENT, + action + }) + ) + } + }) + ) + return { submit: (url: string) => act(() => hook.result.current(url)), requests, hook } +} + +let stopLifetime = () => {} +let diagnostics: Parameters< + NonNullable +>[0] +afterEach(() => stopLifetime()) +beforeEach(async () => { + Object.assign(window.api.runtimeEnvironments, { + onSharedControlDiagnostics: (callback: typeof diagnostics) => { + diagnostics = callback + return () => { + diagnostics = () => {} + } + } + }) + stopLifetime = installBrowserInitializationPresentationLifetime() + resetBrowserClientHostIdForTests() + clearBrowserPageDeferredNavigation('page-a') + Object.assign(window.api, { browser: { readClientHostId: () => 'host-a' } }) + useAppStore.setState({ tabsByWorktree: {}, terminalLayoutsByTabId: {}, ptyIdsByTabId: {} }) + await publish(snapshot('https://example.internal/S', 1)) +}) + +it('keeps the typed address through an older frame, settles the action reply and adopts the requested commit', async () => { + const answer = Promise.withResolvers() + runtimeCall.mockImplementation(() => answer.promise) + const h = submitter() + deferBrowserPageNavigation('page-a', 'https://example.internal/obsolete') + h.submit('https://example.internal/F') + expect(consumeBrowserPageDeferredNavigation('page-a')).toBeNull() + await publish(snapshot('https://example.internal/S', 2)) + expect(page()?.url).toBe('https://example.internal/F') + answer.resolve(response(snapshot('https://example.internal/F', 3))) + await act(async () => { + await h.requests[0] + }) + expect(hasBrowserPageInitializationAction('page-a')).toBe(false) + expect(page()?.url).toBe('https://example.internal/F') + await publish(snapshot('https://example.internal/redirected', 4, null)) + expect(page()?.url).toBe('https://example.internal/redirected') + expect( + useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a']?.initializationPresentation + ?.disposition + ).toBeUndefined() + expect(runtimeCall).toHaveBeenCalledTimes(1) +}) + +it('keeps a newer local submission when the first reply arrives late', async () => { + const first = Promise.withResolvers(), + second = Promise.withResolvers() + runtimeCall + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise) + const h = submitter() + h.submit('https://example.internal/F') + h.submit('https://example.internal/G') + first.resolve(response(snapshot('https://example.internal/F', 2))) + await act(async () => { + await h.requests[0] + }) + expect(page()?.url).toBe('https://example.internal/G') + expect(hasBrowserPageInitializationAction('page-a')).toBe(true) + second.resolve(response(snapshot('https://example.internal/G', 3))) + await act(async () => { + await h.requests[1] + }) + expect(page()?.url).toBe('https://example.internal/G') + expect(hasBrowserPageInitializationAction('page-a')).toBe(false) + expect(runtimeCall).toHaveBeenCalledTimes(2) +}) + +it('accepts the current address in the action reply and rejects an older frame received before the action but applied after it', async () => { + const oldReceipt = nextReceivedSessionTabsFrame() + const answer = Promise.withResolvers() + runtimeCall.mockImplementation(() => answer.promise) + const h = submitter() + h.submit('https://example.internal/F') + answer.resolve(response(snapshot('https://example.internal/H', 3))) + await act(async () => { + await h.requests[0] + }) + expect(page()?.url).toBe('https://example.internal/H') + await publish(snapshot('https://example.internal/S', 2), oldReceipt) + expect(page()?.url).toBe('https://example.internal/H') + expect(runtimeCall).toHaveBeenCalledTimes(1) +}) + +it('discards an action reply after its runtime environment is retired', async () => { + const answer = Promise.withResolvers() + runtimeCall.mockImplementation(() => answer.promise) + const h = submitter() + h.submit('https://example.internal/F') + replaceRuntimeEnvironmentRevisions([]) + answer.resolve(response(snapshot('https://example.internal/H', 3))) + await act(async () => { + await h.requests[0] + }) + expect(page()?.url).toBe('https://example.internal/F') + expect(page()?.loadError).toBeNull() + expect(runtimeCall).toHaveBeenCalledTimes(1) + expect(hasBrowserPageInitializationAction('page-a')).toBe(false) +}) + +it('reconciles a newer remote address already received before the delayed action reply', async () => { + const answer = Promise.withResolvers() + runtimeCall + .mockImplementationOnce(() => answer.promise) + .mockImplementation(async () => response(snapshot('https://example.internal/H', 3))) + const h = submitter() + h.submit('https://example.internal/F') + await publish(snapshot('https://example.internal/H', 3)) + expect(page()?.url).toBe('https://example.internal/F') + answer.resolve(response(snapshot('https://example.internal/F', 2))) + await act(async () => { + await h.requests[0] + }) + expect(page()?.url).toBe('https://example.internal/H') + expect(runtimeCall).toHaveBeenCalledTimes(2) + expect(runtimeCall.mock.calls[1][0]).toMatchObject({ method: 'session.tabs.list' }) + await publish(snapshot('https://example.internal/S', 1)) + expect(page()?.url).toBe('https://example.internal/H') +}) + +it.each(['settle', 'new-native'] as const)( + 'reconciles a committed redirect before its delayed acceptance without replacing %s native content', + async (mode) => { + const answer = Promise.withResolvers() + const read = Promise.withResolvers() + const reading = Promise.withResolvers() + const committed = snapshot('https://example.internal/R', 3, null) + committed.tabs = committed.tabs.map((tab) => + tab.type === 'browser' ? { ...tab, placement: { ...PLACEMENT, pageHostGeneration: 1 } } : tab + ) + runtimeCall + .mockImplementationOnce(() => answer.promise) + .mockImplementationOnce(() => { + reading.resolve() + return read.promise + }) + const h = submitter() + h.submit('https://example.internal/F') + h.hook.unmount() + await publish(committed) + expect(page()?.url).toBe('https://example.internal/F') + answer.resolve(response(snapshot('https://example.internal/F', 2))) + await act(async () => { + await reading.promise + }) + expect(hasBrowserPageInitializationAction('page-a')).toBe(false) + if (mode === 'new-native') { + act(() => + useAppStore.setState((state) => ({ + browserPagesByWorkspace: { + ...state.browserPagesByWorkspace, + [WS]: [ + { + ...state.browserPagesByWorkspace[WS][0], + url: 'https://example.internal/N', + title: 'New native document', + loading: false + } + ] + } + })) + ) + } + read.resolve(response(committed)) + await act(async () => { + await h.requests[0] + }) + expect(page()?.url).toBe( + mode === 'settle' ? 'https://example.internal/R' : 'https://example.internal/N' + ) + expect(runtimeCall.mock.calls.map(([call]) => call.method)).toEqual([ + 'browser.clientPage.initializationAction', + 'session.tabs.list' + ]) + } +) + +it('clears a failed action with one current read and never revives a removed page', async () => { + const answer = Promise.withResolvers() + runtimeCall + .mockImplementationOnce(() => answer.promise) + .mockImplementation(async () => response(snapshot('https://example.internal/S', 1))) + const h = submitter() + h.submit('https://example.internal/F') + answer.reject(new Error('unavailable')) + await act(async () => { + await h.requests[0] + }) + expect(page()?.loadError).toBeNull() + expect(page()?.url).toBe('https://example.internal/S') + expect(runtimeCall).toHaveBeenCalledTimes(2) + const removed = Promise.withResolvers() + runtimeCall.mockImplementation(() => removed.promise) + h.submit('https://example.internal/G') + await publish({ ...snapshot('', 4), tabs: [] }) + removed.resolve(response(snapshot('https://example.internal/G', 3))) + await act(async () => { + await h.requests[1] + }) + expect(page()).toBeUndefined() + expect(runtimeCall).toHaveBeenCalledTimes(3) + expect(hasBrowserPageInitializationAction('page-a')).toBe(false) +}) + +it('carries transient initialization through the actual delta codec, clears omission and excludes it from disk', async () => { + const encoder = createSessionTabsTabDeltaEncoder([SESSION_TABS_TAB_DELTA_RUNTIME_CAPABILITY]) + const decoder = createSessionTabsTabDeltaDecoder() + const first = snapshot('https://example.internal/S', 2) + // Include an unchanged sibling so the production codec chooses its delta branch. + const sibling = first.tabs[0] + if (sibling.type !== 'browser') { + throw new Error('browser_fixture_required') + } + first.tabs.push({ + ...sibling, + id: 'tab-b', + browserPageId: 'page-b', + browserWorkspaceId: 'workspace-b' + }) + const encoded = decoder.decode( + JSON.parse(JSON.stringify(encoder.full({ ...first, type: 'snapshot' }))) + ) + expect(encoded.ok).toBe(true) + const cleared = { + ...first, + snapshotVersion: 3, + tabs: first.tabs.map((tab, index) => + index === 0 ? { ...tab, initializationState: undefined, loading: false } : tab + ) + } + const wire = encoder.update({ ...cleared, type: 'updated' }) + expect(wire).toHaveProperty('tabsDelta') + const decoded = decoder.decode(JSON.parse(JSON.stringify(wire))) + expect(decoded.ok).toBe(true) + if (!decoded.ok) { + throw new Error('fixture delta failed') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: codec roundtrips the typed frame above; its public result stays unknown. + await publish(decoded.result as RuntimeMobileSessionTabsResult) + expect( + useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a']?.initializationPresentation + ?.disposition + ).toBeUndefined() + await publish(snapshot('https://example.internal/S', 4, 'stopped')) + expect( + useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a']?.initializationPresentation + ?.disposition + ).toBe('stopped') + const state = useAppStore.getState() + const saved = buildPersistedBrowserPagesByWorkspace( + state.browserPagesByWorkspace, + state.remoteBrowserPageHandlesByPageId, + {} + ) + expect(saved?.[WS]?.[0]).not.toHaveProperty('initializationState') +}) + +it('rejects old action and dispatched read across loss without changing fresh pending or its requested address', async () => { + const oldAction = Promise.withResolvers() + runtimeCall.mockImplementation(() => oldAction.promise) + const h = submitter() + h.submit('https://example.internal/typed') + const read = Promise.withResolvers<{ + snapshot: RuntimeMobileSessionTabsResult + runtimeId: string + }>() + const dispatched = listRemoteRuntimeSessionTabsDeduped({ + environmentId: ENV, + worktreeId: WT, + load: () => read.promise + }) + act(() => + diagnostics({ + environmentId: ENV, + transportGeneration: 1, + diagnostics: { + state: 'closed', + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 0, + lastConnectedAt: 1, + lastClose: null, + lastError: null + } + }) + ) + await publish(snapshot('https://example.internal/fresh', 4)) + oldAction.reject(new Error('transport closed')) + read.resolve({ + snapshot: snapshot('https://example.internal/obsolete', 100, null), + runtimeId: 'runtime-a' + }) + await act(async () => { + await h.requests[0] + await applyWebRuntimeSessionTabsAnswer(ENV, WT, await dispatched) + }) + expect(page()?.url).toBe('https://example.internal/fresh') + expect(page()?.loadError).toBeNull() + expect( + useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a']?.initializationPresentation + ?.disposition + ).toBe('pending') + expect(runtimeCall).toHaveBeenCalledTimes(1) + expect(hasBrowserPageInitializationAction('page-a')).toBe(false) +}) + +it('keeps cached pending revoked until a fresh equal-version replay is accepted', async () => { + const cached = snapshot('https://example.internal/S', 2) + await publish(cached) + const emit = (state: 'closed' | 'ready') => + act(() => + diagnostics({ + environmentId: ENV, + transportGeneration: 1, + diagnostics: { + state, + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 0, + lastConnectedAt: 1, + lastClose: null, + lastError: null + } + }) + ) + emit('closed') + emit('ready') + await publish(cached) + expect( + useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a']?.initializationPresentation + ?.disposition + ).toBe('unavailable') + await act(async () => { + await applyWebRuntimeSessionTabsAnswer( + ENV, + WT, + { + snapshot: snapshot('https://example.internal/S', 2), + receivedFrame: nextReceivedSessionTabsFrame(), + runtimeId: 'runtime-a' + }, + { acceptCurrentSnapshot: true } + ) + }) + expect( + useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a']?.initializationPresentation + ?.disposition + ).toBe('pending') + expect(page()?.url).toBe('https://example.internal/S') +}) + +it.each([10, 100])( + 'keeps held presentation stable over %s unrelated received frames and still publishes Stop', + async (count) => { + vi.useFakeTimers() + useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) + const persist = vi.fn() + const stopPersistence = createSessionWriteSubscriber({ store: useAppStore, persist }) + await vi.advanceTimersByTimeAsync(200) + persist.mockClear() + const render = vi.fn() + const hook = renderHook(() => { + const presentation = useAppStore( + (s) => s.remoteBrowserPageHandlesByPageId['page-a']?.initializationPresentation + ) + render() + return presentation?.disposition + }) + render.mockClear() + const handles = useAppStore.getState().remoteBrowserPageHandlesByPageId + let handleWrites = 0 + const unsubscribe = useAppStore.subscribe((s, previous) => { + if (s.remoteBrowserPageHandlesByPageId !== previous.remoteBrowserPageHandlesByPageId) { + handleWrites++ + } + }) + try { + for (let i = 0; i < count; i++) { + await publish(snapshot('https://example.internal/S', i + 2)) + await vi.advanceTimersByTimeAsync(200) + } + expect(useAppStore.getState().remoteBrowserPageHandlesByPageId).toBe(handles) + expect(handleWrites).toBe(0) + expect(render).not.toHaveBeenCalled() + expect(persist).not.toHaveBeenCalled() + await publish(snapshot('https://example.internal/S', count + 2, 'stopped')) + expect(handleWrites).toBe(1) + expect(hook.result.current).toBe('stopped') + await vi.advanceTimersByTimeAsync(200) + expect(persist).toHaveBeenCalledTimes(1) + } finally { + unsubscribe() + hook.unmount() + stopPersistence() + vi.useRealTimers() + } + } +) + +it('does not reauthorize pending from a retired-publisher census dispatched before loss', async () => { + const coordinator = new VisibilityResumeCoordinator({ + environments: [], + environmentIdBySubscriptionSpec: [], + omissions: new Map(), + activeRuntimeWorktreeKey: () => null + }) + const globalFrame = async (epoch: string, version: number) => { + await act(async () => { + handleGlobalSessionEvent({ + environmentId: ENV, + expectedEnvironmentConnectionGeneration: 1, + expectedEnvironmentPairingRevision: getRuntimeEnvironmentRevision(ENV), + expectedTrackingGeneration: getWebSessionTabsTrackingGeneration(ENV), + visibilityGeneration: 0, + isCurrent: () => true, + event: { + ...snapshot('https://example.internal/S', version), + publicationEpoch: epoch, + type: 'snapshot' + }, + response: response({ + ...snapshot('https://example.internal/S', version), + publicationEpoch: epoch + }), + awaitingVisibilityResumeInventory: { value: false }, + coordinator + }) + await vi.advanceTimersByTimeAsync(0) + }) + } + vi.useFakeTimers() + try { + await globalFrame('epoch-b', 1) + await globalFrame('epoch-a', 2) + const census = Promise.withResolvers() + runtimeCall.mockImplementationOnce(() => census.promise) + await act(async () => { + await vi.advanceTimersByTimeAsync(250) + }) + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'session.tabs.listAll' }) + ) + act(() => + diagnostics({ + environmentId: ENV, + transportGeneration: 1, + diagnostics: { + state: 'closed', + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 0, + lastConnectedAt: 1, + lastClose: null, + lastError: null + } + }) + ) + await act(async () => { + census.resolve({ + id: 'census', + ok: true, + result: { authoritative: true, snapshots: [snapshot('https://example.internal/S', 3)] }, + _meta: { runtimeId: 'runtime-a' } + }) + await vi.advanceTimersByTimeAsync(0) + }) + expect( + useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a']?.initializationPresentation + ?.disposition + ).toBe('unavailable') + await globalFrame('epoch-c', 1) + expect( + useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a']?.initializationPresentation + ?.disposition + ).toBe('pending') + } finally { + vi.useRealTimers() + } +}) + +it('does not persist delivery presentation but still saves changed browser ownership and staged visibility', async () => { + vi.useFakeTimers() + useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) + const persist = vi.fn() + const stop = createSessionWriteSubscriber({ store: useAppStore, persist }) + try { + await vi.advanceTimersByTimeAsync(200) + persist.mockClear() + act(() => + diagnostics({ + environmentId: ENV, + transportGeneration: 1, + diagnostics: { + state: 'closed', + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 0, + lastConnectedAt: 1, + lastClose: null, + lastError: null + } + }) + ) + await vi.advanceTimersByTimeAsync(200) + expect(persist).not.toHaveBeenCalled() + await publish(snapshot('https://example.internal/S', 2)) + await vi.advanceTimersByTimeAsync(200) + expect(persist).not.toHaveBeenCalled() + const updateHandle = (patch: Partial) => { + useAppStore.setState((s) => ({ + remoteBrowserPageHandlesByPageId: Object.assign( + copyBrowserPageHandles(s.remoteBrowserPageHandlesByPageId, ['page-a']), + { + 'page-a': { ...s.remoteBrowserPageHandlesByPageId['page-a'], ...patch } + } + ) + })) + } + updateHandle({ remotePageId: 'replacement-identity' }) + await vi.advanceTimersByTimeAsync(200) + expect(persist).toHaveBeenCalledTimes(1) + expect(persist.mock.calls[0][0].patch.browserPagesByWorkspace[WS][0]).toMatchObject({ + url: 'https://example.internal/S', + remoteBrowserPageId: 'replacement-identity', + remoteBrowserPageClientHosted: true + }) + persist.mockClear() + updateHandle({ staged: true }) + await vi.advanceTimersByTimeAsync(200) + expect(persist.mock.calls[0][0].patch.browserPagesByWorkspace[WS]).toBeUndefined() + persist.mockClear() + updateHandle({ staged: undefined }) + await vi.advanceTimersByTimeAsync(200) + expect(persist.mock.calls[0][0].patch.browserPagesByWorkspace[WS][0].remoteBrowserPageId).toBe( + 'replacement-identity' + ) + } finally { + stop() + vi.useRealTimers() + } +}) diff --git a/src/renderer/src/components/browser-pane/navigate/browser-page-initialization-action.ts b/src/renderer/src/components/browser-pane/navigate/browser-page-initialization-action.ts new file mode 100644 index 00000000000..0240b9b2305 --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/browser-page-initialization-action.ts @@ -0,0 +1,127 @@ +import { + captureBrowserInitializationDelivery, + isBrowserInitializationDeliveryCurrent, + recordBrowserInitializationReceipt +} from '@/runtime/web-session-tabs-sync/browser-initialization-presentation' +import { sameRuntimeBrowserPlacement } from '../../../../../shared/runtime-browser-placement' +import { useAppStore } from '@/store' +import type { BrowserPageInitializationAction } from '../../../../../shared/browser/client-hosting/browser-page-initialization-action' +import type { RuntimeBrowserClientPlacement } from '../../../../../shared/runtime-browser-placement' +import type { RuntimeMobileSessionTabsResult } from '../../../../../shared/runtime-session-contracts' +import type { RuntimeRpcResponse } from '../../../../../shared/runtime-rpc-envelope' +import { captureRuntimeEnvironmentCall } from '@/runtime/web-session-tabs-sync/identity/web-runtime-session-environment' +import { getRuntimeEnvironmentRevision } from '@/runtime/rpc/environment-revisions/runtime-environment-revision' +import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status/runtime-status-connection-generation' +import { unwrapRuntimeRpcResult } from '@/runtime/rpc/calls/runtime-rpc-result' +import { toRuntimeWorktreeSelector } from '@/runtime/rpc/targets/runtime-worktree-selector' +import { nextReceivedSessionTabsFrame } from '@/runtime/web-session-tabs-sync/state' +import { getSessionTabsRuntimeIdFromResponse } from '@/runtime/web-session-tabs-sync/publisher-identity-fences' +import { + applyWebRuntimeSessionTabsAnswer, + refreshWebRuntimeSessionTabsSnapshot +} from '@/runtime/web-session-tabs-sync/inventory/web-runtime-session-snapshot' +import { + beginBrowserPageInitializationAction, + endBrowserPageInitializationAction, + hasBrowserPageInitializationAction +} from './browser-page-deferred-navigation' + +export async function routeBrowserPageInitializationAction(input: { + pageId: string + workspaceId: string + worktreeId: string + environmentId: string + placement: RuntimeBrowserClientPlacement + action: BrowserPageInitializationAction +}): Promise { + const { pageId, environmentId } = input + const delivery = captureBrowserInitializationDelivery(environmentId) + const presentation = + useAppStore.getState().remoteBrowserPageHandlesByPageId[pageId]?.initializationPresentation + const token = beginBrowserPageInitializationAction(pageId) + const revision = getRuntimeEnvironmentRevision(environmentId) + const connection = getRuntimeEnvironmentConnectionGeneration(environmentId) + const remotePageId = useAppStore.getState().remoteBrowserPageHandlesByPageId[pageId]?.remotePageId + const receivedFrame = nextReceivedSessionTabsFrame() + const alive = (): boolean => { + const state = useAppStore.getState() + const handle = state.remoteBrowserPageHandlesByPageId[pageId] + return ( + isBrowserInitializationDeliveryCurrent(delivery) && + getRuntimeEnvironmentRevision(environmentId) === revision && + getRuntimeEnvironmentConnectionGeneration(environmentId) === connection && + handle?.environmentId === environmentId && + handle.remotePageId === remotePageId && + Boolean(state.browserPagesByWorkspace[input.workspaceId]?.some((page) => page.id === pageId)) + ) + } + const live = (): boolean => { + const handle = useAppStore.getState().remoteBrowserPageHandlesByPageId[pageId] + return ( + alive() && + handle?.initializationPresentation?.delivery === presentation?.delivery && + Boolean(handle?.placement && sameRuntimeBrowserPlacement(handle.placement, input.placement)) + ) + } + const current = (): boolean => live() && hasBrowserPageInitializationAction(pageId, token) + let applied = false + let released = false + try { + const response = await captureRuntimeEnvironmentCall( + environmentId, + revision + )({ + method: 'browser.clientPage.initializationAction', + params: { + worktree: toRuntimeWorktreeSelector(input.worktreeId), + page: remotePageId ?? pageId, + expectedPlacement: input.placement, + action: input.action + }, + timeoutMs: 15_000 + }) + if (!current()) { + return + } + // SAFETY: this method returns the same caller-projected session snapshot as session.tabs.list. + const snapshot = unwrapRuntimeRpcResult( + response as RuntimeRpcResponse + ) + recordBrowserInitializationReceipt(environmentId, snapshot, receivedFrame, delivery) + applied = await applyWebRuntimeSessionTabsAnswer( + environmentId, + input.worktreeId, + { + snapshot, + receivedFrame, + runtimeId: getSessionTabsRuntimeIdFromResponse(response) + }, + { + expectedEnvironmentPairingRevision: revision, + errorMode: 'throw', + isCurrent: current, + beforeApply: () => { + released = endBrowserPageInitializationAction(pageId, token) + } + } + ) + } catch { + // A control rejection is not a guest document error; the current snapshot owns its outcome. + } finally { + released = endBrowserPageInitializationAction(pageId, token) || released + if (!applied && released && alive() && !hasBrowserPageInitializationAction(pageId)) { + const state = useAppStore.getState() + const page = state.browserPagesByWorkspace[input.workspaceId]?.find( + (page) => page.id === pageId + ) + const handle = state.remoteBrowserPageHandlesByPageId[pageId] + await refreshWebRuntimeSessionTabsSnapshot(environmentId, input.worktreeId, { + expectedEnvironmentPairingRevision: revision, + acceptCurrentSnapshot: true, + afterCurrentInFlight: true, + isCurrent: () => alive() && !hasBrowserPageInitializationAction(pageId), + ...(page && handle ? { reconcileBrowserPage: { page, handle } } : {}) + }) + } + } +} diff --git a/src/renderer/src/components/browser-pane/navigate/browser-page-initialization-fixture.ts b/src/renderer/src/components/browser-pane/navigate/browser-page-initialization-fixture.ts new file mode 100644 index 00000000000..37a2c10bfbe --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/browser-page-initialization-fixture.ts @@ -0,0 +1,68 @@ +import { act } from '@testing-library/react' +import { useAppStore } from '@/store' +import type { RuntimeMobileSessionTabsResult } from '../../../../../shared/runtime-session-contracts' +import { applyWebRuntimeSessionTabsAnswer } from '@/runtime/web-session-tabs-sync/inventory/web-runtime-session-snapshot' +import { nextReceivedSessionTabsFrame } from '@/runtime/web-session-tabs-sync/state' +import { + ENV, + WT +} from '@/runtime/web-session-tabs-sync/hydration/testing/host-session-mirror-frame-fixtures' + +export const PLACEMENT = { + kind: 'client' as const, + browserHostClientId: 'host-a', + browserHostGeneration: 1, + pageHostGeneration: 0 +} +export const WS = 'browser-workspace-a' +export function snapshot( + url: string, + version: number, + state: 'pending' | 'stopped' | null = 'pending' +): RuntimeMobileSessionTabsResult { + return { + worktree: WT, + publicationEpoch: 'epoch-a', + snapshotVersion: version, + activeGroupId: 'host-group-a', + activeTabId: 'tab-a', + activeTabType: 'browser', + tabs: [ + { + type: 'browser', + id: 'tab-a', + title: url, + browserWorkspaceId: WS, + browserPageId: 'page-a', + placement: PLACEMENT, + url, + initializationState: state ?? undefined, + loading: state === 'pending', + canGoBack: false, + canGoForward: false, + isActive: true + } + ] + } +} +export const response = (result: RuntimeMobileSessionTabsResult) => ({ + id: 'answer', + ok: true as const, + result, + _meta: { runtimeId: 'runtime-a' } +}) +export async function publish( + frame: RuntimeMobileSessionTabsResult, + receivedFrame = nextReceivedSessionTabsFrame() +) { + await act(async () => { + await applyWebRuntimeSessionTabsAnswer(ENV, WT, { + snapshot: frame, + receivedFrame, + runtimeId: 'runtime-a' + }) + }) +} +export function page() { + return useAppStore.getState().browserPagesByWorkspace[WS]?.[0] +} diff --git a/src/renderer/src/components/browser-pane/navigate/browser-page-presentation-loss.test.tsx b/src/renderer/src/components/browser-pane/navigate/browser-page-presentation-loss.test.tsx new file mode 100644 index 00000000000..9f12fa0b3cf --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/browser-page-presentation-loss.test.tsx @@ -0,0 +1,389 @@ +// @vitest-environment happy-dom +import { act } from '@testing-library/react' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import type { RemoteBrowserPageHandle } from '@/store/slices/browser/browser-slice-contract' +import { + annotateBrowserPageHandles, + copyBrowserPageHandles +} from '@/store/slices/browser/hosts/browser-initialization-presentation-ownership' +import { refreshWebRuntimeSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync/inventory/web-runtime-session-snapshot' +import { + captureBrowserInitializationDelivery, + isBrowserInitializationDeliveryCurrent, + installBrowserInitializationPresentationLifetime +} from '@/runtime/web-session-tabs-sync/browser-initialization-presentation' +import { clearWebSessionTabsTrackingForEnvironment } from '@/runtime/web-session-tabs-sync/tracking-lifecycle' +import { + getRuntimeEnvironmentRevision, + replaceRuntimeEnvironmentRevisions +} from '@/runtime/rpc/environment-revisions/runtime-environment-revision' +import { + ENV, + WT +} from '@/runtime/web-session-tabs-sync/hydration/testing/host-session-mirror-frame-fixtures' +import { + installFrameOrderingHarness, + runtimeCall +} from '@/runtime/web-session-tabs-sync/hydration/testing/host-session-mirror-frame-ordering-test-harness' +import { snapshot, response, publish, page } from './browser-page-initialization-fixture' + +installFrameOrderingHarness() +let stopLifetime = () => {} +let diagnostics: Parameters< + NonNullable +>[0] +afterEach(() => stopLifetime()) +beforeEach(async () => { + Object.assign(window.api.runtimeEnvironments, { + onSharedControlDiagnostics: (callback: typeof diagnostics) => { + diagnostics = callback + return () => { + diagnostics = () => {} + } + } + }) + stopLifetime = installBrowserInitializationPresentationLifetime() + await publish(snapshot('https://example.internal/S', 1)) +}) + +it.each([750, 7500])( + 'skips loss traversal after a failed dispatch and revokes a fresh one-shot receipt among %s handles', + async (count) => { + const loss = { + environmentId: ENV, + transportGeneration: 1, + diagnostics: { + state: 'closed' as const, + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 0, + lastConnectedAt: 1, + lastClose: null, + lastError: null + } + } + const original = useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'] + const unrelated = { ...original, environmentId: 'other-environment' } + const handles = { ...useAppStore.getState().remoteBrowserPageHandlesByPageId } + for (let i = 0; i < count; i++) { + handles[`unrelated-${i}`] = unrelated + } + useAppStore.setState({ + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles( + handles, + Object.keys(handles), + useAppStore.getState().remoteBrowserPageHandlesByPageId + ) + }) + act(() => diagnostics(loss)) + let visits = 0 + let publications = 0 + const countHandles = () => { + useAppStore.setState((state) => ({ + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles( + new Proxy(state.remoteBrowserPageHandlesByPageId, { + ownKeys: (target) => { + visits++ + return Reflect.ownKeys(target) + } + }), + [], + state.remoteBrowserPageHandlesByPageId + ) + })) + visits = 0 + publications = 0 + } + runtimeCall.mockRejectedValueOnce(new Error('offline')) + await act(async () => { + await expect( + refreshWebRuntimeSessionTabsSnapshot(ENV, WT, { + acceptCurrentSnapshot: true, + errorMode: 'throw' + }) + ).rejects.toThrow('offline') + }) + countHandles() + const stop = useAppStore.subscribe(() => { + publications++ + }) + try { + act(() => diagnostics(loss)) + expect(visits).toBe(0) + expect(publications).toBe(0) + runtimeCall.mockResolvedValueOnce( + response(snapshot('https://example.internal/ordinary', 3, null)) + ) + await act(async () => { + await refreshWebRuntimeSessionTabsSnapshot(ENV, WT, { acceptCurrentSnapshot: true }) + }) + countHandles() + act(() => diagnostics(loss)) + expect(visits).toBe(0) + expect(publications).toBe(0) + runtimeCall.mockResolvedValueOnce(response(snapshot('https://example.internal/stale', 2))) + await act(async () => { + await refreshWebRuntimeSessionTabsSnapshot(ENV, WT) + }) + countHandles() + act(() => diagnostics(loss)) + expect(visits).toBe(0) + expect(publications).toBe(0) + runtimeCall.mockResolvedValueOnce(response(snapshot('https://example.internal/fresh', 4))) + await act(async () => { + await refreshWebRuntimeSessionTabsSnapshot(ENV, WT, { acceptCurrentSnapshot: true }) + }) + expect( + useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'].initializationPresentation + ?.disposition + ).toBe('pending') + countHandles() + act(() => diagnostics(loss)) + expect(visits).toBe(2) + expect(publications).toBe(1) + expect( + useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'].initializationPresentation + ?.disposition + ).toBe('unavailable') + expect(useAppStore.getState().remoteBrowserPageHandlesByPageId['unrelated-0']).toBe(unrelated) + expect(page()?.url).toBe('https://example.internal/fresh') + act(() => diagnostics(loss)) + expect(visits).toBe(2) + expect(publications).toBe(1) + } finally { + stop() + } + } +) + +it.each([750, 7500])( + 'revokes %s affected pages in one publication while leaving another environment untouched', + (count) => { + const handle = useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'] + const handles = { ...useAppStore.getState().remoteBrowserPageHandlesByPageId } + for (let i = 0; i < count; i++) { + handles[`scale-${i}`] = { ...handle, remotePageId: `remote-${i}` } + } + const unrelated = { ...handle, environmentId: 'other-environment' } + handles['unrelated-page'] = unrelated + useAppStore.setState({ + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles( + handles, + Object.keys(handles), + useAppStore.getState().remoteBrowserPageHandlesByPageId + ) + }) + const pages = useAppStore.getState().browserPagesByWorkspace + let publications = 0 + const unsubscribe = useAppStore.subscribe(() => { + publications++ + }) + const event = { + environmentId: ENV, + transportGeneration: 1, + diagnostics: { + state: 'closed' as const, + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 0, + lastConnectedAt: 1, + lastClose: null, + lastError: null + } + } + try { + act(() => diagnostics(event)) + expect(publications).toBe(1) + const next = useAppStore.getState() + expect(next.browserPagesByWorkspace).toBe(pages) + expect(next.remoteBrowserPageHandlesByPageId['unrelated-page']).toBe(unrelated) + for (let i = 0; i < count; i++) { + expect( + next.remoteBrowserPageHandlesByPageId[`scale-${i}`].initializationPresentation + ?.disposition + ).toBe('unavailable') + } + act(() => diagnostics(event)) + expect(publications).toBe(1) + stopLifetime() + stopLifetime = () => {} + act(() => diagnostics(event)) + expect(publications).toBe(1) + } finally { + unsubscribe() + } + } +) + +it.each([750, 7500])( + 'revokes retained pending after tracking clears with %s unrelated handles', + (count) => { + const retained = useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'] + const committed = { ...retained, initializationPresentation: undefined } + const unrelated = { ...retained, environmentId: 'unrelated-environment' } + const handles: Record = { 'page-a': retained, committed } + for (let i = 0; i < count; i++) { + handles[`unrelated-${i}`] = unrelated + } + let enumerations = 0 + let reads = 0 + const counted = new Proxy(handles, { + ownKeys: (target) => { + enumerations++ + return Reflect.ownKeys(target) + }, + get: (target, key) => { + reads++ + return typeof key === 'string' ? target[key] : undefined + } + }) + useAppStore.setState({ + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles( + counted, + Object.keys(handles), + useAppStore.getState().remoteBrowserPageHandlesByPageId + ) + }) + clearWebSessionTabsTrackingForEnvironment(ENV) + expect(useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a']).toBe(retained) + enumerations = 0 + reads = 0 + let publications = 0 + const stop = useAppStore.subscribe(() => { + publications++ + }) + const event = { + environmentId: ENV, + transportGeneration: 1, + diagnostics: { + state: 'closed' as const, + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 0, + lastConnectedAt: 1, + lastClose: null, + lastError: null + } + } + try { + act(() => diagnostics(event)) + expect(enumerations).toBe(2) // One selection and one immutable copy. + expect(reads).toBeLessThanOrEqual(3 * (count + 2)) + expect(publications).toBe(1) + expect( + useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'].initializationPresentation + ?.disposition + ).toBe('unavailable') + expect(useAppStore.getState().remoteBrowserPageHandlesByPageId.committed).toBe(committed) + expect(useAppStore.getState().remoteBrowserPageHandlesByPageId['unrelated-0']).toBe(unrelated) + expect(page()?.url).toBe('https://example.internal/S') + act(() => diagnostics(event)) + expect(enumerations).toBe(2) + expect(publications).toBe(1) + } finally { + stop() + } + } +) + +it.each(['retirement', 'teardown'] as const)( + 'batches multiple environments on %s and preserves a successor against stale diagnostics', + (kind) => { + const environments = [ENV, 'second-environment', 'third-environment'] + replaceRuntimeEnvironmentRevisions( + environments.map((id) => ({ + id, + createdAt: 0, + pairingRevision: getRuntimeEnvironmentRevision(id) ?? 1 + })) + ) + const original = useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'] + const deliveries = environments.map((id) => captureBrowserInitializationDelivery(id)) + const handles: Record = {} + for (let i = 0; i < environments.length; i++) { + handles[`batch-${i}`] = { + ...original, + environmentId: environments[i], + remotePageId: `batch-${i}`, + initializationPresentation: { + disposition: 'pending', + delivery: deliveries[i], + receivedFrame: 1 + } + } + } + // A closed page has no retained handle and must not be recreated. + let enumerations = 0 + useAppStore.setState({ + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles( + new Proxy(handles, { + ownKeys: (target) => { + enumerations++ + return Reflect.ownKeys(target) + } + }), + ['page-a', ...Object.keys(handles)], + useAppStore.getState().remoteBrowserPageHandlesByPageId + ) + }) + enumerations = 0 + let publications = 0 + const stop = useAppStore.subscribe(() => { + publications++ + }) + try { + if (kind === 'retirement') { + replaceRuntimeEnvironmentRevisions([]) + } else { + stopLifetime() + stopLifetime = () => {} + } + expect(enumerations).toBe(2) + expect(publications).toBe(1) + expect( + deliveries.every((delivery) => !isBrowserInitializationDeliveryCurrent(delivery)) + ).toBe(true) + for (let i = 0; i < environments.length; i++) { + expect( + useAppStore.getState().remoteBrowserPageHandlesByPageId[`batch-${i}`] + .initializationPresentation?.disposition + ).toBe('unavailable') + } + expect(useAppStore.getState().remoteBrowserPageHandlesByPageId.closed).toBeUndefined() + const successor = captureBrowserInitializationDelivery(ENV) + useAppStore.setState((s) => ({ + remoteBrowserPageHandlesByPageId: Object.assign( + copyBrowserPageHandles(s.remoteBrowserPageHandlesByPageId, ['batch-0']), + { + 'batch-0': { + ...s.remoteBrowserPageHandlesByPageId['batch-0'], + initializationPresentation: { + disposition: 'pending', + delivery: successor, + receivedFrame: 2 + } + } + } + ) + })) + const before = useAppStore.getState().remoteBrowserPageHandlesByPageId + diagnostics({ + environmentId: ENV, + transportGeneration: -2, + diagnostics: { + state: 'closed', + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 0, + lastConnectedAt: 1, + lastClose: null, + lastError: null + } + }) + expect(useAppStore.getState().remoteBrowserPageHandlesByPageId).toBe(before) + } finally { + stop() + } + } +) diff --git a/src/renderer/src/components/browser-pane/navigate/browser-page-presentation-ownership.test.tsx b/src/renderer/src/components/browser-pane/navigate/browser-page-presentation-ownership.test.tsx new file mode 100644 index 00000000000..0a27b7d8d28 --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/browser-page-presentation-ownership.test.tsx @@ -0,0 +1,367 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { + annotateBrowserPageHandles, + copyBrowserPageHandles +} from '@/store/slices/browser/hosts/browser-initialization-presentation-ownership' +import { installBrowserInitializationPresentationLifetime } from '@/runtime/web-session-tabs-sync/browser-initialization-presentation' +import { adoptFrameDrawnClientHostedBrowserHandles } from '@/runtime/web-session-tabs-sync/frame-drawn-desktop-browser-handles' +import { buildLayoutFrameStorePatch } from '@/store/workspace-layout/frames/workspace-layout-frame-store-patch' +import { withHeldTabsAsDrawn } from '@/store/workspace-layout/frames/workspace-layout-frame-held-tabs' +import { installFrameOrderingHarness } from '@/runtime/web-session-tabs-sync/hydration/testing/host-session-mirror-frame-ordering-test-harness' +import { + ENV, + WT +} from '@/runtime/web-session-tabs-sync/hydration/testing/host-session-mirror-frame-fixtures' +import { publish, snapshot } from './browser-page-initialization-fixture' + +installFrameOrderingHarness() +let stopLifetime = () => {} +let diagnostics: Parameters< + NonNullable +>[0] +const loss = () => + diagnostics({ + environmentId: ENV, + transportGeneration: 1, + diagnostics: { + state: 'closed', + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 0, + lastConnectedAt: 1, + lastClose: null, + lastError: null + } + }) +const disposition = (id: string) => + useAppStore.getState().remoteBrowserPageHandlesByPageId[id]?.initializationPresentation + ?.disposition +beforeEach(async () => { + Object.assign(window.api.runtimeEnvironments, { + onSharedControlDiagnostics: (callback: typeof diagnostics) => { + diagnostics = callback + return () => {} + } + }) + stopLifetime = installBrowserInitializationPresentationLifetime() + await publish(snapshot('https://example.internal/S', 1)) +}) +afterEach(() => stopLifetime()) + +it('revokes the same live presentation after held-frame withdrawal, frame re-add and staged-handle adoption', () => { + const original = useAppStore.getState() + const handle = original.remoteBrowserPageHandlesByPageId['page-a'] + const empty = { + ...original, + browserTabsByWorktree: { [WT]: [] }, + browserPagesByWorkspace: {}, + remoteBrowserPageHandlesByPageId: {} + } + const held = withHeldTabsAsDrawn(empty, original, [WT], { + isTabHeld: () => true, + isGroupLayoutHeld: () => true + }) + useAppStore.setState((state) => buildLayoutFrameStorePatch(state, held, [WT])) + expect(disposition('page-a')).toBeUndefined() + useAppStore.setState((state) => buildLayoutFrameStorePatch(state, original, [WT])) + expect(useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a']).toBe(handle) + useAppStore.getState().setRemoteBrowserPageHandle('page-a', { ...handle, staged: true }) + useAppStore.setState((state) => + adoptFrameDrawnClientHostedBrowserHandles( + state, + snapshot('https://example.internal/S', 2), + ENV, + WT + ) + ) + loss() + expect(disposition('page-a')).toBe('unavailable') +}) + +it('folds both staged IDs across a copied record only when the agent-status batch commits', () => { + const handle = useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'] + useAppStore.getState().removeRemoteBrowserPageHandle('page-a') + useAppStore.getState().transactAgentStatuses(() => { + useAppStore.getState().updateAgentStatusState((state) => ({ + remoteBrowserPageHandlesByPageId: Object.assign( + copyBrowserPageHandles(state.remoteBrowserPageHandlesByPageId, ['early']), + { early: handle } + ) + })) + useAppStore.getState().updateAgentStatusState((state) => ({ + remoteBrowserPageHandlesByPageId: Object.assign( + copyBrowserPageHandles(state.remoteBrowserPageHandlesByPageId, ['late']), + { late: handle } + ) + })) + expect(disposition('early')).toBeUndefined() + }) + loss() + expect(disposition('early')).toBe('unavailable') + expect(disposition('late')).toBe('unavailable') +}) + +it('does no loss publication for an abandoned batch or a patch rejected by the editor invariant', () => { + const handle = useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'] + useAppStore.getState().removeRemoteBrowserPageHandle('page-a') + expect(() => + useAppStore.getState().transactAgentStatuses(() => { + useAppStore.getState().updateAgentStatusState((state) => ({ + remoteBrowserPageHandlesByPageId: Object.assign( + copyBrowserPageHandles(state.remoteBrowserPageHandlesByPageId, ['abandoned']), + { abandoned: handle } + ) + })) + throw new Error('abandoned') + }) + ).toThrow('abandoned') + useAppStore.setState({ + openFiles: ['edit-a', 'edit-b'].map((id) => ({ + id, + filePath: '/tmp/shared.md', + relativePath: 'shared.md', + worktreeId: WT, + language: 'markdown', + isDirty: false, + mode: 'edit' as const + })) + }) + expect(() => + useAppStore.setState((state) => ({ + remoteBrowserPageHandlesByPageId: Object.assign( + copyBrowserPageHandles(state.remoteBrowserPageHandlesByPageId, ['rejected']), + { rejected: handle } + ), + editorDrafts: { 'edit-a': 'first', 'edit-b': 'second' } + })) + ).toThrow('differs from another view') + let publications = 0 + const stop = useAppStore.subscribe(() => publications++) + try { + loss() + expect(publications).toBe(0) + expect(disposition('abandoned')).toBeUndefined() + expect(disposition('rejected')).toBeUndefined() + } finally { + stop() + } +}) + +it('accounts before reentrant loss and retains a committed write when its subscriber throws', () => { + const handle = useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'] + useAppStore.getState().removeRemoteBrowserPageHandle('page-a') + let notified = false + const stop = useAppStore.subscribe(() => { + if (notified) { + return + } + notified = true + loss() + throw new Error('subscriber-after-commit') + }) + try { + expect(() => useAppStore.getState().setRemoteBrowserPageHandle('page-a', handle)).toThrow( + 'subscriber-after-commit' + ) + expect(disposition('page-a')).toBe('unavailable') + loss() + expect(disposition('page-a')).toBe('unavailable') + } finally { + stop() + } +}) + +it('rejects an unannotated record replacement before it can remove a live presentation', () => { + const before = useAppStore.getState().remoteBrowserPageHandlesByPageId + expect(() => useAppStore.setState({ remoteBrowserPageHandlesByPageId: {} })).toThrow( + 'missing changed page IDs' + ) + expect(useAppStore.getState().remoteBrowserPageHandlesByPageId).toBe(before) + loss() + expect(disposition('page-a')).toBe('unavailable') +}) + +it('does no loss publication after closing the last handle, but revokes a remaining sibling', async () => { + const handle = useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'] + useAppStore + .getState() + .setRemoteBrowserPageHandle('sibling', { ...handle, remotePageId: 'sibling' }) + useAppStore.getState().removeRemoteBrowserPageHandle('page-a') + loss() + expect(disposition('sibling')).toBe('unavailable') + await publish(snapshot('https://example.internal/next', 2)) + useAppStore.getState().removeRemoteBrowserPageHandle('page-a') + let publications = 0 + const stop = useAppStore.subscribe(() => publications++) + try { + loss() + expect(publications).toBe(0) + } finally { + stop() + } +}) + +it('keeps ownership through a valid whole-state replacement', () => { + const state = useAppStore.getState() + const handle = state.remoteBrowserPageHandlesByPageId['page-a'] + state.removeRemoteBrowserPageHandle('page-a') + const current = useAppStore.getState() + useAppStore.setState( + { + ...current, + remoteBrowserPageHandlesByPageId: Object.assign( + copyBrowserPageHandles(current.remoteBrowserPageHandlesByPageId, ['page-a']), + { 'page-a': handle } + ) + }, + true + ) + loss() + expect(disposition('page-a')).toBe('unavailable') +}) + +it('revokes a live sibling reinstalled by an outer updater after a nested accepted removal', () => { + const handle = useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'] + useAppStore + .getState() + .setRemoteBrowserPageHandle('sibling', { ...handle, remotePageId: 'sibling' }) + useAppStore.setState((state) => { + const handles = copyBrowserPageHandles(state.remoteBrowserPageHandlesByPageId, ['page-a']) + delete handles['page-a'] + useAppStore.getState().removeRemoteBrowserPageHandle('sibling') + return { remoteBrowserPageHandlesByPageId: handles } + }) + expect(disposition('page-a')).toBeUndefined() + expect(disposition('sibling')).toBe('pending') + loss() + expect(disposition('sibling')).toBe('unavailable') +}) + +it('does no loss publication for an inner addition omitted by the outer accepted record', () => { + const handle = useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'] + useAppStore.setState((state) => { + const handles = copyBrowserPageHandles(state.remoteBrowserPageHandlesByPageId, ['page-a']) + delete handles['page-a'] + useAppStore.getState().setRemoteBrowserPageHandle('sibling', handle) + return { remoteBrowserPageHandlesByPageId: handles } + }) + let publications = 0 + const stop = useAppStore.subscribe(() => publications++) + try { + loss() + expect(disposition('page-a')).toBeUndefined() + expect(disposition('sibling')).toBeUndefined() + expect(publications).toBe(0) + } finally { + stop() + } +}) + +it('carries an accepted descendant through a rejected updater and a partial intermediate write', () => { + const handle = useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'] + useAppStore.getState().setRemoteBrowserPageHandle('sibling', handle) + useAppStore.setState((state) => { + const handles = copyBrowserPageHandles(state.remoteBrowserPageHandlesByPageId, ['page-a']) + delete handles['page-a'] + useAppStore.setState(() => { + expect(() => + useAppStore.setState(() => { + useAppStore.getState().removeRemoteBrowserPageHandle('sibling') + throw new Error('rejected-ancestor') + }) + ).toThrow('rejected-ancestor') + return { activeBrowserTabId: null } + }) + expect(disposition('sibling')).toBeUndefined() + return { remoteBrowserPageHandlesByPageId: handles } + }) + loss() + expect(disposition('page-a')).toBeUndefined() + expect(disposition('sibling')).toBe('unavailable') +}) + +it('still rejects an unannotated candidate after its nested write was accepted', () => { + const handle = useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'] + useAppStore.getState().setRemoteBrowserPageHandle('sibling', handle) + useAppStore.setState((state) => { + const handles = copyBrowserPageHandles(state.remoteBrowserPageHandlesByPageId, ['page-a']) + delete handles['page-a'] + expect(() => + useAppStore.setState(() => { + useAppStore.getState().removeRemoteBrowserPageHandle('sibling') + return { remoteBrowserPageHandlesByPageId: {} } + }) + ).toThrow('missing changed page IDs') + expect(disposition('page-a')).toBe('pending') + return { remoteBrowserPageHandlesByPageId: handles } + }) + loss() + expect(disposition('sibling')).toBe('unavailable') +}) + +it('carries a nested subscriber write to the evaluating outer updater', () => { + const handle = useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'] + let notified = false + const stop = useAppStore.subscribe(() => { + if (!notified) { + notified = true + useAppStore.getState().setRemoteBrowserPageHandle('sibling', handle) + } + }) + try { + useAppStore.setState((state) => { + const handles = copyBrowserPageHandles(state.remoteBrowserPageHandlesByPageId, ['page-a']) + delete handles['page-a'] + useAppStore.getState().setRemoteBrowserPageHandle('page-a', { ...handle }) + return { remoteBrowserPageHandlesByPageId: handles } + }) + let publications = 0 + const stopCount = useAppStore.subscribe(() => publications++) + try { + loss() + expect(disposition('page-a')).toBeUndefined() + expect(disposition('sibling')).toBeUndefined() + expect(publications).toBe(0) + } finally { + stopCount() + } + } finally { + stop() + } +}) + +it('carries concrete IDs from real nested hydration without a later hot resync', () => { + const handle = useAppStore.getState().remoteBrowserPageHandlesByPageId['page-a'] + useAppStore.getState().setRemoteBrowserPageHandle('sibling', handle) + useAppStore.setState((state) => { + const handles = copyBrowserPageHandles(state.remoteBrowserPageHandlesByPageId, ['page-a']) + delete handles['page-a'] + useAppStore.getState().hydrateBrowserSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + }) + expect(disposition('sibling')).toBeUndefined() + return { remoteBrowserPageHandlesByPageId: handles } + }) + const next = copyBrowserPageHandles(useAppStore.getState().remoteBrowserPageHandlesByPageId, [ + 'sibling' + ]) + let enumerations = 0 + const counted = new Proxy(next, { + ownKeys: (target) => { + enumerations++ + return Reflect.ownKeys(target) + } + }) + useAppStore.setState({ + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles(counted, ['sibling'], next) + }) + expect(enumerations).toBe(0) + loss() + expect(disposition('sibling')).toBe('unavailable') +}) diff --git a/src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.ts b/src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.ts index 458c478338f..391e5d7aa3f 100644 --- a/src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.ts +++ b/src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.ts @@ -1,3 +1,4 @@ +import type { BrowserPageInitializationAction } from '../../../../../shared/browser/client-hosting/browser-page-initialization-action' import { useCallback, useMemo, useState, type MutableRefObject } from 'react' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { useLatestHandler } from '@/lib/use-latest-handler' @@ -14,11 +15,13 @@ import type { BrowserTabPageState } from '../describe-page/browser-page-types' export function useBrowserPageReloadActions({ browserTab, + onInitializationAction, webviewRef, retryGuestRecoveryRef, onUpdatePageStateRef }: { browserTab: BrowserPageState + onInitializationAction?: (action: BrowserPageInitializationAction) => void webviewRef: MutableRefObject retryGuestRecoveryRef: MutableRefObject<() => void> onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void> @@ -42,6 +45,10 @@ export function useBrowserPageReloadActions({ ) // Stable so shortcut listeners don't re-register on every page-state update. const reloadWebviewOrRecoverGuest = useLatestHandler((ignoreCache: boolean) => { + if (onInitializationAction) { + onInitializationAction({ type: 'navigate', url: browserTab.url }) + return + } const webview = webviewRef.current if (!webview) { return @@ -62,6 +69,14 @@ export function useBrowserPageReloadActions({ }) const runReloadTrigger = useCallback( (trigger: BrowserReloadTrigger) => { + if (onInitializationAction) { + onInitializationAction( + resolveBrowserReloadIntent(trigger, reloadState) === 'stop' + ? { type: 'stop' } + : { type: 'navigate', url: browserTab.url } + ) + return + } const webview = webviewRef.current if (!webview) { return @@ -87,6 +102,7 @@ export function useBrowserPageReloadActions({ }, [ browserTab, + onInitializationAction, onUpdatePageStateRef, reloadState, reloadWebviewOrRecoverGuest, diff --git a/src/renderer/src/components/browser-pane/navigate/use-client-hosted-page-initialization.ts b/src/renderer/src/components/browser-pane/navigate/use-client-hosted-page-initialization.ts new file mode 100644 index 00000000000..5694717081b --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/use-client-hosted-page-initialization.ts @@ -0,0 +1,91 @@ +import { useCallback, useLayoutEffect, type RefObject } from 'react' +import { isBlankBrowserUrl } from '@/lib/browser/palette/browser-palette-search' +import { useAppStore } from '@/store' +import type { BrowserPageInitializationAction } from '../../../../../shared/browser/client-hosting/browser-page-initialization-action' +import type { BrowserGuestMetadata } from '../../../../../shared/browser/guest-lifecycle/browser-guest-metadata' +import type { ClientHostedBrowserPagePaneProps } from '../describe-page/browser-page-types' +import { useRestoredClientHostedRecoveryWindow } from '../restored-client-hosted-recovery-window' +import { routeBrowserPageInitializationAction } from './browser-page-initialization-action' + +export function useClientHostedPageInitialization( + props: ClientHostedBrowserPagePaneProps, + attachmentError: string | null, + loadFailureRef: RefObject +) { + const { + browserTab, + placement, + runtimeEnvironmentId, + workspaceId, + worktreeId, + onUpdatePageState + } = props + const presentation = useAppStore( + (state) => state.remoteBrowserPageHandlesByPageId[browserTab.id]?.initializationPresentation + ) + const ownedInitialization = + presentation?.disposition === 'pending' || presentation?.disposition === 'stopped' + const ownedPending = presentation?.disposition === 'pending' + useLayoutEffect(() => { + if (ownedInitialization) { + loadFailureRef.current = null + } + }, [ownedInitialization, loadFailureRef]) + const restoredPageUnrecovered = useRestoredClientHostedRecoveryWindow({ + ownedInitialization, + browserPageId: browserTab.id, + environmentId: runtimeEnvironmentId, + placementPending: placement === null + }) + const actOnInitialization = useCallback( + (action: BrowserPageInitializationAction) => { + const handle = useAppStore.getState().remoteBrowserPageHandlesByPageId[browserTab.id] + const expected = placement ?? handle?.placement + if (expected?.kind !== 'client') { + return + } + if (action.type === 'navigate') { + loadFailureRef.current = null + onUpdatePageState(browserTab.id, { loading: true, loadError: null }) + } + void routeBrowserPageInitializationAction({ + pageId: browserTab.id, + workspaceId, + worktreeId, + environmentId: runtimeEnvironmentId, + placement: expected, + action + }) + }, + [ + loadFailureRef, + browserTab.id, + onUpdatePageState, + placement, + runtimeEnvironmentId, + workspaceId, + worktreeId + ] + ) + const initializationAction = ownedInitialization ? actOnInitialization : undefined + const unavailable = + presentation?.disposition === 'unavailable' || + (!ownedInitialization && (Boolean(attachmentError) || restoredPageUnrecovered)) + const showFailureOverlay = !presentation && !unavailable && Boolean(browserTab.loadError) + + // Unrecovered pages have no pending load that could ever clear a loading indicator. + const loading = presentation + ? ownedPending + : !restoredPageUnrecovered && (placement === null || browserTab.loading) + + return { + initializationAction, + ownedInitialization, + showNewTab: !presentation && isBlankBrowserUrl(browserTab.url), + reloadPage: presentation ? { ...browserTab, loading, loadError: null } : browserTab, + suppressAttachment: Boolean(presentation), + unavailable, + showFailureOverlay, + loading + } +} diff --git a/src/renderer/src/components/browser-pane/navigate/use-client-hosted-page-url-submission.ts b/src/renderer/src/components/browser-pane/navigate/use-client-hosted-page-url-submission.ts index 45e6953cab4..1a3bce97f77 100644 --- a/src/renderer/src/components/browser-pane/navigate/use-client-hosted-page-url-submission.ts +++ b/src/renderer/src/components/browser-pane/navigate/use-client-hosted-page-url-submission.ts @@ -1,3 +1,4 @@ +import type { BrowserPageInitializationAction } from '../../../../../shared/browser/client-hosting/browser-page-initialization-action' import { issueBrowserGuestNavigation } from '../host-guest/browser-guest-metadata-cache' import { useCallback } from 'react' import type { MutableRefObject, RefObject } from 'react' @@ -11,6 +12,7 @@ import type { BrowserPageUrlSetter, BrowserTabPageState } from '../describe-page /** Resolve workspace documents before URLs; refuse local file: access and defer navigation until a staged guest attaches. */ export function useClientHostedPageUrlSubmission(params: { + onInitializationAction?: (action: BrowserPageInitializationAction) => void browserTabId: string worktreeId: string webviewRef: RefObject @@ -21,6 +23,7 @@ export function useClientHostedPageUrlSubmission(params: { }): (value: string) => void { const { browserTabId, + onInitializationAction, worktreeId, webviewRef, activeLoadFailureRef, @@ -47,12 +50,16 @@ export function useClientHostedPageUrlSubmission(params: { const browserModelUrl = redactKagiSessionToken(submission.url) activeLoadFailureRef.current = null setAddressBarValue(toDisplayUrl(browserModelUrl)) + onInitializationAction?.({ type: 'navigate', url: submission.url }) onSetUrl(browserTabId, browserModelUrl) onUpdatePageState(browserTabId, { loading: true, loadError: null, title: getBrowserDisplayTitle(browserModelUrl, browserModelUrl) }) + if (onInitializationAction) { + return + } const webview = webviewRef.current if (!webview) { deferBrowserPageNavigation(browserTabId, submission.url) @@ -66,6 +73,7 @@ export function useClientHostedPageUrlSubmission(params: { [ activeLoadFailureRef, browserTabId, + onInitializationAction, onUpdatePageState, onSetUrl, setAddressBarValue, diff --git a/src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx b/src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx index fbf164522e7..686b020c001 100644 --- a/src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx +++ b/src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx @@ -1,4 +1,8 @@ // @vitest-environment happy-dom +import { + annotateBrowserPageHandles, + markHydratedBrowserPageHandles +} from '@/store/slices/browser/hosts/browser-initialization-presentation-ownership' import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -64,23 +68,25 @@ function runtimeStatusMap( function seedStore(options: { restored: boolean; environment: EnvironmentReachability }): void { useAppStore.setState({ - remoteBrowserPageHandlesByPageId: options.restored - ? { - [PAGE_ID]: { - environmentId: ENVIRONMENT_ID, - remotePageId: 'remote-page-a', - restoredFromSession: true, - restoredClientHosted: true + remoteBrowserPageHandlesByPageId: markHydratedBrowserPageHandles( + options.restored + ? { + [PAGE_ID]: { + environmentId: ENVIRONMENT_ID, + remotePageId: 'remote-page-a', + restoredFromSession: true, + restoredClientHosted: true + } } - } - : { - [PAGE_ID]: { - environmentId: ENVIRONMENT_ID, - remotePageId: 'remote-page-a', - staged: true, - stagedClientHosted: true + : { + [PAGE_ID]: { + environmentId: ENVIRONMENT_ID, + remotePageId: 'remote-page-a', + staged: true, + stagedClientHosted: true + } } - }, + ), runtimeStatusByEnvironmentId: runtimeStatusMap(options.environment) }) } @@ -121,7 +127,7 @@ describe('restored client-hosted recovery window', () => { cleanup() vi.useRealTimers() useAppStore.setState({ - remoteBrowserPageHandlesByPageId: {}, + remoteBrowserPageHandlesByPageId: markHydratedBrowserPageHandles({}), runtimeStatusByEnvironmentId: new Map() }) }) @@ -150,23 +156,29 @@ describe('restored client-hosted recovery window', () => { act(() => vi.advanceTimersByTime(RESTORED_CLIENT_HOSTED_RECOVERY_WINDOW_MS)) act(() => { useAppStore.setState({ - remoteBrowserPageHandlesByPageId: { - [PAGE_ID]: { environmentId: ENVIRONMENT_ID, remotePageId: 'remote-page-a' } - } + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles( + { + [PAGE_ID]: { environmentId: ENVIRONMENT_ID, remotePageId: 'remote-page-a' } + }, + [PAGE_ID] + ) }) }) expect(noticeShown()).toBe(false) act(() => { useAppStore.setState({ - remoteBrowserPageHandlesByPageId: { - [PAGE_ID]: { - environmentId: ENVIRONMENT_ID, - remotePageId: 'remote-page-a', - restoredFromSession: true, - restoredClientHosted: true - } - } + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles( + { + [PAGE_ID]: { + environmentId: ENVIRONMENT_ID, + remotePageId: 'remote-page-a', + restoredFromSession: true, + restoredClientHosted: true + } + }, + [PAGE_ID] + ) }) view.rerender(paneElement()) }) diff --git a/src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.ts b/src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.ts index 7b5b7613cdf..a223db93f52 100644 --- a/src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.ts +++ b/src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.ts @@ -8,11 +8,13 @@ export const RESTORED_CLIENT_HOSTED_RECOVERY_WINDOW_MS = 45_000 export function useRestoredClientHostedRecoveryWindow({ browserPageId, environmentId, - placementPending + placementPending, + ownedInitialization = false }: { browserPageId: string environmentId: string placementPending: boolean + ownedInitialization?: boolean }): boolean { // The snapshot supplying placement also clears the restored marker. const restoredFromSession = useAppStore( @@ -21,7 +23,7 @@ export function useRestoredClientHostedRecoveryWindow({ const environmentReachable = useAppStore( (s) => s.runtimeStatusByEnvironmentId.get(environmentId)?.status != null ) - const awaitingRecovery = restoredFromSession && placementPending + const awaitingRecovery = restoredFromSession && placementPending && !ownedInitialization const [windowElapsed, setWindowElapsed] = useState(false) useEffect(() => { diff --git a/src/renderer/src/components/cmd-j/palette-live-status.tsx b/src/renderer/src/components/cmd-j/palette-live-status.tsx index bb3d6f395db..041ebbc4804 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.tsx @@ -10,13 +10,13 @@ import { FilledBellIcon } from '@/components/sidebar/workspace-card/identity/wor import { buildExplicitEntriesByTabId, type TabPaneInputSources -} from '../../../../shared/sidebar-list/smart-attention' +} from '../../../../shared/sidebar-list/sorting/smart-attention' import { isAgentStatusFresh, AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status/contracts/agent-status-freshness' import { getLiveAgentStatusByWorktreeId } from '@/lib/workspace/activity/worktree-activity-state' -import type { LiveAgentWorktreeStatus } from '../../../../shared/sidebar-list/workspace-activity-facts' +import type { LiveAgentWorktreeStatus } from '../../../../shared/sidebar-list/feed-facts/workspace-activity-facts' import { getWorktreeStatus, getWorktreeStatusLabel } from '@/lib/workspace/activity/worktree-status' import type { WorktreeStatus } from '../../../../shared/worktree-status-rollup' import { diff --git a/src/renderer/src/components/cmd-j/palette-project-results.ts b/src/renderer/src/components/cmd-j/palette-project-results.ts index 0d0e2f81bb1..9b97284c794 100644 --- a/src/renderer/src/components/cmd-j/palette-project-results.ts +++ b/src/renderer/src/components/cmd-j/palette-project-results.ts @@ -12,7 +12,7 @@ import type { Project, ProjectHostSetup } from '../../../../shared/project-types import type { Repo } from '../../../../shared/repo-types' import { translate } from '@/i18n/i18n' import type { Translate } from '@/i18n/use-translate' -import { getProjectGroupHeaderKey } from '../../../../shared/sidebar-list/group-keys' +import { getProjectGroupHeaderKey } from '../../../../shared/sidebar-list/sections/group-keys' import { getDistinctProjectGroups } from '../../../../shared/project-groups' import { getProjectHeaderRevealTarget } from '../../../../shared/project-section-grouping' import type { ProjectGroupingModel } from '../../../../shared/project-section-grouping' diff --git a/src/renderer/src/components/editor/EditorPanel.html-preview.test.tsx b/src/renderer/src/components/editor/EditorPanel.html-preview.test.tsx index fa005061013..a9477168f36 100644 --- a/src/renderer/src/components/editor/EditorPanel.html-preview.test.tsx +++ b/src/renderer/src/components/editor/EditorPanel.html-preview.test.tsx @@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' +import { markHydratedBrowserPageHandles } from '@/store/slices/browser/hosts/browser-initialization-presentation-ownership' import { TooltipProvider } from '@/components/ui/tooltip' import { EDITOR_OWNER, @@ -25,7 +26,16 @@ import EditorPanel from './EditorPanel' afterEach(() => { cleanup() - useAppStore.setState(useAppStore.getInitialState(), true) + const initialState = useAppStore.getInitialState() + useAppStore.setState( + { + ...initialState, + remoteBrowserPageHandlesByPageId: markHydratedBrowserPageHandles({ + ...initialState.remoteBrowserPageHandlesByPageId + }) + }, + true + ) }) it('opens the HTML preview in the clicked editor’s folder group', async () => { diff --git a/src/renderer/src/components/editor/open-editor-html-preview.test.ts b/src/renderer/src/components/editor/open-editor-html-preview.test.ts index 752bf9141e6..6da5b17735e 100644 --- a/src/renderer/src/components/editor/open-editor-html-preview.test.ts +++ b/src/renderer/src/components/editor/open-editor-html-preview.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useAppStore } from '@/store/index' +import { markHydratedBrowserPageHandles } from '@/store/slices/browser/hosts/browser-initialization-presentation-ownership' import { createStoreCascadesMockApi } from '@/store/testing/app-store-cascade-api.test-fixture' import { EDITOR_OWNER, @@ -20,9 +21,21 @@ vi.mock('@/components/browser-pane/host-guest/browser-focus', () => ({ const initialState = useAppStore.getInitialState() +function resetBrowserLinkStore(): void { + useAppStore.setState( + { + ...initialState, + remoteBrowserPageHandlesByPageId: markHydratedBrowserPageHandles({ + ...initialState.remoteBrowserPageHandlesByPageId + }) + }, + true + ) +} + beforeEach(() => { createStoreCascadesMockApi() - useAppStore.setState(initialState, true) + resetBrowserLinkStore() seedBrowserLinkTestStore(useAppStore) useAppStore.setState((state) => ({ openFiles: [ @@ -44,7 +57,7 @@ beforeEach(() => { } })) }) -afterEach(() => useAppStore.setState(initialState, true)) +afterEach(resetBrowserLinkStore) it('opens HTML in its editor group even when another pane is active, and reuses it on repeat', async () => { await openEditorHtmlPreview('report', 'source') diff --git a/src/renderer/src/components/native-chat-recovery/tree/native-chat-resume-workspace-lookups.ts b/src/renderer/src/components/native-chat-recovery/tree/native-chat-resume-workspace-lookups.ts index 9de3a406497..97f81fc6b9c 100644 --- a/src/renderer/src/components/native-chat-recovery/tree/native-chat-resume-workspace-lookups.ts +++ b/src/renderer/src/components/native-chat-recovery/tree/native-chat-resume-workspace-lookups.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { getCyclicProjectedWorktreeLineageIds, getSidebarLineageAncestors -} from '../../../../../shared/sidebar-list/worktree-lineage-projection' +} from '../../../../../shared/sidebar-list/lineage/worktree-lineage-projection' import { getIndexedAllWorktrees, getIndexedWorktreeMap diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 91f07967fda..b2e63de3882 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -9,7 +9,6 @@ import { useNativeChatNewRowsMarker } from './viewport/new-rows/native-chat-new- import { withoutPullRequestEventRows } from '../../../../shared/native-chat/native-chat-pull-request-event' import { useNativeChatRowTypography } from './viewport/virtualization/use-native-chat-row-typography' import { useCallback, useMemo, useRef, useState } from 'react' -import { useTranslate } from '@/i18n/use-translate' import { useAppStore } from '@/store' import { nativeChatShowsCiResultLines, @@ -41,9 +40,11 @@ import { useNativeChatTranscriptSlots } from './conversation/slots/use-native-ch import type { NativeChatTimedRow } from './conversation/slots/native-chat-timed-rows' import { useNativeChatTranscriptWindow } from './viewport/virtualization/use-native-chat-transcript-window' import { useNativeChatHistoryRetention } from './viewport/history/use-native-chat-history-retention' -import { useNativeChatSubagentSections } from './subagents/use-native-chat-subagent-sections' +import { useNativeChatSubagentScope } from './subagents/use-native-chat-subagent-scope' +import type { NativeChatSubagentView } from './subagents/native-chat-open-subagent' +import type { StructuredAgentSubagentRoster } from '../../../../shared/agent-session/view/structured-agent-session-subagent-roster' +import type { NativeChatTranscriptSlot } from './conversation/slots/native-chat-transcript-slots' import { toggleNativeChatExpandedKey } from './messages/disclosure/native-chat-expanded-keys' -import { useNativeChatTurnMembership } from './conversation/slots/use-native-chat-turn-membership' import { useNativeChatSideChatForkOffer, useNativeChatSideChatForkPointReport @@ -75,6 +76,8 @@ import { NativeChatPullRequestChangeContext } from './tools/pull-requests/Native import { transcriptTailRow } from './conversation/slots/native-chat-transcript-tail-row' import type { NativeChatAwaitingInput } from '../../../../shared/native-chat/native-chat-terminal-turn' +const NO_SLOTS: readonly NativeChatTranscriptSlot[] = [] + export function NativeChatMessageList({ ref, session, @@ -83,6 +86,7 @@ export function NativeChatMessageList({ journalLatestTurn: latestTurn, journalHostWork: hostWork, subagentRoster, + subagentView, railOutline = null, isVisible = true, isWorking, @@ -108,7 +112,9 @@ export function NativeChatMessageList({ /** The host's prompts still waiting on the person and its working answer (an older host: none). */ journalHostWork?: StructuredAgentSessionHostWork /** Every subagent the session's rosters named, whether or not its roster row is loaded. */ - subagentRoster?: Parameters[2] + subagentRoster?: StructuredAgentSubagentRoster + /** The subagent shown in place of the conversation (its own rows, no turns), and how to open one. */ + subagentView?: NativeChatSubagentView /** User messages older than the loaded window, from the host's outline. */ railOutline?: readonly NativeChatRailOutlineEntry[] | null isVisible?: boolean @@ -128,7 +134,6 @@ export function NativeChatMessageList({ stopping?: boolean runtimeContext?: RuntimeFileOperationArgs | null }): React.JSX.Element { - const t = useTranslate() const receipts = useNativeChatQuestionReceipts(journalItems) const scrollRef = useRef(null) const contentRef = useRef(null) @@ -145,34 +150,28 @@ export function NativeChatMessageList({ root: contentRef, sourceKey: `${session.agent}:${session.sessionId}` }) - // Pending/failed reads cannot page; recovery remounts the sentinel to recheck its range. - const showOlderHistory = hasMore && session.readPhase === 'ready' - const { messages, subagentRows } = useNativeChatTranscriptProjection( + const { messages: conversation, subagentRows } = useNativeChatTranscriptProjection( session, journalItems, journalSubmissions, latestTurn ) - const { - sections: subagentSections, - subagentRowsInOrder, - subagentChoices, - subagentDisclosure, - openSubagentSections - } = useNativeChatSubagentSections(messages, subagentRows, subagentRoster) - // Folded subagent rows stay loaded, so a reopened reply keeps its reveal. - const loadedMessageIds = useMemo( - () => new Set([...messages.map(({ id }) => id), ...subagentSections.pathOf.keys()]), - [messages, subagentSections] - ) - const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages]) - const turnRows = useNativeChatTurnMembership( - messages, + const scope = useNativeChatSubagentScope({ + view: subagentView, + conversation, + subagentRows, + roster: subagentRoster, journalItems, journalSubmissions, - latestTurn - ) + latestTurn, + isWorking + }) + const { agentId: subagentId, messages, turnRows, working, rowsInOrder } = scope + // Pending/failed reads cannot page; recovery remounts the sentinel to recheck its range. + const showOlderHistory = hasMore && session.readPhase === 'ready' && scope.pagesHistory + const loadedMessageIds = useMemo(() => new Set(messages.map(({ id }) => id)), [messages]) + const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages]) const { liveTurnKey } = turnRows const showCiResultLines = useAppStore((state) => nativeChatShowsCiResultLines(state.settings)) // Hidden lines leave the transcript entirely, so they reserve no row or gap. @@ -182,28 +181,27 @@ export function NativeChatMessageList({ ) const latestTurnKey = turnKeys.findLast((key) => key !== undefined) const showTurnDiffs = useAppStore((state) => nativeChatShowsTurnDiffSummary(state.settings)) - const turnDiffs = useNativeChatTurnDiffs( - showTurnDiffs ? turnRows : null, - subagentRowsInOrder, - subagentSections.pathOf - ) - const openedPullRequests = useNativeChatTurnOpenedPullRequests(turnRows, subagentRowsInOrder) + const turns = scope.conversationTurns + const turnDiffs = useNativeChatTurnDiffs(showTurnDiffs ? turns : null, rowsInOrder) + const openedPullRequests = useNativeChatTurnOpenedPullRequests(turns, rowsInOrder) // Only real tail reasoning counts as thinking; an in-flight request with no output does not. const thinking = useMemo( - () => isStructuredAgentSessionThinking({ items: journalItems ?? [], latestTurn }), - [journalItems, latestTurn] + () => + subagentId === null && + isStructuredAgentSessionThinking({ items: journalItems ?? [], latestTurn }), + [journalItems, latestTurn, subagentId] ) const turnStatuses = useNativeChatTurnStatus({ turnKeys, liveTurnKey, - isWorking, + isWorking: working, workingStartedAt, settledTurns, thinking }) // Only unshown prompts need a waiting tail row; prompt cards explain their own wait. - const tailRow = transcriptTailRow(isWorking, awaitingInput) - const lifecycleWorking = session.transcriptLifecycle?.state === 'working' + const tailRow = transcriptTailRow(working, awaitingInput) + const lifecycleWorking = subagentId === null && session.transcriptLifecycle?.state === 'working' const { measureContent, typography } = useNativeChatRowTypography(contentRef) const forkOffer = useNativeChatSideChatForkOffer(latestTurnKey) const transcript = useNativeChatTranscriptSlots({ @@ -217,10 +215,9 @@ export function NativeChatMessageList({ turnDiffs, openedPullRequests: openedPullRequests.byTurn, expandedTurnKeys: expandedTurnIds, - isWorking, + isWorking: working, lifecycleWorking, - subagentSections, - subagentChoices, + subagentHeads: scope.heads, forkOffer, line: { tailRow, @@ -243,7 +240,7 @@ export function NativeChatMessageList({ ), [allSlots, journalItems, stopping, journalSubmissions, latestTurn, hostWork] ) - const navigationRequest = useNativeChatNavigationRequest(slots, messages, subagentSections) + const navigationRequest = useNativeChatNavigationRequest(slots) const newRows = useNativeChatNewRowsMarker( slots, waitingSlots, @@ -262,7 +259,7 @@ export function NativeChatMessageList({ scrollRef, contentRef, itemCount: slots.length, - isWorking, + isWorking: working, showsTailRow: tailRow !== null, isVisible, alignToViewportTop: transcriptWindow.alignToViewportTop, @@ -289,7 +286,8 @@ export function NativeChatMessageList({ }) const rail = useNativeChatMessageRail({ scrollRef, - slots, + // The rail outlines the conversation's prompts; a subagent's view has none. + slots: subagentId === null ? slots : NO_SLOTS, turnRows, virtualItems: transcriptWindow.virtualItems, outline: railOutline @@ -298,15 +296,15 @@ export function NativeChatMessageList({ request: navigationRequest, rail, slots, - conversation: messages, - sections: subagentSections, + conversation, + subagentRows, roster: subagentRoster, journalItems, - openSubagentSections, - subagentDisclosure, + subagentId, sessionKey: `${session.agent}:${session.sessionId}`, isVisible, loadEarlier, + holdHistory: historyRetention.leaveEnd, scrollRef, scrollMessageToTop, readerLeavesEnd, @@ -339,8 +337,7 @@ export function NativeChatMessageList({ scrollToBottom, onSent: newRows.dismiss, revealFindMatch: readerInput.revealFindMatch, - slots: allSlots, - revealSubagent: navigation.revealSubagent + slots: allSlots }) const rowContext = useNativeChatTranscriptRowContext({ @@ -355,7 +352,7 @@ export function NativeChatMessageList({ deliveryNotices, runtimeContext, onToggleExpandedTurn: toggleExpandedTurn, - subagentDisclosure, + onOpenSubagent: scope.open, onScrollMessageToTop: scrollMessageToTop, onRevealDiff: navigation.revealDiff }) @@ -364,6 +361,7 @@ export function NativeChatMessageList({
+ {scope.bar}
@@ -389,6 +387,7 @@ export function NativeChatMessageList({ // Shares the composer's maximum width; rail clearance and the 5px inner inset narrow constrained panes. className={NATIVE_CHAT_TRANSCRIPT_COLUMN_CLASS} > + {scope.lead} closeNativeChatSubagentOnEscape(event, paneKey)} onContextMenuCapture={contextMenu.onContextMenuCapture} className={cn( NATIVE_CHAT_APPEARANCE_ROOT_CLASS, @@ -350,19 +351,19 @@ export function NativeChatResolvedView({ ) : viewState.kind === 'empty' ? ( ) : ( - - - + )} {/* A collapsed card stays mounted but hidden, so a partly answered question survives. */} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 2ee97beb567..126e8c16ce1 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -19,8 +19,8 @@ import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/dr import { NativeChatEmptyState } from '@/components/native-chat/host-access/NativeChatEmptyState' import { NativeChatEmptyPane } from '@/components/native-chat/host-access/NativeChatEmptyPane' import { NativeChatLoadingCue } from '@/components/native-chat/host-access/NativeChatLoadingCue' -import { NativeChatMessageList } from '@/components/native-chat/NativeChatMessageList' -import { NativeChatToolRunWindowProvider } from '@/components/native-chat/tools/runs/native-chat-tool-run-window-context' +import { NativeChatSubagentTranscripts } from '@/components/native-chat/subagents/NativeChatSubagentTranscripts' +import { useStructuredNativeChatSubagents } from '@/components/native-chat/subagents/use-structured-native-chat-subagents' import { NativeChatContextClearFade } from '@/components/native-chat/conversation/projection/NativeChatContextClearFade' import { useStructuredNativeChatSubmitReveal } from '@/components/native-chat/pane/actions/use-structured-native-chat-submit-reveal' import { useStructuredPromptResponseHold } from '@/components/native-chat/prompts/use-structured-prompt-response-hold' @@ -44,7 +44,6 @@ import { useStructuredAgentSession } from '@/components/native-chat/session/stru import { useNativeChatImageRuntimeContext } from '@/components/native-chat/media/images/native-chat-image-runtime-context' import { useStructuredNativeChatPaneCommands } from '@/components/native-chat/pane/actions/use-structured-native-chat-pane-commands' import type { NativeChatStructuredViewProps } from '@/components/native-chat/native-chat-view-types' -import { NativeChatStructuredSessionStatus } from '@/components/native-chat/tools/tasks/NativeChatStructuredSessionStatus' import { useNativeChatInterruptedContinuation } from '@/components/native-chat/usage/NativeChatInterruptedContinue' import { NativeChatPromptSlotNotices } from '@/components/native-chat/composer/controls/NativeChatComposerNotices' import { @@ -189,7 +188,11 @@ export function NativeChatStructuredSession(props: Omit - - - {(timedRows) => ( - - )} - - + + {(timedRows) => ( + + )} + @@ -350,20 +354,14 @@ export function NativeChatStructuredSession(props: Omit - + {subagents.strip} {/* Workers' open prompts stay pinned here, above this chat's own prompt. */} - {prompt ? null : ( + {prompt || subagentOpen ? null : ( )} - {composerShown ? null : } + {composerShown || subagentOpen ? null : } {nativeChatStructuredAsyncQuestion(props, controller, session, transport, submits)} {composerShown && } + {subagents.statusBar} {composerShown ? ( - slot.kind === 'message' && slot.depth === 0 && slot.message.timestamp !== null + slot.kind === 'message' && slot.message.timestamp !== null ? [{ id: slot.message.id, at: slot.message.timestamp }] : [] ) diff --git a/src/renderer/src/components/native-chat/conversation/slots/native-chat-transcript-input.ts b/src/renderer/src/components/native-chat/conversation/slots/native-chat-transcript-input.ts index 91efb9c108a..1f70971fa76 100644 --- a/src/renderer/src/components/native-chat/conversation/slots/native-chat-transcript-input.ts +++ b/src/renderer/src/components/native-chat/conversation/slots/native-chat-transcript-input.ts @@ -5,10 +5,7 @@ import type { NativeChatTurnDiff } from '../../../../../../shared/native-chat/to import type { NativeChatOpenedPullRequest } from '../../../../../../shared/native-chat/tools/native-chat-opened-pull-requests' import type { NativeChatRowTypography } from '../../viewport/virtualization/native-chat-row-height-estimate' import type { NativeChatSideChatForkOffer } from '../../messages/actions/native-chat-side-chat-fork-point' -import type { - NativeChatSubagentChoices, - NativeChatSubagentSections -} from '../../subagents/native-chat-subagent-sections' +import type { NativeChatSubagentHead } from '../../subagents/native-chat-subagent-scope' import type { NativeChatMarkdownImageSources } from '../../../../../../shared/native-chat/transcript/native-chat-generated-image-answers' @@ -34,8 +31,8 @@ export type NativeChatTranscriptSlotsInput = { lifecycleWorking: boolean /** Only where `/btw` listens: find the newest response that offers Fork. */ forkOffer?: NativeChatSideChatForkOffer - subagentSections?: NativeChatSubagentSections - subagentChoices?: NativeChatSubagentChoices + /** Subagents no roster row in this transcript names, each drawn as a head at its first row. */ + subagentHeads?: readonly NativeChatSubagentHead[] /** The open reasoning block the live activity line discloses: its row takes no slot meanwhile. */ liveReasoningId?: string | null } diff --git a/src/renderer/src/components/native-chat/conversation/slots/native-chat-transcript-slot-selection.ts b/src/renderer/src/components/native-chat/conversation/slots/native-chat-transcript-slot-selection.ts index 1c66b2ececd..e43e5fff5df 100644 --- a/src/renderer/src/components/native-chat/conversation/slots/native-chat-transcript-slot-selection.ts +++ b/src/renderer/src/components/native-chat/conversation/slots/native-chat-transcript-slot-selection.ts @@ -10,9 +10,7 @@ export function nativeChatSlotKey(slot: NativeChatTranscriptSlot): string { case 'message': return slot.message.id case 'subagent': - return `subagent-section:${slot.agentId}` - case 'subagent-entries': - return `subagent-entries:${slot.rosterRowId}:${slot.agents[0]?.id}` + return `subagent-head:${slot.agentId}` } } diff --git a/src/renderer/src/components/native-chat/conversation/slots/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/conversation/slots/native-chat-transcript-slots.ts index 170731386b1..57c11686826 100644 --- a/src/renderer/src/components/native-chat/conversation/slots/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/conversation/slots/native-chat-transcript-slots.ts @@ -1,6 +1,9 @@ // Count only rendered rows: virtualized entries reserve height even when React would render null. -import type { NativeChatMessage } from '../../../../../../shared/native-chat/native-chat-types' +import type { + NativeChatMessage, + NativeChatSubagentEntry +} from '../../../../../../shared/native-chat/native-chat-types' import type { NativeChatTurnStatus } from '../../../../../../shared/native-chat/transcript/native-chat-turn-status' import { isNativeChatRowInLiveWorkingTurn } from '../../../../../../shared/native-chat/transcript/native-chat-live-turn-membership' import { nativeChatTurnBarRows } from '../../../../../../shared/native-chat/transcript/native-chat-turn-grouping' @@ -13,22 +16,14 @@ import { nativeChatGeneratedImageAnswers } from '../../../../../../shared/native import { nativeChatMarkdownImageSources } from '../../markdown/native-chat-markdown-image-sources' import { estimateNativeChatRowHeight, - nativeChatRowContentMetrics + nativeChatRowContentMetrics, + NATIVE_CHAT_SUBAGENT_HEAD_PX } from '../../viewport/virtualization/native-chat-row-height-estimate' import type { NativeChatResolvedPrompt } from '../../../../../../shared/native-chat/native-chat-resolution-receipt' import type { NativeChatTurnDiff } from '../../../../../../shared/native-chat/tools/native-chat-turn-diffs' import type { NativeChatOpenedPullRequest } from '../../../../../../shared/native-chat/tools/native-chat-opened-pull-requests' -import { - NO_NATIVE_CHAT_SUBAGENT_CHOICES, - NO_NATIVE_CHAT_SUBAGENT_SECTIONS, - type NativeChatSubagentRosterState -} from '../../subagents/native-chat-subagent-sections' -import { nativeChatSubagentLiveSections } from '../../subagents/native-chat-subagent-live-frontier' -import { - nativeChatSubagentSectionSlots, - type NativeChatSubagentEntriesSlot, - type NativeChatSubagentSectionSlot -} from '../../subagents/native-chat-subagent-section-slots' +import type { NativeChatSubagentHead } from '../../subagents/native-chat-subagent-scope' +import { compareNativeChatDisplayMessages as compareMessages } from '../../../../../../shared/native-chat/transcript/native-chat-message-order' import { isGeneratedImageOperation } from '../../../../../../shared/native-chat/native-chat-generated-image' import { nativeChatRowRendersProse, @@ -49,10 +44,18 @@ import { import { markNativeChatResponseActionsHang } from './native-chat-response-actions-hang' import type { Translate } from '../../../../i18n/use-translate' -export type NativeChatTranscriptSlot = - | NativeChatMessageSlot - | NativeChatSubagentSectionSlot - | NativeChatSubagentEntriesSlot +export type NativeChatTranscriptSlot = NativeChatMessageSlot | NativeChatSubagentHeadSlot + +/** Opens a subagent no loaded roster row names, at its first row. */ +export type NativeChatSubagentHeadSlot = { + kind: 'subagent' + agentId: string + /** The conversation turn the head sits in, so the outline rail can place it. */ + turnKey: string | undefined + /** The roster's entry for the agent; absent when no roster names it. */ + entry: NativeChatSubagentEntry | undefined + estimatedHeight: number +} export type NativeChatMessageSlot = { kind: 'message' @@ -77,28 +80,25 @@ export type NativeChatMessageSlot = { /** The agent's next row in the same turn follows directly, so the two sit close. */ continuesTurn?: boolean /** In a turn: whether this row holds its response actions, even when tools or thoughts follow: - * the last drawn response with words, else the last with images. Absent outside a turn and in - * subagent sections. */ + * the last drawn response with words, else the last with images. Absent outside a turn. */ endsResponse?: boolean /** This response is the newest one in the conversation. */ latestResponse?: boolean /** Response controls under this row may hang into the gap below it: the row draws nothing after * its message, and what follows is the person's own right-aligned bubble or nothing. Absent when * something left-side follows (an agent's card, a notice, a task or subagent row, the turn's bar, - * diff rollup or PR card), where the controls keep their own height, and in subagent sections. */ + * diff rollup or PR card), where the controls keep their own height. */ actionsHang?: boolean turnDiff: NativeChatTurnDiff | undefined /** Pull requests this row's turn opened, drawn after it. */ openedPullRequests?: readonly NativeChatOpenedPullRequest[] - /** A closed roster list hides child sections regardless of their individual expansion state. */ - subagentRoster: NativeChatSubagentRosterState | undefined - /** Subagent sections this row sits inside; 0 is the conversation. */ - depth: number estimatedHeight: number /** Members of an unbroken work run, with message as its head. */ workRun?: readonly NativeChatMessage[] } +const NO_SUBAGENT_HEADS: readonly NativeChatSubagentHead[] = [] + export function buildNativeChatTranscriptSlots( input: NativeChatTranscriptSlotsInput, t?: Translate @@ -128,8 +128,7 @@ export function buildNativeChatTranscript( expandedTurnKeys, isWorking, lifecycleWorking, - subagentSections: sections = NO_NATIVE_CHAT_SUBAGENT_SECTIONS, - subagentChoices: choices = NO_NATIVE_CHAT_SUBAGENT_CHOICES, + subagentHeads = NO_SUBAGENT_HEADS, liveReasoningId = null, forkOffer } = input @@ -166,18 +165,25 @@ export function buildNativeChatTranscript( } }) const slots: NativeChatTranscriptSlot[] = [] - const live = nativeChatSubagentLiveSections(messages, sections, isWorking || lifecycleWorking) - const sectionSlots = nativeChatSubagentSectionSlots({ - sections, - choices, - live, - receipts, - slots, - typography - }) - const pending = [...(sections.openAt.get(null) ?? [])] + let nextHead = 0 + /** Heads whose first row comes before `message`; undefined flushes the rest. */ + const pushHeadsBefore = (message: NativeChatMessage | undefined): void => { + for (; nextHead < subagentHeads.length; nextHead += 1) { + const head = subagentHeads[nextHead]! + if (message !== undefined && compareMessages(head.first.message, message) >= 0) { + return + } + slots.push({ + kind: 'subagent', + agentId: head.agentId, + turnKey: head.first.turnKey, + entry: head.entry, + estimatedHeight: NATIVE_CHAT_SUBAGENT_HEAD_PX * (typography?.spacingScale ?? 1) + }) + } + } for (const [index, message] of messages.entries()) { - sectionSlots.openBefore(pending, message, 0) + pushHeadsBefore(message) const turnKey = turnKeys[index] const receipt = receipts.get(message.id) const bar = turnKey === undefined ? undefined : bars.get(turnKey) @@ -204,7 +210,6 @@ export function buildNativeChatTranscript( (!folded && (message.agentStart || nativeChatRowRendersContent(message.blocks)) && message.id !== liveReasoningId) - const roster = sectionSlots.rosterAt(message.id) if ( drawsRow || status !== undefined || @@ -225,8 +230,6 @@ export function buildNativeChatTranscript( turnFolds: turnKey !== undefined && foldableTurnKeys.has(turnKey), turnDiff, openedPullRequests, - subagentRoster: roster, - depth: 0, estimatedHeight: estimateNativeChatRowHeight( nativeChatRowContentMetrics(message, typography), { @@ -241,9 +244,8 @@ export function buildNativeChatTranscript( ) }) } - sectionSlots.openAnchoredAt(message, roster, turnKey) } - sectionSlots.openBefore(pending, undefined, 0) + pushHeadsBefore(undefined) const drawn = nativeChatTranscriptWorkRuns(slots, typography) // One action holder per turn: its last drawn response with words, else (when folding hid them) // its last drawn image response. Tools and thoughts never hold them. @@ -251,7 +253,7 @@ export function buildNativeChatTranscript( const lastImageByTurn = new Map() let newestOffered: NativeChatMessageSlot | undefined for (const slot of drawn) { - if (slot.kind !== 'message' || slot.depth !== 0) { + if (slot.kind !== 'message') { continue } if (forkOffer && nativeChatSlotOffersFork(slot, forkOffer)) { @@ -284,7 +286,6 @@ export function buildNativeChatTranscript( const latestResponse = drawn.findLast( (slot) => slot.kind === 'message' && - slot.depth === 0 && slot.drawsMessage && !slot.receipt && slot.message.role === 'assistant' && @@ -300,9 +301,6 @@ export function buildNativeChatTranscript( if ( slot.kind === 'message' && next.kind === 'message' && - // A subagent's section keeps the transcript's gap as part of its frame. - slot.depth === 0 && - next.depth === 0 && slot.turnKey !== undefined && next.turnKey === slot.turnKey && // The agent's own next step, not a notice or task row that trails its answer. diff --git a/src/renderer/src/components/native-chat/conversation/slots/use-native-chat-transcript-row-context.ts b/src/renderer/src/components/native-chat/conversation/slots/use-native-chat-transcript-row-context.ts index 012a58dbf12..1e112c2f917 100644 --- a/src/renderer/src/components/native-chat/conversation/slots/use-native-chat-transcript-row-context.ts +++ b/src/renderer/src/components/native-chat/conversation/slots/use-native-chat-transcript-row-context.ts @@ -24,7 +24,7 @@ export function useNativeChatTranscriptRowContext({ runtimeContext, structuredAttachments, onToggleExpandedTurn, - subagentDisclosure, + onOpenSubagent, onScrollMessageToTop, onRevealDiff }: NativeChatTranscriptRowContextInput): NativeChatTranscriptRowContext { @@ -41,7 +41,7 @@ export function useNativeChatTranscriptRowContext({ runtimeContext, structuredAttachments, onToggleExpandedTurn, - subagentDisclosure, + onOpenSubagent, onScrollMessageToTop, onRevealDiff }), @@ -58,7 +58,7 @@ export function useNativeChatTranscriptRowContext({ structuredAttachments, onScrollMessageToTop, taskListPredecessors, - subagentDisclosure, + onOpenSubagent, onToggleExpandedTurn ] ) diff --git a/src/renderer/src/components/native-chat/conversation/slots/use-native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/conversation/slots/use-native-chat-transcript-slots.ts index 92bb31c8821..cbd124834d2 100644 --- a/src/renderer/src/components/native-chat/conversation/slots/use-native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/conversation/slots/use-native-chat-transcript-slots.ts @@ -50,8 +50,7 @@ export function useNativeChatTranscriptSlots({ expandedTurnKeys, isWorking, lifecycleWorking, - subagentSections, - subagentChoices, + subagentHeads, forkOffer } = input const { tailRow, thinking, stopping = false, activityText } = line @@ -102,8 +101,7 @@ export function useNativeChatTranscriptSlots({ expandedTurnKeys, isWorking, lifecycleWorking, - subagentSections, - subagentChoices, + subagentHeads, liveReasoningId, forkOffer }), @@ -118,8 +116,7 @@ export function useNativeChatTranscriptSlots({ messages, openedPullRequests, receipts, - subagentChoices, - subagentSections, + subagentHeads, turnDiffs, turnKeys, turnStatuses, diff --git a/src/renderer/src/components/native-chat/messages/actions/native-chat-side-chat-fork-point.ts b/src/renderer/src/components/native-chat/messages/actions/native-chat-side-chat-fork-point.ts index ef6c6e56698..4d26e3b13b1 100644 --- a/src/renderer/src/components/native-chat/messages/actions/native-chat-side-chat-fork-point.ts +++ b/src/renderer/src/components/native-chat/messages/actions/native-chat-side-chat-fork-point.ts @@ -21,7 +21,7 @@ export function nativeChatSlotOffersFork( return nativeChatRowOffersFork( { message: slot.message, - inSubagentSection: slot.depth > 0, + inSubagentSection: false, turnWorking: slot.activeTurnIsWorking, inLatestTurn: slot.turnKey !== undefined && slot.turnKey === offer.latestTurnKey }, diff --git a/src/renderer/src/components/native-chat/messages/actions/use-native-chat-reveal-latest.ts b/src/renderer/src/components/native-chat/messages/actions/use-native-chat-reveal-latest.ts index 492a8fb14fa..9bd811f738e 100644 --- a/src/renderer/src/components/native-chat/messages/actions/use-native-chat-reveal-latest.ts +++ b/src/renderer/src/components/native-chat/messages/actions/use-native-chat-reveal-latest.ts @@ -60,8 +60,6 @@ export type NativeChatMessageListHandle = { revealFindMatch: (match: Range, bar: DOMRectReadOnly | null) => void /** What the row drawing this message copies from its Copy button; null when none draws it. */ messageCopyText: (messageId: string) => string | null - /** Open a subagent's activity where it sits in the conversation and bring it into view. */ - revealSubagent: (agentId: string) => void } /** This pane's own transcript; an unmounted pane's reveal reaches nothing. */ @@ -97,9 +95,8 @@ export function useNativeChatMessageListHandle( scrollToBottom, onSent, revealFindMatch, - slots, - revealSubagent - }: Pick & { + slots + }: Pick & { beginNavigation: () => void scrollToBottom: (reason: 'send') => void /** The reader answered, whether or not the view moves. */ @@ -124,9 +121,8 @@ export function useNativeChatMessageListHandle( revealLatest, readerAtEnd, revealFindMatch, - messageCopyText: (messageId) => nativeChatSlotCopyText(slots, messageId), - revealSubagent + messageCopyText: (messageId) => nativeChatSlotCopyText(slots, messageId) }), - [readerAtEnd, revealLatest, revealFindMatch, revealSubagent, slots] + [readerAtEnd, revealLatest, revealFindMatch, slots] ) } diff --git a/src/renderer/src/components/native-chat/messages/rows/NativeChatAssistantMessageRow.tsx b/src/renderer/src/components/native-chat/messages/rows/NativeChatAssistantMessageRow.tsx index e709e5008dd..5020d7ff3ec 100644 --- a/src/renderer/src/components/native-chat/messages/rows/NativeChatAssistantMessageRow.tsx +++ b/src/renderer/src/components/native-chat/messages/rows/NativeChatAssistantMessageRow.tsx @@ -54,7 +54,6 @@ export function NativeChatAssistantMessageRow({ endsResponse, latestResponse = true, actionsHang = false, - inSubagentSection = false, runtimeContext, fork } = props @@ -76,7 +75,6 @@ export function NativeChatAssistantMessageRow({ message.role === 'assistant' && message.source === 'transcript' && !message.agentStart && - !inSubagentSection && isRootAgentJournalItem(message) return ( @@ -157,8 +155,7 @@ function toolRunProps( revealedDiff: run ? run.revealedDiff : props.revealedDiff, onRevealDiff: props.onScrollMessageToTop, subagentGroups: content.subagentGroups, - subagentRoster: props.subagentRoster, - subagentDisclosure: props.subagentDisclosure, + onOpenSubagent: props.onOpenSubagent, backgroundTasks: content.backgroundTasks, followsProse: content.markdown.length > 0 || content.hasImages, expandSignal: props.expandSignal, diff --git a/src/renderer/src/components/native-chat/messages/rows/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/messages/rows/NativeChatMessageRow.tsx index 0ee81443a4a..3d338bf9b07 100644 --- a/src/renderer/src/components/native-chat/messages/rows/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/messages/rows/NativeChatMessageRow.tsx @@ -15,10 +15,6 @@ import { nativeChatNoticeBlock } from '../../../../../../shared/native-chat/nati import { nativeChatMessageRowBlocks } from './native-chat-message-row-words' import { ProviderFrameRow } from '../diagnostics/NativeChatProviderDetails' import type { NativeChatDiffReveal } from '../../../../../../shared/native-chat/tools/native-chat-turn-diffs' -import type { - NativeChatSubagentDisclosure, - NativeChatSubagentRosterState -} from '../../subagents/native-chat-subagent-sections' import type { RuntimeFileOperationArgs } from '@/runtime/files/runtime-file-client-types' import { useNativeChatWorkRun } from '../../tools/runs/use-native-chat-work-run' import { useNativeChatRowNavigation } from '../actions/use-native-chat-row-navigation' @@ -65,11 +61,8 @@ export type MessageRowProps = { /** Align this message's top to the top of the scroll viewport. */ onScrollMessageToTop: (el: HTMLElement) => void deliveryNotice?: NativeChatDeliveryNotice - /** On a roster row: its list's state and the subagents whose rows open below it. */ - subagentRoster?: NativeChatSubagentRosterState - subagentDisclosure?: NativeChatSubagentDisclosure - /** Inside a subagent's section, whose border has to reach past the row's controls. */ - inSubagentSection?: boolean + /** On a roster row: opens one of its subagents in its own view. */ + onOpenSubagent?: (agentId: string) => void structuredAttachments?: boolean runtimeContext?: RuntimeFileOperationArgs | null /** On a user row: discards it and everything after it. */ diff --git a/src/renderer/src/components/native-chat/messages/rows/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/messages/rows/NativeChatTranscriptRow.tsx index 0b8694affa9..10811057885 100644 --- a/src/renderer/src/components/native-chat/messages/rows/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/messages/rows/NativeChatTranscriptRow.tsx @@ -6,9 +6,7 @@ import { NativeChatResolutionReceipt } from '../../prompts/NativeChatResolutionR import { NativeChatWorkingStatus } from '../../conversation/turns/NativeChatWorkingStatus' import { NativeChatTurnDiffRollup } from '../../tools/diffs/NativeChatTurnDiffRollup' import { NativeChatOpenedPullRequestCard } from '../../tools/pull-requests/NativeChatOpenedPullRequestCard' -import { NativeChatSubagentSectionHead } from '../../subagents/NativeChatSubagentSectionHead' -import { NativeChatSubagentEntries } from '../../subagents/NativeChatSubagentRun' -import type { NativeChatSubagentDisclosure } from '../../subagents/native-chat-subagent-sections' +import { NativeChatSubagentHead } from '../../subagents/NativeChatSubagentHead' import type { NativeChatTaskListPredecessors } from '../../../../../../shared/native-chat/input/native-chat-task-list-history' import type { NativeChatTranscriptSlot } from '../../conversation/slots/native-chat-transcript-slots' import type { NativeChatTimedRow } from '../../conversation/slots/native-chat-timed-rows' @@ -42,7 +40,8 @@ export type NativeChatTranscriptRowContext = { structuredAttachments?: boolean runtimeContext?: RuntimeFileOperationArgs | null onToggleExpandedTurn: (turnKey: string) => void - subagentDisclosure: NativeChatSubagentDisclosure + /** Opens a subagent's own view; absent where the transcript has none to open. */ + onOpenSubagent?: (agentId: string) => void onScrollMessageToTop: (element: HTMLElement) => void onRevealDiff: (target: NativeChatDiffTarget) => void } @@ -64,30 +63,12 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ // Offered only on the conversation's own turn-opening prompts (`nativeChatRowOffersRewind`). const rewind = useContext(NativeChatRewindContext) const fork = useContext(NativeChatForkContext) - const sectionClassName = cn( - slot.depth > 0 && 'border-l-2 border-border/60 pl-3', - slot.depth > 1 && 'ml-4' - ) if (slot.kind === 'subagent') { return ( -
- -
- ) - } - if (slot.kind === 'subagent-entries') { - return ( - ) } @@ -116,8 +97,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
{newRowsMarker?.place === 'above-row' ? marker : null} @@ -152,13 +132,15 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ actionsHang={slot.actionsHang === true && !newRowsMarkerBelow} onScrollMessageToTop={context.onScrollMessageToTop} deliveryNotice={deliveryNotice} - subagentRoster={slot.subagentRoster} - subagentDisclosure={context.subagentDisclosure} - inSubagentSection={slot.depth > 0} + onOpenSubagent={context.onOpenSubagent} runtimeContext={context.runtimeContext} structuredAttachments={context.structuredAttachments} rewind={ - nativeChatRowOffersRewind(message, slot, deliveryNotice !== undefined) && + nativeChatRowOffersRewind( + message, + { depth: 0, turnKey }, + deliveryNotice !== undefined + ) && (!rewind?.eligibleItemIds || rewind.eligibleItemIds.has(message.id)) ? rewind : undefined diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentHead.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentHead.tsx new file mode 100644 index 00000000000..8e0222dab44 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentHead.tsx @@ -0,0 +1,48 @@ +import { Bot, ChevronRight } from 'lucide-react' +import { useTranslate } from '@/i18n/use-translate' +import { normalizeSubagentState } from '../../../../../shared/native-chat/transcript/native-chat-subagent-summary' +import type { NativeChatSubagentEntry } from '../../../../../shared/native-chat/native-chat-types' +import { NativeChatSubagentStatusIcon } from './NativeChatSubagentStatusIcon' + +/** Head for a subagent no loaded roster row names; unnamed agents have no invented label or state. */ +export function NativeChatSubagentHead({ + agentId, + entry, + onOpen +}: { + agentId: string + entry: NativeChatSubagentEntry | undefined + onOpen?: (agentId: string) => void +}): React.JSX.Element { + const t = useTranslate() + const state = entry === undefined ? null : normalizeSubagentState(entry.state) + const content = ( + <> + + {state === null ? null : } + + {entry?.label ?? t('components.native-chat.subagents.unnamed', 'Subagent')} + + + ) + if (!onOpen) { + return ( +
+ {content} +
+ ) + } + return ( + + ) +} diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentPrompt.fixture.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentPrompt.fixture.tsx new file mode 100644 index 00000000000..c42fa017a77 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentPrompt.fixture.tsx @@ -0,0 +1,33 @@ +import { defineFixture } from '../../../component-fixtures/component-fixture' +import { nativeChatAppearanceStyle } from '../appearance/native-chat-appearance-style' +import { NativeChatSubagentPrompt } from './NativeChatSubagentPrompt' + +const short = 'List the files in `src/` and read `src/math.ts`. Report what it exports.' + +const long = [ + 'Audit the auth flow for token refresh bugs.', + '', + '1. Read `src/auth/session.ts` and `src/auth/refresh.ts`.', + '2. Find every place a request retries after a **401**.', + '3. Check whether two refreshes can run at once.', + '4. Note any timer that refreshes before expiry.', + '5. Look for tests that cover a refresh race.', + '6. Do not change any files.', + '7. Reply with a short list of findings, most serious first.', + '8. Include file paths and line numbers for each finding.' +].join('\n') + +function Prompt({ prompt }: { prompt: string }) { + return ( +
+ +
+ ) +} + +export const brief = defineFixture({ width: 640, render: () => }) + +export const folded = defineFixture({ width: 640, render: () => }) diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentPrompt.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentPrompt.tsx new file mode 100644 index 00000000000..3c47d9ed1f3 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentPrompt.tsx @@ -0,0 +1,35 @@ +import { useTranslate } from '@/i18n/use-translate' +import { NativeChatMarkdown } from '../markdown/NativeChatMarkdown' +import { NativeChatCodeBlock } from '../markdown/code/NativeChatCodeBlock' +import { NativeChatUserMessageFold } from '../messages/rows/NativeChatUserMessageFold' + +/** Opens a subagent's view with the task the main agent gave it, folded like a long prompt. */ +export function NativeChatSubagentPrompt({ + agentId, + prompt +}: { + agentId: string + prompt: string +}): React.JSX.Element { + const t = useTranslate() + return ( +
+

+ {t('components.native-chat.subagents.promptFromMain', 'Prompt from main agent')} +

+ + + +
+ ) +} diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.fixture.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.fixture.tsx index fd2881cdd75..64ead5115f3 100644 --- a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.fixture.tsx +++ b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.fixture.tsx @@ -46,7 +46,7 @@ const block: NativeChatSubagentGroupBlock = { function Roster() { return (
- {}} /> + {}} />
) } @@ -61,7 +61,7 @@ export const hovered = defineFixture({ render: () => , interact: async (frame) => { frame - .querySelector('[data-native-chat-subagent-entry="a1"] > div') + .querySelector('[data-native-chat-subagent-entry="a1"] > button') ?.dispatchEvent(new PointerEvent('pointerover', { bubbles: true, pointerType: 'mouse' })) await new Promise((resolve) => setTimeout(resolve, 500)) } diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.tsx index aba13b19bc6..ada9f47a1d9 100644 --- a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.tsx +++ b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react' +import { useId, useRef, useState } from 'react' import { Bot, ChevronRight } from 'lucide-react' import { cn } from '@/lib/class-names' +import { useTranslate } from '@/i18n/use-translate' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { useNow } from '@/hooks/use-now' import { @@ -21,19 +22,14 @@ import type { NativeChatSubagentState } from '../../../../../shared/native-chat/native-chat-types' import { formatNativeChatDuration } from '../../../../../shared/native-chat/transcript/native-chat-turn-status' -import { nativeChatSubagentEntryRuns } from './native-chat-subagent-sections' -import { sayNativeChatSubagentGroupTranslated as say } from './native-chat-subagent-group-words-text' +import { useNativeChatDisclosure } from '../messages/disclosure/native-chat-disclosure-store' +import { + sayNativeChatSubagentGroupTranslated as say, + sayNativeChatSubagentStateHeading +} from './native-chat-subagent-group-words-text' +import { NativeChatSubagentStatusIcon } from './NativeChatSubagentStatusIcon' import { sayNativeChatSessionOptionTranslated as sayOption } from '../session/options/controls/native-chat-session-option-labels-text' -const STATE_DOT_CLASS: Record = { - working: 'bg-foreground/70', - idle: 'bg-muted-foreground/40', - completed: 'bg-muted-foreground/60', - failed: 'bg-destructive', - stopped: 'bg-muted-foreground', - unverifiable: 'bg-muted-foreground' -} - /** Keep the identity glyph fixed as state changes; convey status through the dot and text. */ function SubagentGlyph(): React.JSX.Element { return ( @@ -43,28 +39,8 @@ function SubagentGlyph(): React.JSX.Element { ) } -/** Keep failure color independent of pulsing so a failed sibling cannot hide ongoing work. */ -export function StatusDot({ - state, - pulsing = false -}: { - state: NativeChatSubagentState - pulsing?: boolean -}): React.JSX.Element { - return ( -
) diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentSectionHead.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentSectionHead.tsx deleted file mode 100644 index 4b43dcaae04..00000000000 --- a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentSectionHead.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Bot, ChevronRight } from 'lucide-react' -import { cn } from '@/lib/class-names' -import { useTranslate } from '@/i18n/use-translate' -import { normalizeSubagentState } from '../../../../../shared/native-chat/transcript/native-chat-subagent-summary' -import type { NativeChatSubagentEntry } from '../../../../../shared/native-chat/native-chat-types' -import { StatusDot } from './NativeChatSubagentRun' - -/** Head a section whose roster entry is unloaded or nested; unnamed agents have no invented label or state. */ -export function NativeChatSubagentSectionHead({ - agentId, - entry, - expanded, - onSetOpen -}: { - agentId: string - entry: NativeChatSubagentEntry | undefined - expanded: boolean - onSetOpen: (agentId: string, open: boolean) => void -}): React.JSX.Element { - const t = useTranslate() - const state = entry === undefined ? null : normalizeSubagentState(entry.state) - return ( - - ) -} diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentStatusBar.fixture.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentStatusBar.fixture.tsx new file mode 100644 index 00000000000..0a5f4fe0d60 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentStatusBar.fixture.tsx @@ -0,0 +1,59 @@ +import { defineFixture } from '../../../component-fixtures/component-fixture' +import type { NativeChatSubagentEntry } from '../../../../../shared/native-chat/native-chat-types' +import { nativeChatAppearanceStyle } from '../appearance/native-chat-appearance-style' +import { NativeChatSubagentStatusBar } from './NativeChatSubagentStatusBar' + +const now = Date.now() + +const completed: NativeChatSubagentEntry = { + id: 'a1', + label: 'Audit auth flow', + state: 'completed', + tokens: 17_800, + startedAt: now - 7_000, + settledAt: now +} +const failed: NativeChatSubagentEntry = { ...completed, state: 'failed' } +const working: NativeChatSubagentEntry = { + ...completed, + state: 'working', + settledAt: undefined, + startedAt: now - 42_000 +} + +function Bar(props: React.ComponentProps) { + return ( +
+ +
+ ) +} + +export const completedWide = defineFixture({ + width: 640, + render: () => {}} /> +}) + +export const failedNarrow = defineFixture({ + width: 340, + render: () => {}} /> +}) + +export const stopping = defineFixture({ + width: 640, + render: () => ( + {} }} + backTo="Audit auth flow" + onBack={() => {}} + /> + ) +}) + +export const stoppingNarrow = defineFixture({ + width: 340, + render: () => ( + {} }} onBack={() => {}} /> + ) +}) diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentStatusBar.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentStatusBar.tsx new file mode 100644 index 00000000000..8aa2039909e --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentStatusBar.tsx @@ -0,0 +1,161 @@ +import { useMemo } from 'react' +import { ChevronLeft } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useTranslate } from '@/i18n/use-translate' +import { formatSubagentTokens } from '../../../../../shared/native-chat/transcript/native-chat-subagent-group-header' +import { normalizeSubagentState } from '../../../../../shared/native-chat/transcript/native-chat-subagent-summary' +import type { NativeChatSubagentEntry } from '../../../../../shared/native-chat/native-chat-types' +import type { AgentChildRowModel } from '../../../../../shared/agent-status/presentation/agent-child-row-model' +import { backgroundTaskRowStopId } from '../../../../../shared/background-task-roster' +import type { StructuredSessionBackgroundTasksView } from '../../../../../shared/structured-session-background-tasks-view' +import { structuredSessionBackgroundTaskGroups } from '../tools/tasks/background-task-roster' +import { sayBackgroundTaskTranslated as sayTask } from '../tools/tasks/background-task-words-text' +import { useStructuredSessionChildRowContext } from '../tools/tasks/use-structured-session-child-row-context' +import type { StructuredSessionTaskStop } from '../tools/tasks/use-structured-session-task-stop' +import { SubagentElapsed } from './NativeChatSubagentRun' +import { NativeChatSubagentStatusIcon } from './NativeChatSubagentStatusIcon' +import { + sayNativeChatSubagentGroupTranslated as say, + sayNativeChatSubagentStateHeading +} from './native-chat-subagent-group-words-text' + +/** Stands in for the composer while a subagent's view is open: its state, its Stop, and the way back. */ +export function NativeChatSubagentStatusBar({ + entry, + stop, + backTo, + onBack +}: { + entry: NativeChatSubagentEntry | undefined + /** Present while the agents strip could stop this subagent. */ + stop?: { stopping: boolean; onStop: () => void } + /** The subagent that spawned this one; absent when the main agent did. */ + backTo?: string + onBack: () => void +}): React.JSX.Element { + const t = useTranslate() + const state = entry ? normalizeSubagentState(entry.state) : null + const back = + backTo === undefined + ? t('components.native-chat.subagents.backToMain', 'Back to main agent') + : t('components.native-chat.subagents.backTo', 'Back to {{value0}}', { value0: backTo }) + return ( +
+
+
+ {state ? ( + + + + {sayNativeChatSubagentStateHeading(state)} + + {entry?.startedAt !== undefined ? ( + // Ticking digits stay out of live announcements. + + {' · '} + + + ) : null} + {typeof entry?.tokens === 'number' ? ( + + {` · ${say('tokens', { value0: formatSubagentTokens(entry.tokens) })}`} + + ) : null} + + ) : null} + {/* Says once the subagent settles; never the ticking clock. */} + + {state !== null && state !== 'working' && state !== 'idle' + ? sayNativeChatSubagentStateHeading(state) + : ''} + + + {stop ? ( + + ) : null} + + +
+
+
+ ) +} + +function rowFor( + rows: readonly AgentChildRowModel[], + providerId: string +): AgentChildRowModel | undefined { + for (const row of rows) { + const found = row.providerId === providerId ? row : rowFor(row.owned, providerId) + if (found) { + return found + } + } + return undefined +} + +/** A structured chat's bar: the roster's entry for the agent, with the agents strip's own Stop. */ +export function NativeChatStructuredSubagentStatusBar({ + agentId, + entry, + paneKey, + backgroundTasks, + stop, + backTo, + onBack +}: { + agentId: string + entry: NativeChatSubagentEntry | undefined + paneKey: string + backgroundTasks: StructuredSessionBackgroundTasksView + stop: StructuredSessionTaskStop + backTo?: string + onBack: () => void +}): React.JSX.Element { + const childRowContext = useStructuredSessionChildRowContext(paneKey) + const { tasks, settledTasks, children, supportsStop } = backgroundTasks + const stopId = useMemo(() => { + const rows = structuredSessionBackgroundTaskGroups( + tasks, + settledTasks, + children, + childRowContext + ).flatMap((group) => group.tasks.map(({ row }) => row)) + const row = rowFor(rows, agentId) + return row ? backgroundTaskRowStopId(row, supportsStop) : null + }, [agentId, childRowContext, children, settledTasks, supportsStop, tasks]) + const { onStop, stoppingTaskIds } = stop + return ( + onStop(stopId) } + } + backTo={backTo} + onBack={onBack} + /> + ) +} diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentStatusIcon.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentStatusIcon.tsx new file mode 100644 index 00000000000..fee1424f291 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentStatusIcon.tsx @@ -0,0 +1,21 @@ +import { AgentStateDot, type AgentDotState } from '@/components/AgentStateDot' +import type { NativeChatSubagentState } from '../../../../../shared/native-chat/native-chat-types' + +const DOT_STATE: Record = { + working: 'working', + idle: 'idle', + completed: 'done', + failed: 'failed', + stopped: 'interrupted', + unverifiable: 'unverifiable' +} + +/** The agents strip's status glyph, so a subagent reads the same on every surface. */ +export function NativeChatSubagentStatusIcon({ + state +}: { + state: NativeChatSubagentState +}): React.JSX.Element { + // The words beside it say the state; the glyph adds no tooltip of its own. + return +} diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentTranscripts.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentTranscripts.tsx new file mode 100644 index 00000000000..9166ba0063c --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentTranscripts.tsx @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { cn } from '@/lib/class-names' +import { NativeChatMessageList } from '../NativeChatMessageList' +import { NativeChatSideChatForkPointContext } from '../messages/actions/native-chat-fork-context' +import { NativeChatToolRunWindowProvider } from '../tools/runs/native-chat-tool-run-window-context' +import type { NativeChatFind } from '../viewport/find/use-native-chat-find' +import { + noteNativeChatSubagentParent, + openNativeChatSubagent, + useNativeChatOpenSubagent, + type NativeChatSubagentView +} from './native-chat-open-subagent' + +/** What took the reader into a subagent, to hand focus back to: its roster entry, its strip row, else the composer. */ +function returnFocusTarget(main: HTMLElement, agentId: string): HTMLElement | null { + const id = CSS.escape(agentId) + const root = main.closest('[data-native-chat-root]') + return ( + main.querySelector(`[data-native-chat-subagent-entry="${id}"] button`) ?? + root?.querySelector(`[data-native-chat-agent-row="${id}"]`) ?? + root?.querySelector('[data-native-chat-prompt-editor]') ?? + null + ) +} + +/** The conversation stays mounted, hidden, under an open subagent's view, so going back keeps the reader's place. */ +export function NativeChatSubagentTranscripts({ + paneKey, + find, + ...props +}: React.ComponentProps & { + /** The chat's status pane key, which also names it to the sidebar's subagent rows. */ + paneKey: string + find: Pick +}): React.JSX.Element { + const openId = useNativeChatOpenSubagent(paneKey)?.agentId ?? null + const open = useCallback( + (agentId: string | null) => openNativeChatSubagent(paneKey, agentId), + [paneKey] + ) + const main = useMemo(() => ({ agentId: null, open }), [open]) + const shown = useMemo( + () => + openId === null + ? null + : { + agentId: openId, + open, + noteParent: (parentId) => noteNativeChatSubagentParent(paneKey, openId, parentId) + }, + [open, openId, paneKey] + ) + const isVisible = props.isVisible ?? true + const mainRef = useRef(null) + const viewRef = useRef(null) + const shownId = shown?.agentId ?? null + const previousIdRef = useRef(null) + useEffect(() => { + const previousId = previousIdRef.current + previousIdRef.current = shownId + // Only focus a switch dropped (the view or control that had it unmounted); never take it from elsewhere. + const active = document.activeElement + const dropped = + active === null || active === document.body || mainRef.current?.contains(active) === true + if (!dropped || previousId === shownId) { + return + } + if (shownId !== null) { + viewRef.current?.focus({ preventScroll: true }) + return + } + // The composer the view replaced mounts in this commit; its editor attaches a frame later. + const frame = requestAnimationFrame(() => { + const main = mainRef.current + if (main && previousId !== null && document.activeElement === document.body) { + returnFocusTarget(main, previousId)?.focus({ preventScroll: true }) + } + }) + return () => cancelAnimationFrame(frame) + }, [shownId]) + return ( + +
+
+ +
+ {shown ? ( +
+ {/* `/btw` forks from the conversation, never from a subagent's rows. */} + + + +
+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentViewBar.fixture.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentViewBar.fixture.tsx new file mode 100644 index 00000000000..249c07dccbb --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentViewBar.fixture.tsx @@ -0,0 +1,76 @@ +import { defineFixture } from '../../../component-fixtures/component-fixture' +import type { NativeChatSubagentEntry } from '../../../../../shared/native-chat/native-chat-types' +import type { NativeChatSubagentRow } from '../../../../../shared/native-chat/transcript/native-chat-transcript-projection' +import { nativeChatAppearanceStyle } from '../appearance/native-chat-appearance-style' +import { NativeChatSubagentViewBar } from './NativeChatSubagentViewBar' + +const now = Date.now() + +const agents: NativeChatSubagentEntry[] = [ + { + id: 'a1', + label: 'Audit auth flow', + state: 'working', + tokens: 31_200, + startedAt: now - 42_000, + agentType: 'general-purpose', + model: 'claude-opus-5-5', + effort: 'high' + }, + { id: 'a2', label: 'List docs/ and read notes.md', state: 'completed', model: 'gpt-5.5' }, + { id: 'a3', label: 'Map token refresh', state: 'failed', tokens: 8_100 }, + { id: 'a4', label: 'Check refresh race', state: 'working', agentType: 'Explore' } +] +const entries = new Map(agents.map((agent) => [agent.id, agent])) + +function rows(id: string, parentAgentId?: string): [string, NativeChatSubagentRow[]] { + return [ + id, + [ + { + message: { + id: `${id}-row`, + role: 'assistant', + blocks: [], + timestamp: now, + source: 'transcript', + ...(parentAgentId ? { parentAgentId } : {}) + }, + turnKey: undefined + } + ] + ] +} +// a4 was spawned by a1; two unnamed agents have rows and no roster entry. +const subagentRows = new Map([rows('a1'), rows('a4', 'a1'), rows('u1'), rows('u2')]) + +function Bar() { + return ( +
+ {}} + /> +
+ ) +} + +export const nested = defineFixture({ width: 640, render: () => }) + +export const switcher = defineFixture({ + width: 640, + render: () => , + interact: async (frame) => { + frame + .querySelector('[aria-haspopup="menu"]') + ?.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerType: 'mouse' }) + ) + await new Promise((resolve) => setTimeout(resolve, 300)) + } +}) diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentViewBar.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentViewBar.tsx new file mode 100644 index 00000000000..e267f5e6da3 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentViewBar.tsx @@ -0,0 +1,144 @@ +import { Fragment, useMemo } from 'react' +import { ChevronDown, ChevronLeft } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { useTranslate } from '@/i18n/use-translate' +import { normalizeSubagentState } from '../../../../../shared/native-chat/transcript/native-chat-subagent-summary' +import { + subagentEntryRunsOn, + subagentRoleLabel +} from '../../../../../shared/native-chat/transcript/native-chat-subagent-entry-detail' +import type { NativeChatSubagentEntry } from '../../../../../shared/native-chat/native-chat-types' +import type { NativeChatSubagentRow } from '../../../../../shared/native-chat/transcript/native-chat-transcript-projection' +import { NativeChatSubagentStatusIcon } from './NativeChatSubagentStatusIcon' +import { sayNativeChatSubagentStateHeading } from './native-chat-subagent-group-words-text' +import { sayNativeChatSessionOptionTranslated as sayOption } from '../session/options/controls/native-chat-session-option-labels-text' +import { nativeChatSubagentSwitcherItems } from './native-chat-subagent-switcher-items' + +const CHIP_CLASS = + 'min-w-0 shrink truncate rounded-full border border-border px-1.5 font-sans text-3xs text-chat-foreground-faint' +const MAIN = '' + +/** Above a subagent's own transcript: its spawn chain back to the main agent, and a switcher over every subagent. */ +export function NativeChatSubagentViewBar({ + chain, + entries, + subagentRows, + onOpen +}: { + /** The spawn chain down to the open subagent, outermost first. */ + chain: readonly string[] + /** Every subagent the session names, in roster order. */ + entries: ReadonlyMap + subagentRows: ReadonlyMap + onOpen: (agentId: string | null) => void +}): React.JSX.Element { + const t = useTranslate() + const unnamed = t('components.native-chat.subagents.unnamed', 'Subagent') + const mainAgent = t('components.native-chat.subagents.mainAgent', 'Main agent') + const items = useMemo( + () => nativeChatSubagentSwitcherItems(entries, subagentRows, unnamed), + [entries, subagentRows, unnamed] + ) + const labelOf = (id: string): string => items.find((item) => item.id === id)?.label ?? unnamed + const agentId = chain.at(-1)! + const entry = entries.get(agentId) + const state = entry ? normalizeSubagentState(entry.state) : null + const role = subagentRoleLabel(entry?.agentType) + const runsOn = entry ? subagentEntryRunsOn(entry, sayOption) : null + return ( +
+ +
+ ) +} diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-open-subagent.ts b/src/renderer/src/components/native-chat/subagents/native-chat-open-subagent.ts new file mode 100644 index 00000000000..2b4e664150d --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/native-chat-open-subagent.ts @@ -0,0 +1,83 @@ +// Which subagent each chat pane shows in place of its conversation; session-only, never persisted. + +import { useCallback, useSyncExternalStore } from 'react' +import { isEditableTarget } from '@/lib/text-input/focus/editable-target' + +/** The subagent a transcript shows (null: the conversation) and how its pane switches. */ +export type NativeChatSubagentView = { + agentId: string | null + open: (agentId: string | null) => void + /** The open view names the subagent that spawned it, which Back and Esc return to. */ + noteParent?: (parentId: string | null) => void +} + +/** The open subagent and, once its view has read its spawn chain, its parent (null: the main agent). */ +export type NativeChatOpenSubagent = { agentId: string; parentId: string | null } + +const openByPane = new Map() +const listeners = new Set<() => void>() + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +function publish(): void { + for (const listener of listeners) { + listener() + } +} + +/** `paneKey` is the chat's status pane key; null returns the pane to its main agent. */ +export function openNativeChatSubagent(paneKey: string, agentId: string | null): void { + if ((openByPane.get(paneKey)?.agentId ?? null) === agentId) { + return + } + if (agentId === null) { + openByPane.delete(paneKey) + } else { + openByPane.set(paneKey, { agentId, parentId: null }) + } + publish() +} + +export function noteNativeChatSubagentParent( + paneKey: string, + agentId: string, + parentId: string | null +): void { + const open = openByPane.get(paneKey) + if (open?.agentId === agentId && open.parentId !== parentId) { + openByPane.set(paneKey, { agentId, parentId }) + publish() + } +} + +/** Back from a subagent: to the subagent that spawned it, else to the main agent. */ +export function backFromNativeChatSubagent(paneKey: string): void { + openNativeChatSubagent(paneKey, openByPane.get(paneKey)?.parentId ?? null) +} + +export function useNativeChatOpenSubagent(paneKey: string): NativeChatOpenSubagent | null { + const read = useCallback(() => openByPane.get(paneKey) ?? null, [paneKey]) + return useSyncExternalStore(subscribe, read, read) +} + +/** Esc outside inputs and menus goes back a level. */ +export function closeNativeChatSubagentOnEscape(event: React.KeyboardEvent, paneKey: string): void { + const target = event.target + if ( + event.key !== 'Escape' || + event.defaultPrevented || + !openByPane.has(paneKey) || + !(target instanceof Element) || + // Portaled menus bubble through React but sit outside this pane's DOM. + !event.currentTarget.contains(target) || + isEditableTarget(target) || + target.closest('[role="menu"], [role="dialog"], [role="listbox"]') !== null + ) { + return + } + event.preventDefault() + backFromNativeChatSubagent(paneKey) +} diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-delegation.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-delegation.ts deleted file mode 100644 index f9c0f0cc921..00000000000 --- a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-delegation.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Codex delegates by the first thread id, including legacy raw frames; Claude spawn call ids change on resume, so join via the announcing roster. - -import { - codexCollabRowAgentIds, - isCodexCollabToolRowName -} from '../../../../../shared/codex-collab-agent-tools' -import { - isSubagentGroupBlock, - type NativeChatMessage -} from '../../../../../shared/native-chat/native-chat-types' - -export type NativeChatSubagentDelegation = - | { kind: 'roster'; agentIds: readonly string[] } - | { kind: 'call'; agentId: string } - | { kind: 'spawn' } - -/** Claude's subagent spawn tool, under its current and its older name. */ -const CLAUDE_SPAWN_TOOLS: ReadonlySet = new Set(['Agent', 'Task']) - -const CODEX_COLLAB_CALL_FRAME = 'item:collabAgentToolCall' - -const delegations = new WeakMap() - -/** An older journal's raw collab-call row: not drawn, but still the agent acting. */ -export function isNativeChatCodexCollabCallFrameRow(message: NativeChatMessage): boolean { - const [only] = message.blocks - return ( - message.blocks.length === 1 && - only?.type === 'text' && - only.providerFrame?.provider === 'codex' && - only.providerFrame.kind === CODEX_COLLAB_CALL_FRAME - ) -} - -export function nativeChatSubagentDelegation( - message: NativeChatMessage -): NativeChatSubagentDelegation | null { - const cached = delegations.get(message) - if (cached !== undefined) { - return cached - } - const delegation = derive(message) - delegations.set(message, delegation) - return delegation -} - -function derive(message: NativeChatMessage): NativeChatSubagentDelegation | null { - const rostered = message.blocks.flatMap((block) => - isSubagentGroupBlock(block) ? block.agents.map((agent) => agent.id) : [] - ) - if (rostered.length > 0) { - return { kind: 'roster', agentIds: rostered } - } - const [only] = message.blocks - const frame = - message.blocks.length === 1 && only?.type === 'text' ? only.providerFrame : undefined - if (frame?.provider === 'codex' && frame.kind === CODEX_COLLAB_CALL_FRAME) { - const [agentId] = frame.payload.truncated ? [] : receiverThreadIds(frame.payload.head) - return agentId === undefined ? null : { kind: 'call', agentId } - } - // A tool run folds into the message before it, so its last call is the newest thing in it. - const newest = message.blocks.findLast((block) => block.type !== 'tool-result') - if (newest?.type !== 'tool-call') { - return null - } - if (isCodexCollabToolRowName(newest.name)) { - const [agentId] = codexCollabRowAgentIds(newest.input) - return agentId === undefined ? null : { kind: 'call', agentId } - } - return CLAUDE_SPAWN_TOOLS.has(newest.name) ? { kind: 'spawn' } : null -} - -function receiverThreadIds(head: string): readonly string[] { - let item: unknown - try { - item = JSON.parse(head) - } catch { - return [] - } - if (typeof item !== 'object' || item === null || !('receiverThreadIds' in item)) { - return [] - } - const ids = item.receiverThreadIds - return Array.isArray(ids) - ? ids.filter((id): id is string => typeof id === 'string' && id.length > 0) - : [] -} diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-ending.test.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-ending.test.ts new file mode 100644 index 00000000000..8058d16cf98 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-ending.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import type { + NativeChatMessage, + NativeChatSubagentEntry +} from '../../../../../shared/native-chat/native-chat-types' +import { nativeChatSubagentEnding } from './native-chat-subagent-ending' + +const said: NativeChatMessage = { + id: 'r1', + role: 'assistant', + blocks: [{ type: 'text', text: 'Found two files.' }], + timestamp: 1, + source: 'transcript' +} + +function entry(state: NativeChatSubagentEntry['state']): NativeChatSubagentEntry { + return { id: 'a', label: 'List src', state, lastMessage: 'Permission denied for ls' } +} + +describe('nativeChatSubagentEnding', () => { + it('ends a completed subagent with its reply', () => { + expect( + nativeChatSubagentEnding({ + entry: entry('completed'), + handoff: { prompt: null, reply: 'src holds math.ts', error: null }, + rows: [], + history: 'landed' + }) + ).toEqual({ kind: 'reply', text: 'src holds math.ts' }) + }) + + it('never ends a failed subagent with a reply, only its error', () => { + expect( + nativeChatSubagentEnding({ + entry: entry('failed'), + handoff: { prompt: null, reply: null, error: null }, + rows: [said], + history: 'landed' + }) + ).toEqual({ kind: 'error', text: 'Permission denied for ls' }) + }) + + it('says a stopped subagent stopped before replying, whatever it said last', () => { + expect( + nativeChatSubagentEnding({ + entry: entry('stopped'), + handoff: null, + rows: [said], + history: 'landed' + }) + ).toEqual({ kind: 'aside', aside: 'stopped' }) + }) + + it('adds no reply when its own last message already says it', () => { + expect( + nativeChatSubagentEnding({ + entry: { ...entry('completed'), lastMessage: 'Found two files.' }, + handoff: null, + rows: [said], + history: 'landed' + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-ending.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-ending.ts new file mode 100644 index 00000000000..9bbed127075 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-ending.ts @@ -0,0 +1,97 @@ +// How a subagent's view ends: only a completed subagent gets a reply; anything else says why it has none. + +import type { + NativeChatMessage, + NativeChatSubagentEntry +} from '../../../../../shared/native-chat/native-chat-types' +import { normalizeSubagentState } from '../../../../../shared/native-chat/transcript/native-chat-subagent-summary' +import type { NativeChatSubagentHandoff } from './native-chat-subagent-handoff' +import type { NativeChatSubagentHistory } from './native-chat-subagent-history' + +/** A quiet line in the transcript's notice style. */ +export type NativeChatSubagentAside = 'no-steps' | 'cleared' | 'stopped' | 'no-reply' + +export type NativeChatSubagentEnding = + | { kind: 'reply'; text: string } + | { kind: 'error'; text: string } + | { kind: 'aside'; aside: NativeChatSubagentAside } + | null + +function words(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +/** Codex's last own message is its reply; the roster's clipped copy of it counts as the same, a shorter own message does not. */ +function rowsEndWith(rows: readonly NativeChatMessage[], reply: string): boolean { + const last = rows.findLast((row) => row.role === 'assistant') + const said = words( + last?.blocks.map((block) => (block.type === 'text' ? block.text : '')).join('\n') ?? '' + ) + const replied = words(reply) + return said.length > 0 && said.startsWith(replied) +} + +export function nativeChatSubagentEnding({ + entry, + handoff, + rows, + history +}: { + entry: NativeChatSubagentEntry | undefined + handoff: NativeChatSubagentHandoff | null + /** The subagent's own rows. */ + rows: readonly NativeChatMessage[] + history: NativeChatSubagentHistory +}): NativeChatSubagentEnding { + if (rows.length === 0 && history === 'cleared') { + return { kind: 'aside', aside: 'cleared' } + } + const state = entry === undefined ? null : normalizeSubagentState(entry.state) + switch (state) { + case 'completed': { + const reply = handoff?.reply ?? entry?.lastMessage?.trim() + if (reply && rowsEndWith(rows, reply)) { + return null + } + return reply ? { kind: 'reply', text: reply } : { kind: 'aside', aside: 'no-reply' } + } + case 'failed': { + const error = handoff?.error ?? entry?.lastMessage?.trim() + return error ? { kind: 'error', text: error } : { kind: 'aside', aside: 'no-reply' } + } + case 'stopped': + return { kind: 'aside', aside: 'stopped' } + case 'unverifiable': + return { kind: 'aside', aside: 'no-reply' } + case 'working': + case 'idle': + case null: + return rows.length === 0 ? { kind: 'aside', aside: 'no-steps' } : null + } +} + +/** The ending as the view's last row: the reply as the agent's message, the rest as notices. */ +export function nativeChatSubagentEndingMessage( + agentId: string, + ending: Exclude, + asideText: string, + settledAt: number | undefined +): NativeChatMessage { + const base = { + id: `subagent-ending:${agentId}`, + timestamp: settledAt ?? null, + source: 'transcript' + } as const + switch (ending.kind) { + case 'reply': + return { ...base, role: 'assistant', blocks: [{ type: 'text', text: ending.text }] } + case 'error': + return { + ...base, + role: 'system', + blocks: [{ type: 'text', text: ending.text, tone: 'error' }] + } + case 'aside': + return { ...base, role: 'system', blocks: [{ type: 'text', text: asideText }] } + } +} diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-group-words-text.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-group-words-text.ts index cecd9f62a87..476e96195c6 100644 --- a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-group-words-text.ts +++ b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-group-words-text.ts @@ -7,6 +7,7 @@ import { type NativeChatSubagentGroupSay } from '../../../../../shared/native-chat/transcript/native-chat-subagent-group-header' import type { CopyValues } from '../../../../../shared/counted-copy-english' +import type { NativeChatSubagentState } from '../../../../../shared/native-chat/native-chat-types' const PIECES: Record string> = { stateCompleted: () => @@ -66,3 +67,24 @@ const PIECES: Record str export const sayNativeChatSubagentGroupTranslated: NativeChatSubagentGroupSay = (id, values) => PIECES[id](values) + +/** A single subagent's state where it leads a line: "Completed · 7s". */ +export function sayNativeChatSubagentStateHeading(state: NativeChatSubagentState): string { + switch (state) { + case 'completed': + return translate('components.native-chat.subagents.stateHeading.completed', 'Completed') + case 'working': + return translate('components.native-chat.subagents.stateHeading.working', 'Working') + case 'idle': + return translate('components.native-chat.subagents.stateHeading.idle', 'Idle') + case 'failed': + return translate('components.native-chat.subagents.stateHeading.failed', 'Failed') + case 'stopped': + return translate('components.native-chat.subagents.stateHeading.stopped', 'Stopped') + case 'unverifiable': + return translate( + 'components.native-chat.subagents.stateHeading.unverifiable', + 'Status unavailable' + ) + } +} diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-handoff-copy-budget.test.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-handoff-copy-budget.test.ts deleted file mode 100644 index d2b4d8daad6..00000000000 --- a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-handoff-copy-budget.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../../../../shared/native-chat/native-chat-types' -import { nativeChatSubagentSections } from './native-chat-subagent-sections' -import { nativeChatSubagentLiveSections } from './native-chat-subagent-live-frontier' - -function message( - id: string, - blocks: NativeChatMessage['blocks'], - sequence: number, - extra: Partial = {} -): NativeChatMessage { - return { - id, - role: 'assistant', - blocks, - timestamp: 1, - source: 'transcript', - journalPosition: { sequence, index: 0 }, - ...extra - } -} - -function handoff(index: number, agents = ['child']): NativeChatMessage { - return message( - `handoff-${index}`, - [ - { - type: 'tool-call', - name: 'wait_agent', - input: { - agents, - future: { reason: 'observe', unicode: '🙂' }, - optional: undefined - } - } - ], - index + 10 - ) -} - -function fixture(calls: readonly NativeChatMessage[]) { - const roster = message( - 'root-roster', - [ - { - type: 'subagent-group', - groupId: 'root', - agents: [{ id: 'parent', label: 'parent', state: 'working' }] - } - ], - 1, - { role: 'system' } - ) - const parent = message('parent-row', [{ type: 'text', text: 'delegating' }], 2, { - agentId: 'parent', - producerKind: 'agent' - }) - const child = message('child-row', [{ type: 'text', text: 'reading' }], 3, { - agentId: 'child', - parentAgentId: 'parent', - producerKind: 'agent' - }) - const conversation = [roster, ...calls] - const sections = nativeChatSubagentSections( - conversation, - new Map([ - ['parent', [{ message: parent, turnKey: undefined }]], - ['child', [{ message: child, turnKey: undefined }]] - ]) - ) - return { conversation, sections, roster, parent, child } -} - -describe('nested subagent handoff accumulation', () => { - it.each([{ agents: [] }, { agents: [''] }, { agents: ['unknown'] }])( - 'keeps unknown or empty target behavior for %j', - ({ agents }) => { - const input = fixture([handoff(0), handoff(1, agents)]) - const before = structuredClone(input) - expect([...nativeChatSubagentLiveSections(input.conversation, input.sections, true)]).toEqual( - ['child'] - ) - expect(input).toEqual(before) - } - ) - - it('keeps live ordering for interleaved handoffs to different nested scopes', () => { - const input = fixture([handoff(0), handoff(1, ['other-child']), handoff(2)]) - const group = input.roster.blocks[0] - if (group?.type !== 'subagent-group') { - throw new Error('Missing root roster') - } - group.agents.push({ id: 'other-parent', label: 'other parent', state: 'working' }) - const otherParent = message('other-parent-row', [{ type: 'text', text: 'delegating' }], 4, { - agentId: 'other-parent', - producerKind: 'agent' - }) - const otherChild = message('other-child-row', [{ type: 'text', text: 'reading' }], 5, { - agentId: 'other-child', - parentAgentId: 'other-parent', - producerKind: 'agent' - }) - const sections = nativeChatSubagentSections( - input.conversation, - new Map([ - ['parent', [{ message: input.parent, turnKey: undefined }]], - ['child', [{ message: input.child, turnKey: undefined }]], - ['other-parent', [{ message: otherParent, turnKey: undefined }]], - ['other-child', [{ message: otherChild, turnKey: undefined }]] - ]) - ) - const before = structuredClone({ input, sections }) - expect([...nativeChatSubagentLiveSections(input.conversation, sections, true)]).toEqual([ - 'other-parent', - 'child', - 'other-child' - ]) - expect({ input, sections }).toEqual(before) - }) - - it('derives fresh results on interleaved sessions with identical IDs and no carried handoffs', () => { - const waiting = fixture([handoff(0)]) - const plain = fixture([]) - expect([ - ...nativeChatSubagentLiveSections(waiting.conversation, waiting.sections, true) - ]).toEqual(['parent', 'child']) - expect([...nativeChatSubagentLiveSections(plain.conversation, plain.sections, true)]).toEqual([ - 'parent' - ]) - expect([ - ...nativeChatSubagentLiveSections(waiting.conversation, waiting.sections, true) - ]).toEqual(['parent', 'child']) - expect(nativeChatSubagentLiveSections(waiting.conversation, waiting.sections, false)).toBe( - nativeChatSubagentLiveSections(plain.conversation, plain.sections, false) - ) - }) -}) diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-handoff.test.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-handoff.test.ts new file mode 100644 index 00000000000..359d9d5f663 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-handoff.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../../shared/agent-session/journal/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../../shared/native-chat/native-chat-types' +import { nativeChatSubagentHandoff } from './native-chat-subagent-handoff' + +function row(id: string, blocks: NativeChatMessage['blocks']): NativeChatMessage { + return { id, role: 'assistant', blocks, timestamp: 1, source: 'transcript' } +} + +describe('nativeChatSubagentHandoff', () => { + it("reads a Claude subagent's prompt and reply from the Agent call its run names", () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handoff reads only each item's body blocks. + const journalItems = [ + { + itemId: 'spawn', + revision: 1, + sequence: 2, + observedAt: 1, + body: { + kind: 'message', + role: 'assistant', + blocks: [ + { + type: 'subagent', + id: 'agent-a', + label: 'List src', + state: 'completed', + groupId: 'g', + run: 'toolu_2' + } + ] + } + } + ] as unknown as AgentJournalRenderItem[] + const conversation = [ + row('call', [ + { type: 'tool-call', name: 'Agent', callId: 'toolu_1', input: { prompt: 'Other task' } }, + { type: 'tool-call', name: 'Agent', callId: 'toolu_2', input: { prompt: 'List src/' } }, + { type: 'tool-result', callId: 'toolu_1', output: 'Other reply' }, + { + type: 'tool-result', + callId: 'toolu_2', + output: + ' Bash\n \n[Subagent hand-back] The report follows:\n src holds:\n \n - `math.ts`' + } + ]) + ] + expect( + nativeChatSubagentHandoff({ + agentId: 'agent-a', + journalItems, + conversation, + subagentRows: new Map() + }) + ).toEqual({ prompt: 'List src/', reply: 'src holds:\n\n- `math.ts`', error: null }) + }) + + it("reads a Codex subagent's prompt from the spawn call naming it, leaving its reply to its own rows", () => { + const conversation = [ + row('spawn', [ + { + type: 'tool-call', + name: 'spawn_agent', + callId: 'call_1', + input: { agents: ['thread-child'], prompt: 'Map token refresh' } + }, + { type: 'tool-result', callId: 'call_1', output: 'Spawned' } + ]) + ] + expect( + nativeChatSubagentHandoff({ + agentId: 'thread-child', + journalItems: [], + conversation, + subagentRows: new Map() + }) + ).toEqual({ prompt: 'Map token refresh', reply: null, error: null }) + }) +}) diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-handoff.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-handoff.ts new file mode 100644 index 00000000000..b469b634fe1 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-handoff.ts @@ -0,0 +1,101 @@ +// What the main agent asked a subagent to do and, for Claude, the reply its spawn call returned. + +import type { AgentJournalRenderItem } from '../../../../../shared/agent-session/journal/agent-session-journal-types' +import { codexCollabRowAgentIds } from '../../../../../shared/codex-collab-agent-tools' +import { + isSubagentBlock, + isToolCallBlock, + type NativeChatMessage +} from '../../../../../shared/native-chat/native-chat-types' +import type { NativeChatSubagentRow } from '../../../../../shared/native-chat/transcript/native-chat-transcript-projection' +import { pairNativeChatToolResults } from '../../../../../shared/native-chat/tools/native-chat-tool-pairing' + +export type NativeChatSubagentHandoff = { + prompt: string | null + /** The spawn call's result: Claude's `Agent` call returns the subagent's whole final reply. */ + reply: string | null + /** The spawn call's result when it reports a failure instead. */ + error: string | null +} + +const NO_HANDOFF: NativeChatSubagentHandoff = { prompt: null, reply: null, error: null } + +/** Claude names the spawning `Agent` call as the run of the subagent's newest revision. */ +function spawnRunOf( + journalItems: readonly AgentJournalRenderItem[] | undefined, + agentId: string +): string | undefined { + for (let index = (journalItems?.length ?? 0) - 1; index >= 0; index -= 1) { + const body = journalItems![index]!.body + if (body.kind !== 'message') { + continue + } + const block = body.blocks.find( + (candidate) => isSubagentBlock(candidate) && candidate.id === agentId + ) + if (block && isSubagentBlock(block) && block.run !== undefined) { + return block.run + } + } + return undefined +} + +/** Claude frames the report it hands back: a column-zero `[Subagent hand-back]` line, then every report line indented two spaces. */ +function handBackReport(output: string): string { + const lines = output.split('\n') + const frame = lines.findLastIndex((line) => line.startsWith('[Subagent hand-back]')) + return frame === -1 + ? output.trim() + : lines + .slice(frame + 1) + .map((line) => line.replace(/^ {2}/, '')) + .join('\n') + .trim() +} + +function promptOf(input: unknown): string | null { + const prompt = + typeof input === 'object' && input !== null && 'prompt' in input ? input.prompt : null + return typeof prompt === 'string' && prompt.trim() ? prompt : null +} + +/** The spawn call is Claude's call named by the run, or the Codex `spawn_agent` call naming the child. */ +export function nativeChatSubagentHandoff({ + agentId, + journalItems, + conversation, + subagentRows +}: { + agentId: string + journalItems: readonly AgentJournalRenderItem[] | undefined + conversation: readonly NativeChatMessage[] + subagentRows: ReadonlyMap +}): NativeChatSubagentHandoff { + const run = spawnRunOf(journalItems, agentId) + const spawners = [ + conversation, + ...Array.from(subagentRows.values(), (rows) => rows.map(({ message }) => message)) + ] + for (const messages of spawners) { + for (const message of messages) { + for (const block of message.blocks) { + if (!isToolCallBlock(block)) { + continue + } + if (run !== undefined && block.callId === run) { + const result = pairNativeChatToolResults(message.blocks).resultByCall.get(block) + const text = (result && handBackReport(result.output)) || null + return { + prompt: promptOf(block.input), + reply: result?.isError ? null : text, + error: result?.isError ? text : null + } + } + if (block.name === 'spawn_agent' && codexCollabRowAgentIds(block.input).includes(agentId)) { + return { prompt: promptOf(block.input), reply: null, error: null } + } + } + } + } + return NO_HANDOFF +} diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-history.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-history.ts new file mode 100644 index 00000000000..f1c68bdb40a --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-history.ts @@ -0,0 +1,49 @@ +import type { StructuredAgentSubagentRoster } from '../../../../../shared/agent-session/view/structured-agent-session-subagent-roster' +import { + isSubagentGroupBlock, + type NativeChatMessage +} from '../../../../../shared/native-chat/native-chat-types' +import type { NativeChatSubagentRow } from '../../../../../shared/native-chat/transcript/native-chat-transcript-projection' + +export type NativeChatSubagentHistory = 'landed' | 'page' | 'cleared' | 'stop' + +function namesAgent(message: NativeChatMessage, agentId: string): boolean { + return message.blocks.some( + (block) => isSubagentGroupBlock(block) && block.agents.some(({ id }) => id === agentId) + ) +} + +/** + * Whether a subagent's view holds all its work: its spawning roster row is loaded ('landed'), older + * history still holds it ('page'), it ran before the latest /clear ('cleared'), or nothing loadable + * names it ('stop'). Scanned per open, not per frame. + */ +export function nativeChatSubagentHistoryStep({ + agentId, + conversation, + subagentRows, + roster, + historyFloor +}: { + agentId: string + conversation: readonly NativeChatMessage[] + subagentRows: ReadonlyMap + roster: StructuredAgentSubagentRoster | undefined + /** The sequence the transcript's history starts from (its latest `/clear`); 0 for none. */ + historyFloor: () => number +}): NativeChatSubagentHistory { + if ( + conversation.some((message) => namesAgent(message, agentId)) || + Array.from(subagentRows.values()).some((rows) => + rows.some(({ message }) => namesAgent(message, agentId)) + ) + ) { + return 'landed' + } + const named = roster?.get(agentId) + if (named === undefined) { + return 'stop' + } + // A spawn before the latest /clear is outside this conversation: no page reaches it. + return named.rosterPosition.sequence < historyFloor() ? 'cleared' : 'page' +} diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-live-frontier.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-live-frontier.ts deleted file mode 100644 index 2fc64b906a9..00000000000 --- a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-live-frontier.ts +++ /dev/null @@ -1,169 +0,0 @@ -// Open the newest delegation’s section while its scope runs; newer output supersedes it, and explicit reader choices win. - -import { compareAgentJournalPositions } from '../../../../../shared/agent-session/journal/agent-session-journal-position' -import type { AgentJournalPosition } from '../../../../../shared/agent-session/journal/agent-session-journal-types' -import { normalizeSubagentState } from '../../../../../shared/native-chat/transcript/native-chat-subagent-summary' -import { nativeChatRowRendersContent } from '../../../../../shared/native-chat/transcript/native-chat-row-content' -import type { NativeChatMessage } from '../../../../../shared/native-chat/native-chat-types' -import { compareNativeChatDisplayMessages as compareMessages } from '../../../../../shared/native-chat/transcript/native-chat-message-order' -import { - isNativeChatCodexCollabCallFrameRow, - nativeChatSubagentDelegation -} from './native-chat-subagent-delegation' -import type { NativeChatSubagentSections } from './native-chat-subagent-sections' - -const NONE: ReadonlySet = new Set() - -/** A row the reader sees the scope's agent produce, or a collab call it made. */ -function isOutput(message: NativeChatMessage): boolean { - return ( - message.role !== 'user' && - (nativeChatRowRendersContent(message.blocks) || isNativeChatCodexCollabCallFrameRow(message)) - ) -} - -/** `scopeActive`: the session is running. */ -export function nativeChatSubagentLiveSections( - conversation: readonly NativeChatMessage[], - sections: NativeChatSubagentSections, - scopeActive: boolean -): ReadonlySet { - if (!scopeActive || sections.rows.size === 0) { - return NONE - } - // Each sectioned agent's scope: null for the session's, else the subagent that spawned it. - const scopeOf = new Map() - for (const agentIds of sections.anchoredAt.values()) { - agentIds.forEach((agentId) => scopeOf.set(agentId, null)) - } - for (const [scope, agentIds] of sections.openAt) { - agentIds.forEach((agentId) => scopeOf.set(agentId, scope)) - } - const handedDown = handDownNestedDelegations(conversation, scopeOf) - const live = new Set() - const visit = (scopeRows: readonly NativeChatMessage[], scope: string | null): void => { - const members = - scope === null - ? [...Array.from(sections.anchoredAt.values()).flat(), ...(sections.openAt.get(null) ?? [])] - : (sections.openAt.get(scope) ?? []) - const inScope = new Set(members) - const belongsHere = (agentId: string): boolean => - !scopeOf.has(agentId) || scopeOf.get(agentId) === scope - const atFrontier = (): readonly string[] => { - for (const row of newestFirst(scopeRows)) { - if (!isOutput(row) || (scope === null && handedDown.rowIds.has(row.id))) { - continue - } - const delegation = nativeChatSubagentDelegation(row) - if (delegation?.kind === 'spawn') { - continue - } - if (delegation === null) { - return [] - } - if (delegation.kind === 'call') { - return inScope.has(delegation.agentId) ? [delegation.agentId] : [] - } - const newest = delegation.agentIds.findLast(belongsHere) - return newest !== undefined && inScope.has(newest) ? [newest] : [] - } - // Nothing loaded since the newest roster naming this scope's agents. - const rostered = members.filter((agentId) => sections.rosters.has(agentId)) - const newest = rostered.reduce( - (best, agentId) => - best === undefined || compareAddedAt(sections, agentId, best) > 0 ? agentId : best, - undefined - ) - return newest === undefined ? [] : [newest] - } - atFrontier().forEach((agentId) => live.add(agentId)) - for (const agentId of members) { - const entry = sections.entries.get(agentId) - if (entry !== undefined && normalizeSubagentState(entry.state) === 'working') { - const own = (sections.rows.get(agentId) ?? []).map((row) => row.message) - const extra = handedDown.byScope.get(agentId) - visit(extra ? [...own, ...extra].sort(compareMessages) : own, agentId) - } - } - } - visit(conversation, null) - return live -} - -/** Use each folded run’s newest part, not its drawn position above later rosters; runs fold only into the latest assistant row. */ -function* newestFirst(rows: readonly NativeChatMessage[]): Generator { - let below: NativeChatMessage[] = [] - for (let index = rows.length - 1; index >= 0; index -= 1) { - const row = rows[index]! - if (row.role !== 'assistant') { - below.push(row) - continue - } - const newest = row.foldedJournalPosition ?? row.journalPosition - let next = 0 - while (next < below.length && (newest === undefined || isNewerThan(below[next]!, newest))) { - yield below[next]! - next += 1 - } - yield row - yield* below.slice(next) - below = [] - } - yield* below -} - -/** A row the journal does not hold yet is newer than any it does. */ -function isNewerThan(row: NativeChatMessage, position: AgentJournalPosition): boolean { - return ( - row.journalPosition === undefined || - compareAgentJournalPositions(row.journalPosition, position) > 0 - ) -} - -/** The session's roster rows and calls whose agents one subagent spawned, by that subagent. */ -function handDownNestedDelegations( - conversation: readonly NativeChatMessage[], - scopeOf: ReadonlyMap -): { rowIds: ReadonlySet; byScope: ReadonlyMap } { - const rowIds = new Set() - const byScope = new Map() - if (!Array.from(scopeOf.values()).some((scope) => scope !== null)) { - return { rowIds, byScope } - } - for (const message of conversation) { - const delegation = nativeChatSubagentDelegation(message) - if (delegation === null || delegation.kind === 'spawn') { - continue - } - const agentIds = delegation.kind === 'call' ? [delegation.agentId] : delegation.agentIds - const spawners = new Set( - agentIds.flatMap((agentId) => (scopeOf.has(agentId) ? [scopeOf.get(agentId) ?? null] : [])) - ) - const [spawner] = spawners - if (spawners.size === 1 && spawner !== undefined && spawner !== null) { - rowIds.add(message.id) - const rows = byScope.get(spawner) - if (rows) { - rows.push(message) - } else { - byScope.set(spawner, [message]) - } - } - } - return { rowIds, byScope } -} - -/** Later roster first; within one roster, the agent whose rows began later. */ -function compareAddedAt(sections: NativeChatSubagentSections, a: string, b: string): number { - const at = (agentId: string) => sections.rosters.get(agentId)?.position - const [rosterA, rosterB] = [at(a), at(b)] - if (rosterA !== undefined && rosterB !== undefined) { - const byRoster = compareAgentJournalPositions(rosterA, rosterB) - if (byRoster !== 0) { - return byRoster - } - } else if (rosterA !== rosterB) { - return rosterA === undefined ? -1 : 1 - } - return compareMessages(sections.rows.get(a)![0]!.message, sections.rows.get(b)![0]!.message) -} diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-reveal.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-reveal.ts deleted file mode 100644 index 27a2dd7cc58..00000000000 --- a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-reveal.ts +++ /dev/null @@ -1,142 +0,0 @@ -// Reveal a subagent named from outside the transcript (the agents strip): open what holds it, then land on its newest drawn activity, else its head or entry. - -import { useLayoutEffect, useRef } from 'react' -import type { StructuredAgentSubagentRoster } from '../../../../../shared/agent-session/view/structured-agent-session-subagent-roster' -import { - isSubagentGroupBlock, - type NativeChatMessage -} from '../../../../../shared/native-chat/native-chat-types' -import type { NativeChatSubagentSections } from './native-chat-subagent-sections' -import { nativeChatSlotIndexOf } from '../conversation/slots/native-chat-transcript-slot-selection' -import type { NativeChatTranscriptSlot } from '../conversation/slots/native-chat-transcript-slots' -import type { NativeChatAlignEdge } from '../viewport/virtualization/use-native-chat-viewport-align' - -/** - * The drawn "Kicked off" row first naming an agent. Siblings' roster items fold into the first - * sibling's row, so an agent's own roster item id can name a row that is never drawn. - * Scanned only on a reveal, never per frame. - */ -function rosterRowNaming( - conversation: readonly NativeChatMessage[], - agentId: string -): string | undefined { - return conversation.find((message) => - message.blocks.some( - (block) => isSubagentGroupBlock(block) && block.agents.some(({ id }) => id === agentId) - ) - )?.id -} - -/** Where a subagent is in the committed transcript, and what opening it takes. */ -export type NativeChatSubagentPlace = - | { kind: 'open'; agentIds: readonly string[]; rosterRowId?: string } - | { kind: 'page' } - | { kind: 'stop' } - -export function nativeChatSubagentPlace({ - agentId, - conversation, - sections, - roster, - slots, - historyFloor -}: { - agentId: string - conversation: readonly NativeChatMessage[] - sections: NativeChatSubagentSections - roster: StructuredAgentSubagentRoster | undefined - slots: readonly NativeChatTranscriptSlot[] - /** The sequence the transcript's history starts from (its latest `/clear`); 0 for none. Read only when nothing loaded names the agent. */ - historyFloor: () => number -}): NativeChatSubagentPlace { - const first = sections.rows.get(agentId)?.[0] - if (first !== undefined) { - // Nested sections open from the outermost, which also opens the roster anchoring it. - return { kind: 'open', agentIds: sections.pathOf.get(first.message.id) ?? [agentId] } - } - // No activity yet (just spawned, or an agent that reports only its final reply): its roster entry. - const rosterRowId = rosterRowNaming(conversation, agentId) - if (rosterRowId !== undefined && nativeChatSlotIndexOf(slots, rosterRowId) !== -1) { - return { kind: 'open', agentIds: [agentId], rosterRowId } - } - const named = roster?.get(agentId) - if (named === undefined) { - return { kind: 'stop' } - } - // A spawn before the latest /clear is outside this conversation: no page reaches it. - return named.rosterPosition.sequence < historyFloor() ? { kind: 'stop' } : { kind: 'page' } -} - -export type NativeChatSubagentLanding = { - index: number - edge: NativeChatAlignEdge - /** Set when the landing is the agent's entry inside a roster slot rather than a slot of its own. */ - entryId?: string -} - -/** Newest drawn activity (streaming stubs draw nothing), else the section head, else the roster entry. */ -export function nativeChatSubagentLanding( - slots: readonly NativeChatTranscriptSlot[], - conversation: readonly NativeChatMessage[], - sections: NativeChatSubagentSections, - agentId: string -): NativeChatSubagentLanding | null { - const rowIds = new Set(sections.rows.get(agentId)?.map(({ message }) => message.id)) - const activity = slots.findLastIndex( - (slot) => - slot.kind === 'message' && - (rowIds.has(slot.message.id) || slot.workRun?.some(({ id }) => rowIds.has(id)) === true) - ) - if (activity !== -1) { - return { index: activity, edge: 'end' } - } - const head = slots.findIndex((slot) => slot.kind === 'subagent' && slot.agentId === agentId) - if (head !== -1) { - return { index: head, edge: 'start' } - } - const entries = slots.findIndex( - (slot) => slot.kind === 'subagent-entries' && slot.agents.some(({ id }) => id === agentId) - ) - const rosterRowId = sections.rosters.get(agentId)?.rowId ?? rosterRowNaming(conversation, agentId) - const index = entries !== -1 ? entries : nativeChatSlotIndexOf(slots, rosterRowId) - return index >= 0 ? { index, edge: 'start', entryId: agentId } : null -} - -/** Land once per request: the row's end for activity, or the entry when the row's start would leave it below the view. */ -export function useNativeChatSubagentLanding({ - request, - landing, - scrollRef, - scrollMessageToTop, - release -}: { - request: { requestId: number } | null - landing: NativeChatSubagentLanding | null - scrollRef: React.RefObject - scrollMessageToTop: (element: HTMLElement, edge?: NativeChatAlignEdge) => void - release: (request: null) => void -}): void { - const servicedRef = useRef(0) - useLayoutEffect(() => { - if (request === null || servicedRef.current === request.requestId) { - return - } - servicedRef.current = request.requestId - const scroller = scrollRef.current - const row = - landing === null - ? null - : scroller?.querySelector(`[data-index="${landing.index}"]`) - if (scroller && landing && row) { - const entry = Array.from( - row.querySelectorAll('[data-native-chat-subagent-entry]') - ).find((element) => element.dataset.nativeChatSubagentEntry === landing.entryId) - const entryBelowView = - entry !== undefined && - entry.getBoundingClientRect().bottom - row.getBoundingClientRect().top > - scroller.clientHeight - scrollMessageToTop(entryBelowView ? entry : row, entryBelowView ? 'start' : landing.edge) - } - release(null) - }, [landing, release, request, scrollMessageToTop, scrollRef]) -} diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-scope.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-scope.ts new file mode 100644 index 00000000000..a91cec062fb --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-scope.ts @@ -0,0 +1,149 @@ +// A transcript shows one scope: the conversation, or one subagent's own rows. Subagents open in their own view, never inline. + +import { compareAgentJournalPositions } from '../../../../../shared/agent-session/journal/agent-session-journal-position' +import type { StructuredAgentSubagentRoster } from '../../../../../shared/agent-session/view/structured-agent-session-subagent-roster' +import { + isSubagentGroupBlock, + type NativeChatMessage, + type NativeChatSubagentEntry +} from '../../../../../shared/native-chat/native-chat-types' +import type { NativeChatSubagentRow } from '../../../../../shared/native-chat/transcript/native-chat-transcript-projection' +import { compareNativeChatDisplayMessages as compareMessages } from '../../../../../shared/native-chat/transcript/native-chat-message-order' + +const NO_HEADS: readonly NativeChatSubagentHead[] = [] + +/** A subagent no roster row in its scope names: it opens from a head at its first row. */ +export type NativeChatSubagentHead = { + agentId: string + entry: NativeChatSubagentEntry | undefined + first: NativeChatSubagentRow +} + +function* rosterAgents(rows: readonly NativeChatMessage[]): Generator { + for (const message of rows) { + for (const block of message.blocks) { + if (isSubagentGroupBlock(block)) { + yield* block.agents + } + } + } +} + +/** Every subagent the session names, oldest roster first: a loaded roster's entry, else the client's. */ +export function nativeChatSubagentEntries( + conversation: readonly NativeChatMessage[], + subagentRows: ReadonlyMap, + roster?: StructuredAgentSubagentRoster +): ReadonlyMap { + const loaded = new Map() + const take = (rows: readonly NativeChatMessage[]): void => { + for (const agent of rosterAgents(rows)) { + if (!loaded.has(agent.id)) { + loaded.set(agent.id, agent) + } + } + } + take(conversation) + for (const rows of subagentRows.values()) { + take(rows.map(({ message }) => message)) + } + const position = (agentId: string) => roster?.get(agentId)?.rosterPosition + const ids = Array.from(new Set([...(roster?.keys() ?? []), ...loaded.keys()])).sort((a, b) => { + const [left, right] = [position(a), position(b)] + return left && right ? compareAgentJournalPositions(left, right) : left ? -1 : right ? 1 : 0 + }) + return new Map( + ids.map((agentId) => [agentId, loaded.get(agentId) ?? roster!.get(agentId)!.entry]) + ) +} + +/** The subagent that spawned this one, when its rows are loaded; null for the session's own agent. */ +function spawnerOf( + agentId: string, + subagentRows: ReadonlyMap +): string | null { + const parent = subagentRows.get(agentId)?.[0]?.message.parentAgentId + return parent !== undefined && parent !== agentId && subagentRows.has(parent) ? parent : null +} + +/** The spawn chain down to a subagent, outermost first; a loop stops at the first repeat. */ +export function nativeChatSubagentChain( + agentId: string, + subagentRows: ReadonlyMap +): readonly string[] { + const chain = [agentId] + for ( + let spawner = spawnerOf(agentId, subagentRows); + spawner !== null && !chain.includes(spawner); + spawner = spawnerOf(spawner, subagentRows) + ) { + chain.unshift(spawner) + } + return chain +} + +/** Subagents the scope's agent (null: the session's) spawned that no roster row in `scopeRows` names. */ +export function nativeChatSubagentHeads( + scopeRows: readonly NativeChatMessage[], + subagentRows: ReadonlyMap, + entries: ReadonlyMap, + scopeAgentId: string | null +): readonly NativeChatSubagentHead[] { + if (subagentRows.size === 0) { + return NO_HEADS + } + const named = new Set(Array.from(rosterAgents(scopeRows), ({ id }) => id)) + const heads: NativeChatSubagentHead[] = [] + for (const [agentId, rows] of subagentRows) { + const first = rows[0] + if ( + first !== undefined && + !named.has(agentId) && + agentId !== scopeAgentId && + spawnerOf(agentId, subagentRows) === scopeAgentId + ) { + heads.push({ agentId, entry: entries.get(agentId), first }) + } + } + return heads.sort((a, b) => compareMessages(a.first.message, b.first.message)) +} + +/** Sort child rows separately so parent-only streaming updates reuse their order. */ +export function nativeChatSubagentRowsInOrder( + subagentRows: ReadonlyMap +): readonly NativeChatSubagentRow[] { + return Array.from(subagentRows.values()) + .flat() + .sort((a, b) => compareMessages(a.message, b.message)) +} + +/** Merge parent and child rows in transcript order so child edits remain in their actual turns. */ +export function nativeChatRowsInTranscriptOrder( + messages: readonly NativeChatMessage[], + turnKeys: readonly (string | undefined)[], + subagentRows: readonly NativeChatSubagentRow[] +): { messages: readonly NativeChatMessage[]; turnKeys: readonly (string | undefined)[] } { + if (subagentRows.length === 0) { + return { messages, turnKeys } + } + const merged: NativeChatMessage[] = [] + const mergedTurnKeys: (string | undefined)[] = [] + let next = 0 + for (const [index, message] of messages.entries()) { + while ( + next < subagentRows.length && + compareMessages(subagentRows[next]!.message, message) < 0 + ) { + merged.push(subagentRows[next]!.message) + mergedTurnKeys.push(subagentRows[next]!.turnKey) + next += 1 + } + merged.push(message) + mergedTurnKeys.push(turnKeys[index]) + } + for (const row of subagentRows.slice(next)) { + merged.push(row.message) + mergedTurnKeys.push(row.turnKey) + } + return { messages: merged, turnKeys: mergedTurnKeys } +} diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-section-slots.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-section-slots.ts deleted file mode 100644 index 3168299ff4f..00000000000 --- a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-section-slots.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { - NativeChatMessage, - NativeChatSubagentEntry -} from '../../../../../shared/native-chat/native-chat-types' -import { - normalizeSubagentState, - subagentGroupBlocks -} from '../../../../../shared/native-chat/transcript/native-chat-subagent-summary' -import { nativeChatRowRendersContent } from '../../../../../shared/native-chat/transcript/native-chat-row-content' -import { - type NativeChatRowTypography, - estimateNativeChatRowHeight, - nativeChatRowContentMetrics, - NATIVE_CHAT_SUBAGENT_SECTION_HEAD_PX -} from '../viewport/virtualization/native-chat-row-height-estimate' -import type { NativeChatResolvedPrompt } from '../../../../../shared/native-chat/native-chat-resolution-receipt' -import { compareNativeChatDisplayMessages as compareMessages } from '../../../../../shared/native-chat/transcript/native-chat-message-order' -import { - nativeChatSubagentEntryRuns, - type NativeChatSubagentChoices, - type NativeChatSubagentRosterState, - type NativeChatSubagentSections -} from './native-chat-subagent-sections' -import { - nativeChatRowRendersProse, - nativeChatRowSpeaksOrActs -} from '../../../../../shared/native-chat/native-chat-trailing-run' -import type { NativeChatTranscriptSlot } from '../conversation/slots/native-chat-transcript-slots' - -/** The head of a section no loaded roster row holds: it names whose rows follow it. */ -export type NativeChatSubagentSectionSlot = { - kind: 'subagent' - agentId: string - /** The conversation turn this head sits in, so the outline rail can place it. */ - turnKey: string | undefined - /** The roster's entry for the agent; absent when no loaded roster names it. */ - entry: NativeChatSubagentEntry | undefined - expanded: boolean - /** Sections this head sits inside; 0 is the conversation. */ - depth: number - estimatedHeight: number -} - -/** A roster row's entries after an open child's rows, through the next open one. */ -export type NativeChatSubagentEntriesSlot = { - kind: 'subagent-entries' - rosterRowId: string - agents: readonly NativeChatSubagentEntry[] - sections: ReadonlyMap - turnKey: string | undefined - depth: number - estimatedHeight: number -} - -/** Place children under loaded roster entries or their first rows; live-frontier defaults yield to reader section/list choices. */ -export function nativeChatSubagentSectionSlots({ - sections, - choices, - live, - receipts, - slots, - typography -}: { - sections: NativeChatSubagentSections - choices: NativeChatSubagentChoices - live: ReadonlySet - receipts: ReadonlyMap - slots: NativeChatTranscriptSlot[] - typography?: NativeChatRowTypography -}) { - const isOpen = (agentId: string): boolean => choices.sections.get(agentId) ?? live.has(agentId) - const pushHead = (agentId: string, depth: number, turnKey: string | undefined): void => { - slots.push({ - kind: 'subagent', - agentId, - turnKey, - entry: sections.entries.get(agentId), - expanded: isOpen(agentId), - depth, - estimatedHeight: NATIVE_CHAT_SUBAGENT_SECTION_HEAD_PX - }) - } - /** Use the section’s displayed turn for all child rows so the outline rail tracks reader position. */ - const pushRows = (agentId: string, depth: number, turnKey: string | undefined): void => { - const rows = sections.rows.get(agentId) ?? [] - const entry = sections.entries.get(agentId) - const working = entry !== undefined && normalizeSubagentState(entry.state) === 'working' - const trailing = rows.findLastIndex((row) => - nativeChatRowSpeaksOrActs(row.message, nativeChatRowRendersProse(row.message), receipts) - ) - const pending = [...(sections.openAt.get(agentId) ?? [])] - for (const [index, { message }] of rows.entries()) { - openBefore(pending, message, depth, { turnKey }) - const receipt = receipts.get(message.id) - if (receipt === undefined && !nativeChatRowRendersContent(message.blocks)) { - continue - } - slots.push({ - kind: 'message', - message, - turnKey, - activeTurnIsWorking: working, - trailingRun: index === trailing, - receipt, - status: undefined, - folded: false, - drawsMessage: true, - turnFolds: false, - turnDiff: undefined, - subagentRoster: undefined, - depth, - estimatedHeight: estimateNativeChatRowHeight( - nativeChatRowContentMetrics(message, typography), - { - hasReceipt: receipt !== undefined, - hasStatus: false, - hasTurnDiff: false, - inSubagentSection: true - }, - typography - ) - }) - } - openBefore(pending, undefined, depth, { turnKey }) - } - /** Emit heads before the next message; undefined flushes all. Nested sections inherit their enclosing turn. */ - function openBefore( - pending: string[], - message: NativeChatMessage | undefined, - depth: number, - section?: { turnKey: string | undefined } - ) { - while (pending.length > 0) { - const agentId = pending[0]! - const first = sections.rows.get(agentId)?.[0] - if ( - message !== undefined && - first !== undefined && - compareMessages(first.message, message) >= 0 - ) { - return - } - pending.shift() - const turnKey = section ? section.turnKey : first?.turnKey - pushHead(agentId, depth, turnKey) - if (isOpen(agentId)) { - pushRows(agentId, depth + 1, turnKey) - } - } - } - return { - openBefore, - /** Each open child's rows under its entry, then the entries up to the next open one. */ - openAnchoredAt( - message: NativeChatMessage, - roster: NativeChatSubagentRosterState | undefined, - turnKey: string | undefined - ): void { - // No child has rows yet (a reveal before first activity): the roster row draws every entry. - if (!roster?.open || roster.sections.size === 0) { - return - } - for (const group of subagentGroupBlocks(message.blocks)) { - let opened: string | undefined - for (const agents of nativeChatSubagentEntryRuns(group.agents, roster.sections)) { - if (opened !== undefined) { - pushRows(opened, 1, turnKey) - if (agents.length > 0) { - slots.push({ - kind: 'subagent-entries', - rosterRowId: message.id, - agents, - sections: roster.sections, - turnKey, - depth: 0, - estimatedHeight: agents.length * NATIVE_CHAT_SUBAGENT_SECTION_HEAD_PX - }) - } - } - opened = agents.at(-1)?.id - } - } - }, - rosterAt(messageId: string): NativeChatSubagentRosterState | undefined { - const anchored = sections.anchoredAt.get(messageId) - if (anchored === undefined) { - // A roster whose children have no rows yet still holds an open it was given (a strip reveal). - const chosen = choices.rosters.get(messageId) - return chosen === undefined ? undefined : { open: chosen, sections: NO_ROSTER_SECTIONS } - } - return { - open: - choices.rosters.get(messageId) ?? - anchored.some((agentId) => choices.sections.has(agentId) || live.has(agentId)), - sections: new Map(anchored.map((agentId) => [agentId, isOpen(agentId)])) - } - } - } -} - -const NO_ROSTER_SECTIONS: ReadonlyMap = new Map() diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-sections.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-sections.ts deleted file mode 100644 index f260e3688ba..00000000000 --- a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-sections.ts +++ /dev/null @@ -1,216 +0,0 @@ -// Retained roster metadata names windowed-out spawns; place children under loaded roster entries, their parent section or their first conversation row. - -import type { AgentJournalPosition } from '../../../../../shared/agent-session/journal/agent-session-journal-types' -import { - isSubagentGroupBlock, - type NativeChatMessage, - type NativeChatSubagentEntry -} from '../../../../../shared/native-chat/native-chat-types' -import type { NativeChatSubagentRow } from '../../../../../shared/native-chat/transcript/native-chat-transcript-projection' -import type { StructuredAgentSubagentRoster } from '../../../../../shared/agent-session/view/structured-agent-session-subagent-roster' -import { compareNativeChatDisplayMessages as compareMessages } from '../../../../../shared/native-chat/transcript/native-chat-message-order' - -export type NativeChatSubagentRosterPlace = { - rowId: string - position: AgentJournalPosition | undefined -} - -export type NativeChatSubagentSections = { - rows: ReadonlyMap - /** The entry of the first roster naming each subagent: a loaded one, else the client's. */ - entries: ReadonlyMap - rosters: ReadonlyMap - /** Roster row id → the subagents whose sections open under it, in roster order. */ - anchoredAt: ReadonlyMap - /** Unanchored sections by enclosing agent (null for conversation), ordered by first row. */ - openAt: ReadonlyMap - /** Row id → the sections enclosing it, outermost first. */ - pathOf: ReadonlyMap -} - -/** Explicit reader choices override defaults; closed roster lists hide their child sections. */ -export type NativeChatSubagentChoices = { - sections: ReadonlyMap - rosters: ReadonlyMap -} - -export const NO_NATIVE_CHAT_SUBAGENT_CHOICES: NativeChatSubagentChoices = { - sections: new Map(), - rosters: new Map() -} - -/** An open child’s rows follow its roster entry. */ -export type NativeChatSubagentRosterState = { - open: boolean - sections: ReadonlyMap -} - -/** Break roster entries after each open child; later entry runs follow that child’s rows. */ -export function nativeChatSubagentEntryRuns( - agents: readonly NativeChatSubagentEntry[], - sections: ReadonlyMap -): NativeChatSubagentEntry[][] { - let run: NativeChatSubagentEntry[] = [] - const runs = [run] - for (const agent of agents) { - run.push(agent) - if (sections.get(agent.id) === true) { - run = [] - runs.push(run) - } - } - return runs -} - -export type NativeChatSubagentDisclosure = { - setSectionOpen: (agentId: string, open: boolean) => void - setRosterOpen: (rosterRowId: string, open: boolean) => void -} - -export const NO_NATIVE_CHAT_SUBAGENT_SECTIONS: NativeChatSubagentSections = { - rows: new Map(), - entries: new Map(), - rosters: new Map(), - anchoredAt: new Map(), - openAt: new Map(), - pathOf: new Map() -} - -export function nativeChatSubagentSections( - conversation: readonly NativeChatMessage[], - subagentRows: ReadonlyMap, - roster?: StructuredAgentSubagentRoster -): NativeChatSubagentSections { - const rows = new Map(Array.from(subagentRows).filter(([, agentRows]) => agentRows.length > 0)) - if (rows.size === 0) { - return NO_NATIVE_CHAT_SUBAGENT_SECTIONS - } - const entries = new Map() - const rosters = new Map() - for (const message of conversation) { - for (const block of message.blocks) { - if (!isSubagentGroupBlock(block)) { - continue - } - for (const agent of block.agents) { - if (!entries.has(agent.id) && rows.has(agent.id)) { - entries.set(agent.id, agent) - rosters.set(agent.id, { rowId: message.id, position: message.journalPosition }) - } - } - } - } - // Loaded rosters anchor their sections; the client's names the rest. - const anchored = new Set(rosters.keys()) - for (const agentId of rows.keys()) { - const named = anchored.has(agentId) ? undefined : roster?.get(agentId) - if (named !== undefined) { - entries.set(agentId, named.entry) - rosters.set(agentId, { rowId: named.rosterItemId, position: named.rosterPosition }) - } - } - const firstRow = (agentId: string): NativeChatMessage => rows.get(agentId)![0]!.message - const spawnerOf = (agentId: string): string | null => { - const parent = firstRow(agentId).parentAgentId - return parent !== undefined && parent !== agentId && rows.has(parent) ? parent : null - } - // A chain of spawners that loops back has no outside to open in. - const scopeOf = (agentId: string): string | null => { - const spawner = spawnerOf(agentId) - const seen = new Set([agentId]) - for (let current = spawner; current !== null; current = spawnerOf(current)) { - if (seen.has(current)) { - return null - } - seen.add(current) - } - return spawner - } - const scopes = new Map() - const openAt = new Map() - for (const agentId of rows.keys()) { - const scope = scopeOf(agentId) - scopes.set(agentId, scope) - if (scope !== null || !anchored.has(agentId)) { - const inScope = openAt.get(scope) - if (inScope) { - inScope.push(agentId) - } else { - openAt.set(scope, [agentId]) - } - } - } - for (const inScope of openAt.values()) { - inScope.sort((a, b) => compareMessages(firstRow(a), firstRow(b))) - } - const anchoredAt = new Map() - for (const [agentId, { rowId }] of rosters) { - if (anchored.has(agentId) && scopes.get(agentId) === null) { - const anchored = anchoredAt.get(rowId) - if (anchored) { - anchored.push(agentId) - } else { - anchoredAt.set(rowId, [agentId]) - } - } - } - const paths = new Map() - const pathTo = (agentId: string): readonly string[] => { - const known = paths.get(agentId) - if (known) { - return known - } - const scope = scopes.get(agentId) ?? null - const path = scope === null ? [agentId] : [...pathTo(scope), agentId] - paths.set(agentId, path) - return path - } - const pathOf = new Map() - for (const [agentId, agentRows] of rows) { - const path = pathTo(agentId) - for (const row of agentRows) { - pathOf.set(row.message.id, path) - } - } - return { rows, entries, rosters, anchoredAt, openAt, pathOf } -} - -/** Sort child rows separately so parent-only streaming updates reuse their order. */ -export function nativeChatSubagentRowsInOrder( - subagentRows: ReadonlyMap -): readonly NativeChatSubagentRow[] { - return Array.from(subagentRows.values()) - .flat() - .sort((a, b) => compareMessages(a.message, b.message)) -} - -/** Merge parent and child rows in transcript order so child edits remain in their actual turns. */ -export function nativeChatRowsInTranscriptOrder( - messages: readonly NativeChatMessage[], - turnKeys: readonly (string | undefined)[], - subagentRows: readonly NativeChatSubagentRow[] -): { messages: readonly NativeChatMessage[]; turnKeys: readonly (string | undefined)[] } { - if (subagentRows.length === 0) { - return { messages, turnKeys } - } - const merged: NativeChatMessage[] = [] - const mergedTurnKeys: (string | undefined)[] = [] - let next = 0 - for (const [index, message] of messages.entries()) { - while ( - next < subagentRows.length && - compareMessages(subagentRows[next]!.message, message) < 0 - ) { - merged.push(subagentRows[next]!.message) - mergedTurnKeys.push(subagentRows[next]!.turnKey) - next += 1 - } - merged.push(message) - mergedTurnKeys.push(turnKeys[index]) - } - for (const row of subagentRows.slice(next)) { - merged.push(row.message) - mergedTurnKeys.push(row.turnKey) - } - return { messages: merged, turnKeys: mergedTurnKeys } -} diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-switcher-items.test.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-switcher-items.test.ts new file mode 100644 index 00000000000..c4ecc924d02 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-switcher-items.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatSubagentRow } from '../../../../../shared/native-chat/transcript/native-chat-transcript-projection' +import { nativeChatSubagentSwitcherItems } from './native-chat-subagent-switcher-items' + +function rows(id: string, parentAgentId?: string): [string, NativeChatSubagentRow[]] { + return [ + id, + [ + { + message: { + id: `${id}-row`, + role: 'assistant', + blocks: [], + timestamp: 1, + source: 'transcript', + ...(parentAgentId ? { parentAgentId } : {}) + }, + turnKey: undefined + } + ] + ] +} + +describe('nativeChatSubagentSwitcherItems', () => { + it('lists each child under the subagent that spawned it and numbers unnamed agents', () => { + const entries = new Map([ + ['a', { id: 'a', label: 'Audit', state: 'working' as const }], + ['b', { id: 'b', label: 'Build', state: 'completed' as const }] + ]) + const subagentRows = new Map([rows('a'), rows('c', 'a'), rows('d', 'c'), rows('b'), rows('e')]) + expect(nativeChatSubagentSwitcherItems(entries, subagentRows, 'Subagent')).toEqual([ + { id: 'a', label: 'Audit', depth: 0 }, + { id: 'c', label: 'Subagent', depth: 1 }, + { id: 'd', label: 'Subagent 2', depth: 2 }, + { id: 'b', label: 'Build', depth: 0 }, + { id: 'e', label: 'Subagent 3', depth: 0 } + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/subagents/native-chat-subagent-switcher-items.ts b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-switcher-items.ts new file mode 100644 index 00000000000..8b800eea92e --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/native-chat-subagent-switcher-items.ts @@ -0,0 +1,47 @@ +import { disambiguateSubagentLabels } from '../../../../../shared/native-chat/transcript/native-chat-subagent-items' +import type { NativeChatSubagentEntry } from '../../../../../shared/native-chat/native-chat-types' +import type { NativeChatSubagentRow } from '../../../../../shared/native-chat/transcript/native-chat-transcript-projection' +import { nativeChatSubagentChain } from './native-chat-subagent-scope' + +export type NativeChatSubagentSwitcherItem = { id: string; label: string; depth: number } + +/** Every subagent, each spawner's children right under it; repeated or missing names get ordinals. */ +export function nativeChatSubagentSwitcherItems( + entries: ReadonlyMap, + subagentRows: ReadonlyMap, + unnamed: string +): readonly NativeChatSubagentSwitcherItem[] { + const ids = [ + ...entries.keys(), + ...Array.from(subagentRows.keys()).filter((id) => !entries.has(id)) + ] + const labels = new Map( + disambiguateSubagentLabels(ids.map((id) => entries.get(id)?.label ?? unnamed)).map( + (label, index) => [ids[index]!, label] + ) + ) + const parentOf = new Map( + ids.map((id) => [id, nativeChatSubagentChain(id, subagentRows).at(-2) ?? null]) + ) + const items: NativeChatSubagentSwitcherItem[] = [] + const placed = new Set() + const place = (parent: string | null, depth: number): void => { + for (const id of ids) { + if (!placed.has(id) && parentOf.get(id) === parent) { + placed.add(id) + items.push({ id, label: labels.get(id)!, depth }) + place(id, depth + 1) + } + } + } + place(null, 0) + // A spawner no entry names leaves its children unplaced; they list at the top level. + for (const id of ids) { + if (!placed.has(id)) { + placed.add(id) + items.push({ id, label: labels.get(id)!, depth: 0 }) + place(id, 1) + } + } + return items +} diff --git a/src/renderer/src/components/native-chat/subagents/use-native-chat-subagent-scope.tsx b/src/renderer/src/components/native-chat/subagents/use-native-chat-subagent-scope.tsx new file mode 100644 index 00000000000..63911ce7d58 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/use-native-chat-subagent-scope.tsx @@ -0,0 +1,197 @@ +import { useEffect, useMemo } from 'react' +import { useTranslate, type Translate } from '@/i18n/use-translate' +import { latestAgentSessionContextClearSequence } from '../../../../../shared/agent-session/agent-session-context-clear' +import type { AgentSessionLatestTurn } from '../../../../../shared/agent-session/agent-session-wire' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../../shared/agent-session/journal/agent-session-journal-types' +import type { StructuredAgentSubagentRoster } from '../../../../../shared/agent-session/view/structured-agent-session-subagent-roster' +import type { + NativeChatMessage, + NativeChatSubagentEntry +} from '../../../../../shared/native-chat/native-chat-types' +import { normalizeSubagentState } from '../../../../../shared/native-chat/transcript/native-chat-subagent-summary' +import type { NativeChatSubagentRow } from '../../../../../shared/native-chat/transcript/native-chat-transcript-projection' +import { + useNativeChatTurnMembership, + type NativeChatTurnRows +} from '../conversation/slots/use-native-chat-turn-membership' +import type { NativeChatSubagentView } from './native-chat-open-subagent' +import { nativeChatSubagentHistoryStep } from './native-chat-subagent-history' +import { + nativeChatSubagentChain, + nativeChatSubagentEntries, + nativeChatSubagentHeads, + nativeChatSubagentRowsInOrder +} from './native-chat-subagent-scope' +import { + nativeChatSubagentEnding, + nativeChatSubagentEndingMessage, + type NativeChatSubagentAside +} from './native-chat-subagent-ending' +import { NativeChatSubagentViewBar } from './NativeChatSubagentViewBar' +import { NativeChatSubagentPrompt } from './NativeChatSubagentPrompt' +import { nativeChatSubagentHandoff } from './native-chat-subagent-handoff' + +const NO_MESSAGES: readonly NativeChatMessage[] = [] +const NO_SUBAGENT_ROWS: readonly NativeChatSubagentRow[] = [] +const NO_TURN_KEYS: readonly (string | undefined)[] = [] +const NO_ENTRIES: ReadonlyMap = new Map() +const NO_CHAIN: readonly string[] = [] + +function asideText(aside: NativeChatSubagentAside, t: Translate): string { + switch (aside) { + case 'no-steps': + return t('components.native-chat.subagents.noSteps', 'No steps yet') + case 'cleared': + return t( + 'components.native-chat.subagents.beforeClear', + 'This subagent ran before the chat was cleared' + ) + case 'stopped': + return t('components.native-chat.subagents.stoppedBeforeReply', 'Stopped before replying') + case 'no-reply': + return t('components.native-chat.subagents.noReply', 'Ended without a reply') + } +} + +/** What a transcript draws: the conversation in its turns, or one subagent's own rows with none. */ +export function useNativeChatSubagentScope({ + view, + conversation, + subagentRows, + roster, + journalItems, + journalSubmissions, + latestTurn, + isWorking +}: { + view: NativeChatSubagentView | undefined + conversation: readonly NativeChatMessage[] + subagentRows: ReadonlyMap + roster: StructuredAgentSubagentRoster | undefined + journalItems: readonly AgentJournalRenderItem[] | undefined + journalSubmissions: readonly AgentJournalSubmission[] | undefined + latestTurn: AgentSessionLatestTurn | null | undefined + isWorking: boolean +}) { + const agentId = view?.agentId ?? null + // The conversation names its subagents only for heads, which need rows; a subagent's bar lists them all. + const entries = useMemo( + () => + agentId === null && subagentRows.size === 0 + ? NO_ENTRIES + : nativeChatSubagentEntries(conversation, subagentRows, roster), + [agentId, conversation, roster, subagentRows] + ) + const t = useTranslate() + const entry = agentId === null ? undefined : entries.get(agentId) + const chain = useMemo( + () => (agentId === null ? NO_CHAIN : nativeChatSubagentChain(agentId, subagentRows)), + [agentId, subagentRows] + ) + const parentId = chain.at(-2) ?? null + const noteParent = view?.noteParent + useEffect(() => { + noteParent?.(parentId) + }, [noteParent, parentId]) + const handoff = useMemo( + () => + agentId === null + ? null + : nativeChatSubagentHandoff({ agentId, journalItems, conversation, subagentRows }), + [agentId, conversation, journalItems, subagentRows] + ) + // A subagent's view pages history only while older history still holds its spawn. + const history = useMemo( + () => + agentId === null + ? 'page' + : nativeChatSubagentHistoryStep({ + agentId, + conversation, + subagentRows, + roster, + historyFloor: () => latestAgentSessionContextClearSequence(journalItems ?? []) + }), + [agentId, conversation, journalItems, roster, subagentRows] + ) + const messages = useMemo(() => { + if (agentId === null) { + return conversation + } + const own = (subagentRows.get(agentId) ?? NO_SUBAGENT_ROWS).map(({ message }) => message) + const ending = nativeChatSubagentEnding({ entry, handoff, rows: own, history }) + return ending === null + ? own + : [ + ...own, + nativeChatSubagentEndingMessage( + agentId, + ending, + ending.kind === 'aside' ? asideText(ending.aside, t) : '', + entry?.settledAt + ) + ] + }, [agentId, conversation, entry, handoff, history, subagentRows, t]) + const heads = useMemo( + () => nativeChatSubagentHeads(messages, subagentRows, entries, agentId), + [agentId, entries, messages, subagentRows] + ) + // Child edits and PRs count in the conversation's turns, which a subagent's view does not draw. + const rowsInOrder = useMemo( + () => (agentId === null ? nativeChatSubagentRowsInOrder(subagentRows) : NO_SUBAGENT_ROWS), + [agentId, subagentRows] + ) + const conversationTurns = useNativeChatTurnMembership( + agentId === null ? messages : NO_MESSAGES, + journalItems, + journalSubmissions, + latestTurn + ) + const turnRows = useMemo( + () => + agentId === null + ? conversationTurns + : { messages, turnKeys: NO_TURN_KEYS, liveTurnKey: undefined }, + [agentId, conversationTurns, messages] + ) + const open = view?.open + return { + agentId, + open, + /** Leads a subagent's transcript: what the main agent asked of it. */ + lead: + agentId !== null && handoff?.prompt ? ( + + ) : null, + /** Drawn above a subagent's own transcript. */ + bar: + agentId !== null && open ? ( + + ) : null, + messages, + heads, + rowsInOrder, + turnRows, + /** The conversation's turns; null in a subagent's view. */ + conversationTurns: agentId === null ? turnRows : null, + pagesHistory: history === 'page', + /** Names the transcript region: the conversation, or the subagent it shows. */ + regionLabel: + agentId === null + ? t('components.native-chat.transcriptLabel', 'Conversation') + : (entry?.label ?? t('components.native-chat.subagents.unnamed', 'Subagent')), + // A subagent's rows sit in the conversation's turns, so its view runs on the agent's own state. + working: + agentId === null + ? isWorking + : entry !== undefined && normalizeSubagentState(entry.state) === 'working' + } +} diff --git a/src/renderer/src/components/native-chat/subagents/use-native-chat-subagent-sections.ts b/src/renderer/src/components/native-chat/subagents/use-native-chat-subagent-sections.ts deleted file mode 100644 index d332712c94d..00000000000 --- a/src/renderer/src/components/native-chat/subagents/use-native-chat-subagent-sections.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' -import type { NativeChatMessage } from '../../../../../shared/native-chat/native-chat-types' -import type { NativeChatSubagentRow } from '../../../../../shared/native-chat/transcript/native-chat-transcript-projection' -import type { StructuredAgentSubagentRoster } from '../../../../../shared/agent-session/view/structured-agent-session-subagent-roster' -import { chooseNativeChatExpanded } from '../messages/disclosure/native-chat-expanded-keys' -import { - NO_NATIVE_CHAT_SUBAGENT_CHOICES, - nativeChatSubagentRowsInOrder, - nativeChatSubagentSections, - type NativeChatSubagentChoices, - type NativeChatSubagentDisclosure, - type NativeChatSubagentSections -} from './native-chat-subagent-sections' - -function choose( - current: NativeChatSubagentChoices, - kind: keyof NativeChatSubagentChoices, - key: string, - open: boolean -): NativeChatSubagentChoices { - const next = chooseNativeChatExpanded(current[kind], key, open) - return next === current[kind] ? current : { ...current, [kind]: next } -} - -/** Preserve manual section/roster choices; otherwise expand only at the running scope’s live frontier. */ -export function useNativeChatSubagentSections( - conversation: readonly NativeChatMessage[], - subagentRows: ReadonlyMap, - roster?: StructuredAgentSubagentRoster -): { - sections: NativeChatSubagentSections - /** Every subagent row in transcript order; kept while only the conversation changes. */ - subagentRowsInOrder: readonly NativeChatSubagentRow[] - subagentChoices: NativeChatSubagentChoices - subagentDisclosure: NativeChatSubagentDisclosure - /** Expand containing sections/rosters so reveals can land on these rows. */ - openSubagentSections: (agentIds: readonly string[]) => void -} { - const sections = useMemo( - () => nativeChatSubagentSections(conversation, subagentRows, roster), - [conversation, roster, subagentRows] - ) - const subagentRowsInOrder = useMemo( - () => nativeChatSubagentRowsInOrder(subagentRows), - [subagentRows] - ) - const [subagentChoices, setChoices] = useState(NO_NATIVE_CHAT_SUBAGENT_CHOICES) - const subagentDisclosure = useMemo( - () => ({ - setSectionOpen: (agentId, open) => - setChoices((current) => choose(current, 'sections', agentId, open)), - setRosterOpen: (rosterRowId, open) => - setChoices((current) => choose(current, 'rosters', rosterRowId, open)) - }), - [] - ) - // Read only by a reveal, so the callback keeps one identity while the transcript streams. - const sectionsRef = useRef(sections) - useLayoutEffect(() => { - sectionsRef.current = sections - }, [sections]) - const openSubagentSections = useCallback((agentIds: readonly string[]) => { - const opening = new Set(agentIds) - const rosterRowIds = Array.from(sectionsRef.current.anchoredAt).flatMap(([rowId, anchored]) => - anchored.some((agentId) => opening.has(agentId)) ? [rowId] : [] - ) - setChoices((current) => - rosterRowIds.reduce( - (choices, rowId) => choose(choices, 'rosters', rowId, true), - agentIds.reduce((choices, agentId) => choose(choices, 'sections', agentId, true), current) - ) - ) - }, []) - return { - sections, - subagentRowsInOrder, - subagentChoices, - subagentDisclosure, - openSubagentSections - } -} diff --git a/src/renderer/src/components/native-chat/subagents/use-structured-native-chat-subagents.tsx b/src/renderer/src/components/native-chat/subagents/use-structured-native-chat-subagents.tsx new file mode 100644 index 00000000000..8685f9349f7 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/use-structured-native-chat-subagents.tsx @@ -0,0 +1,67 @@ +import type { AgentSessionCancelResult } from '../../../../../shared/agent-session/agent-session-wire' +import type { StructuredAgentSubagentRoster } from '../../../../../shared/agent-session/view/structured-agent-session-subagent-roster' +import type { StructuredSessionBackgroundTasksView } from '../../../../../shared/structured-session-background-tasks-view' +import { NativeChatStructuredSessionStatus } from '../tools/tasks/NativeChatStructuredSessionStatus' +import { useStructuredSessionTaskStop } from '../tools/tasks/use-structured-session-task-stop' +import { useTranslate } from '@/i18n/use-translate' +import { + backFromNativeChatSubagent, + closeNativeChatSubagentOnEscape, + useNativeChatOpenSubagent +} from './native-chat-open-subagent' +import { NativeChatStructuredSubagentStatusBar } from './NativeChatSubagentStatusBar' + +/** A structured chat's agents strip and open subagent's bar, which share the strip's one Stop. */ +export function useStructuredNativeChatSubagents({ + sessionId, + paneKey, + isVisible, + controller +}: { + sessionId: string + paneKey: string + isVisible: boolean + controller: { + subagentRoster?: StructuredAgentSubagentRoster + backgroundTasks: StructuredSessionBackgroundTasksView + stopBackgroundTask: (taskId?: string) => Promise + } +}) { + const t = useTranslate() + const open = useNativeChatOpenSubagent(paneKey) + const openId = open?.agentId ?? null + const parentId = open?.parentId ?? null + const stop = useStructuredSessionTaskStop(sessionId, controller) + return { + /** The subagent whose view replaces the conversation and the composer; null for none. */ + openId, + onKeyDown: (event: React.KeyboardEvent) => closeNativeChatSubagentOnEscape(event, paneKey), + strip: ( + + ), + statusBar: + openId === null ? null : ( + backFromNativeChatSubagent(paneKey)} + /> + ) + } +} diff --git a/src/renderer/src/components/native-chat/surfaces/integration/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/surfaces/integration/NativeChatStructuredSession.test-harness.tsx index 77585ea9c24..197d99f5048 100644 --- a/src/renderer/src/components/native-chat/surfaces/integration/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/surfaces/integration/NativeChatStructuredSession.test-harness.tsx @@ -315,8 +315,7 @@ export function createStructuredSessionMocks() { revealLatest: mocks.revealLatest, readerAtEnd: () => true, revealFindMatch: () => {}, - messageCopyText: () => null, - revealSubagent: () => {} + messageCopyText: () => null })) return } diff --git a/src/renderer/src/components/native-chat/tools/diffs/use-native-chat-turn-diffs.ts b/src/renderer/src/components/native-chat/tools/diffs/use-native-chat-turn-diffs.ts index ee56171deec..9f33e8a3437 100644 --- a/src/renderer/src/components/native-chat/tools/diffs/use-native-chat-turn-diffs.ts +++ b/src/renderer/src/components/native-chat/tools/diffs/use-native-chat-turn-diffs.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react' import type { NativeChatSubagentRow } from '../../../../../../shared/native-chat/transcript/native-chat-transcript-projection' -import { nativeChatRowsInTranscriptOrder } from '../../subagents/native-chat-subagent-sections' +import { nativeChatRowsInTranscriptOrder } from '../../subagents/native-chat-subagent-scope' import { nativeChatTurnDiffs, type NativeChatTurnDiff @@ -12,19 +12,13 @@ const NO_TURN_DIFFS: ReadonlyMap = new Map() /** Count subagent edits in their owning turn; omit totals without a journal or for partially loaded turns. */ export function useNativeChatTurnDiffs( turns: NativeChatTurnRows | null, - subagentRows: readonly NativeChatSubagentRow[], - subagentSectionsOf: ReadonlyMap + subagentRows: readonly NativeChatSubagentRow[] ): ReadonlyMap { return useMemo(() => { if (!turns) { return NO_TURN_DIFFS } const merged = nativeChatRowsInTranscriptOrder(turns.messages, turns.turnKeys, subagentRows) - return nativeChatTurnDiffs( - merged.messages, - merged.turnKeys, - subagentSectionsOf, - turns.partialTurnKey - ) - }, [subagentRows, subagentSectionsOf, turns]) + return nativeChatTurnDiffs(merged.messages, merged.turnKeys, undefined, turns.partialTurnKey) + }, [subagentRows, turns]) } diff --git a/src/renderer/src/components/native-chat/tools/pull-requests/use-native-chat-turn-opened-pull-requests.ts b/src/renderer/src/components/native-chat/tools/pull-requests/use-native-chat-turn-opened-pull-requests.ts index 8c59c4b40f0..bcdb23fa1db 100644 --- a/src/renderer/src/components/native-chat/tools/pull-requests/use-native-chat-turn-opened-pull-requests.ts +++ b/src/renderer/src/components/native-chat/tools/pull-requests/use-native-chat-turn-opened-pull-requests.ts @@ -10,7 +10,7 @@ import { type NativeChatTurnOpenedPullRequests } from '../../../../../../shared/native-chat/tools/native-chat-opened-pull-requests' import { nativeChatToolFullPayload } from '../../../../../../shared/native-chat/tools/native-chat-tool-output' -import { nativeChatRowsInTranscriptOrder } from '../../subagents/native-chat-subagent-sections' +import { nativeChatRowsInTranscriptOrder } from '../../subagents/native-chat-subagent-scope' import { NativeChatMarkdownImageOwnerContext } from '../../media/images/native-chat-markdown-image-owner' import { useNativeChatVisualOwner } from '../../media/visuals/native-chat-visual-owner' import type { NativeChatTurnRows } from '../../conversation/slots/use-native-chat-turn-membership' diff --git a/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRun.tsx index 59e4432ce34..3fb636ef14a 100644 --- a/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRun.tsx @@ -22,10 +22,6 @@ import { nativeChatAskRunSubject } from '../../../../../../shared/native-chat/in import { NativeChatAwaitingInputRow } from '../../prompts/NativeChatAwaitingInputRow' import { NativeChatBackgroundTaskRun } from '../tasks/NativeChatBackgroundTaskRun' import { NativeChatSubagentRun } from '../../subagents/NativeChatSubagentRun' -import type { - NativeChatSubagentDisclosure, - NativeChatSubagentRosterState -} from '../../subagents/native-chat-subagent-sections' import { NativeChatToolRunIcon } from '../lines/NativeChatToolIcon' import { NativeChatToolRunCallCounts } from './NativeChatToolRunCallCounts' import { revealNativeChatToolRunMember } from './NativeChatToolRunMembers' @@ -52,8 +48,7 @@ export function NativeChatToolRun({ revealedDiff, onRevealDiff, subagentGroups = NO_SUBAGENT_GROUPS, - subagentRoster, - subagentDisclosure, + onOpenSubagent, backgroundTasks = NO_BACKGROUND_TASKS, followsProse = true, expandSignal, @@ -70,9 +65,8 @@ export function NativeChatToolRun({ onRevealDiff?: (element: HTMLElement) => void /** Spawn-group rosters that belong with this run's activity, one row each. */ subagentGroups?: NativeChatSubagentGroupBlock[] - /** The rosters' list state, and their children whose rows open below this run. */ - subagentRoster?: NativeChatSubagentRosterState - subagentDisclosure?: NativeChatSubagentDisclosure + /** Opens one of the rosters' subagents in its own view. */ + onOpenSubagent?: (agentId: string) => void /** Background tasks that belong with this run's activity, one row each. */ backgroundTasks?: NativeChatBackgroundTaskBlock[] /** The row draws prose above this run, which the run then sits just under. */ @@ -115,14 +109,8 @@ export function NativeChatToolRun({ subagentDisclosure.setRosterOpen(disclosureId, open) - : undefined - } - sections={subagentRoster?.sections} - onSetSectionOpen={subagentDisclosure?.setSectionOpen} + disclosureId={disclosureId} + onOpenSubagent={onOpenSubagent} /> )) // Roster/task rows bypass tool-header hiding because they carry durable state beyond the originating turn. diff --git a/src/renderer/src/components/native-chat/tools/runs/native-chat-transcript-work-runs.ts b/src/renderer/src/components/native-chat/tools/runs/native-chat-transcript-work-runs.ts index 3fc0f456745..ccbf81ca290 100644 --- a/src/renderer/src/components/native-chat/tools/runs/native-chat-transcript-work-runs.ts +++ b/src/renderer/src/components/native-chat/tools/runs/native-chat-transcript-work-runs.ts @@ -1,4 +1,4 @@ -// Combine calls/thoughts after slot selection so every intervening visible row or section boundary ends the run. +// Combine calls/thoughts after slot selection so every intervening visible row ends the run. import { agentJournalItemSubagentId } from '../../../../../../shared/agent-session/journal/agent-session-journal-producer' import { @@ -22,17 +22,12 @@ function workRunRow(slot: NativeChatTranscriptSlot): NativeChatWorkRunRow { return { member: null, draws: true, scope: undefined } } return { - member: - slot.drawsMessage && slot.subagentRoster === undefined - ? nativeChatWorkRunMember( - slot.message, - slot.receipt !== undefined, - // At the top level the live line owns the open thought; only a section has none. - slot.depth > 0 && slot.activeTurnIsWorking - ) - : null, + member: slot.drawsMessage + ? // The live line owns the open thought. + nativeChatWorkRunMember(slot.message, slot.receipt !== undefined, false) + : null, draws: true, - scope: `${slot.depth}:${agentJournalItemSubagentId(slot.message)}:${slot.turnKey}` + scope: `${agentJournalItemSubagentId(slot.message)}:${slot.turnKey}` } } @@ -60,8 +55,7 @@ function workRunSlot( hasStatus: head.status !== undefined, hasTurnDiff: turnDiff !== undefined, turnDiffFileCount: turnDiff?.files.length, - openedPullRequestCount: openedPullRequests?.length, - inSubagentSection: head.depth > 0 + openedPullRequestCount: openedPullRequests?.length }, typography ) diff --git a/src/renderer/src/components/native-chat/tools/tasks/NativeChatBackgroundTasksStatus.test.tsx b/src/renderer/src/components/native-chat/tools/tasks/NativeChatBackgroundTasksStatus.test.tsx index 311c195e851..86213292b62 100644 --- a/src/renderer/src/components/native-chat/tools/tasks/NativeChatBackgroundTasksStatus.test.tsx +++ b/src/renderer/src/components/native-chat/tools/tasks/NativeChatBackgroundTasksStatus.test.tsx @@ -41,7 +41,7 @@ it("shows an agent by the provider's id, beside its own Stop, and leaves command onShowAgent={onShowAgent} /> ) - fireEvent.click(screen.getByRole('button', { name: 'Show child a in the conversation' })) + fireEvent.click(screen.getByRole('button', { name: 'Open child a' })) expect(onShowAgent).toHaveBeenCalledWith('provider-a') expect(onStop).not.toHaveBeenCalled() expect(screen.queryByRole('button', { name: /Show child s/ })).toBeNull() diff --git a/src/renderer/src/components/native-chat/tools/tasks/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/tools/tasks/NativeChatBackgroundTasksStatus.tsx index bb8528460f4..d6ac863269d 100644 --- a/src/renderer/src/components/native-chat/tools/tasks/NativeChatBackgroundTasksStatus.tsx +++ b/src/renderer/src/components/native-chat/tools/tasks/NativeChatBackgroundTasksStatus.tsx @@ -9,6 +9,7 @@ import type { AgentChildWorkView } from '../../../../../../shared/agent-status/c import { AgentChildRowContent } from '@/components/AgentChildRowContent' import { agentChildRowName } from '@/components/agent-child-row-text' import { Button } from '@/components/ui/button' +import { cn } from '@/lib/class-names' import { useTranslate } from '@/i18n/use-translate' import { useNow } from '@/hooks/use-now' import { @@ -19,11 +20,13 @@ import { backgroundTaskRowMeta, backgroundTaskRowStopId, backgroundTasksStripTicks, - buildBackgroundTaskGroupsFromViews, type BackgroundTaskGroup } from '../../../../../../shared/background-task-roster' import { backgroundTasksHeaderContent } from './background-task-header-content' -import { backgroundTaskGroupLabel, buildBackgroundTaskGroups } from './background-task-roster' +import { + backgroundTaskGroupLabel, + structuredSessionBackgroundTaskGroups +} from './background-task-roster' import { sayBackgroundTaskTranslated as say } from './background-task-words-text' /** Use strip width in rem so narrow splits behave like narrow windows even on wide monitors. */ @@ -74,6 +77,8 @@ function BackgroundTaskRow(props: { stoppingTaskIds: ReadonlySet onStop: (taskId: string) => void onShowAgent?: (providerId: string) => void + /** The agent whose view is open, marked current. */ + currentAgentId?: string | null }): React.JSX.Element { const t = useTranslate() const { row, now } = props @@ -107,12 +112,15 @@ function BackgroundTaskRow(props: { {shownId !== undefined ? (